简介:这是一套基于JSP与MVC设计模式、以MySQL为数据库的网上图书购物系统源码,面向Java Web初学者与进阶学习者,可作为毕业设计、课程设计、大作业或工程实训的参考项目。压缩包共76个文件,约47.8MB,包含14个jsp页面文件、12个java源文件与12个class编译文件,另有css样式、xml配置、sql数据库脚本及项目展示视频等,覆盖前端页面、控制层与数据层的主要结构。资源中附有数据库文件与README说明,便于读者理解MVC分层思路、页面跳转逻辑与数据表设计,并在此基础上自行调试、修改与扩展功能。目前已有136人学习下载。需要说明的是,代码仅作参考资料,需具备一定基础方能看懂并自行排错,不能直接照搬复制。
1. 从零搭一套 JSP+MVC+MySQL 图书购物系统:为什么它仍是 JavaWeb 毕设的硬通货
打开招聘网站搜 Java 后端,十有八九写着「熟悉 Spring 全家桶」,可回到课程设计和毕业设计,JSP + MVC + MySQL 这套组合依然是绕不开的硬通货。原因很实在:它把 Servlet、JSP、JavaBean、JDBC、会话管理、过滤器这些 JavaWeb 的底层零件全暴露在你面前,没有框架帮你兜底,写一遍就等于把整个请求响应链路走通了。网上图书购物系统这个题目,恰好把「用户登录注册、图书列表分页、购物车增删改、订单生成、后台管理」这些典型场景全塞进一个项目里,业务闭环完整,数据表关系清晰,特别适合拿来练手。这篇笔记不讲空话,我会按「环境怎么搭、MVC 怎么分层、购物车和订单怎么落地、部署和排错怎么做」的顺序,把一套能跑起来的方案拆开讲清楚。适合正在找基于 JSP 的毕设选题、或者想补 JavaWeb 基础的同学,也适合工作后回头补底层原理的开发者。
2. 环境搭建与 MVC 三层架构的落地方式
2.1 JDK、Tomcat、MySQL 的版本搭配与安装要点
先把地基打牢。这套技术栈对版本不算挑剔,但搭配错了会出各种玄学问题。我一般用 JDK 8 或 JDK 11,Tomcat 8.5 或 9.0,MySQL 5.7 或 8.0,IDE 用 IntelliJ IDEA。JDK 版本别超过 Tomcat 支持的编译级别,否则启动时直接报 UnsupportedClassVersionError。
MySQL 安装是新手翻车最多的地方。Windows 下推荐用官方 installer,安装时勾选 MySQL Server 和 MySQL Workbench,服务名默认 MySQL80。安装过程中会让你设 root 密码,记牢,后面 JDBC 连接要用。Linux 下如果用 rpm 安装,注意先装 mysql-community-common 再装 server,顺序错了依赖会报错。装完执行mysql -u root -p能进命令行就说明服务起来了。
# 检查 MySQL 服务状态(Windows) net start | findstr MySQL # Linux 下启动并设置开机自启 systemctl start mysqld systemctl enable mysqld # 登录后创建项目专用数据库,字符集用 utf8mb4 mysql -u root -p CREATE DATABASE bookshop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;字符集这一步别省。用默认的 latin1 建库,中文图书名存进去就是乱码,后面改起来要重建表,血泪经验。utf8mb4 比 utf8 多支持 emoji 和部分生僻字,图书简介里出现特殊符号也不会炸。
Tomcat 的配置重点在端口和编码。默认 8080 端口如果被占用,改 conf/server.xml 里的 Connector port。URIEncoding 建议显式设成 UTF-8,避免 GET 请求中文参数乱码。
<!-- conf/server.xml 中 Connector 节点 --> <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" />2.2 MVC 三层架构在图书系统里的职责划分
MVC 不是三个文件夹那么简单,核心是职责边界。Model 层管数据和业务,View 层只管展示,Controller 层负责接收请求、调业务、选视图。落到代码结构上,我一般这样分:
| 层次 | 包名 | 职责 | 典型类 |
|---|---|---|---|
| 视图 | /webapp/jsp | 页面展示,尽量少写 Java 代码 | book_list.jsp、cart.jsp |
| 控制 | com.bookshop.controller | 接收请求、参数校验、跳转 | BookServlet、CartServlet |
| 业务 | com.bookshop.service | 业务逻辑、事务控制 | BookService、OrderService |
| 数据 | com.bookshop.dao | 数据库增删改查 | BookDao、UserDao |
| 实体 | com.bookshop.entity | 与表对应的 JavaBean | Book、User、Order |
Controller 里不要直接写 SQL,Service 里不要碰 HttpServletRequest,这是两条铁律。见过太多项目把 JDBC 代码塞进 Servlet 的 doGet 里,后期加个功能要改五六个文件,维护成本直接爆炸。
一个典型的 Servlet 请求处理长这样:
// BookServlet.java 处理图书列表请求 @WebServlet("/book/list") public class BookServlet extends HttpServlet { private BookService bookService = new BookService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 取分页参数,给默认值防止空指针 int pageNum = 1; int pageSize = 8; String pageStr = req.getParameter("pageNum"); if (pageStr != null && !pageStr.isEmpty()) { pageNum = Integer.parseInt(pageStr); } // 2. 调业务层拿分页数据 PageBean<Book> page = bookService.findByPage(pageNum, pageSize); // 3. 数据放请求域,转发到 JSP req.setAttribute("page", page); req.getRequestDispatcher("/jsp/book_list.jsp").forward(req, resp); } }参数说明:pageNum 是当前页码,pageSize 是每页条数,PageBean 是自定义分页封装类,包含 list、totalCount、totalPage、currentPage 四个字段。转发用 forward 不用 redirect,因为要把 request 域里的数据带到页面。如果这里用重定向,页面拿不到 page 对象,列表就是空的。
2.3 数据库连接池的引入与 JDBC 工具类封装
每次请求都 DriverManager.getConnection 是性能杀手,连接建立开销大,并发一上来数据库就顶不住。常见做法是引入 Druid 或 C3P0 连接池。Druid 配置简单,监控功能也够用。
# druid.properties driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/bookshop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username=root password=你的密码 initialSize=5 maxActive=20 maxWait=3000注意 url 里的几个参数:useSSL=false 关掉 SSL 警告,serverTimezone 必须设,否则 MySQL 8 会报时区错误。maxActive 设 20 对课程设计够用,生产环境要按 QPS 调。
封装一个 JDBCUtils 工具类,统一获取连接和释放资源:
public class JDBCUtils { private static DataSource dataSource; static { try { Properties props = new Properties(); props.load(JDBCUtils.class.getClassLoader() .getResourceAsStream("druid.properties")); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError("连接池初始化失败"); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 按 rs -> stmt -> conn 顺序关闭,每个都判空 try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (stmt != null) stmt.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }关闭顺序不能反,先关结果集再关语句再关连接,反了会报「Operation not allowed after ResultSet closed」。连接池里的 close 不是真关闭,是归还连接,所以放心调。
3. 用户、图书、购物车三大核心模块的实现
3.1 用户注册登录与 Session 会话管理
用户模块是入口,涉及密码存储和会话保持两个安全点。密码绝对不能明文存,用 MD5 加盐或者 BCrypt。课程设计里 MD5 够用,但要知道它不安全,生产环境必须换。
// 注册时对密码做 MD5 摘要 public static String md5(String password) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(password.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("MD5 计算失败", e); } }登录成功后把 user 对象放进 session,后续判断登录状态就靠它:
// LoginServlet 登录校验通过后 HttpSession session = req.getSession(); session.setAttribute("user", user); session.setMaxInactiveInterval(30 * 60); // 30 分钟超时 resp.sendRedirect(req.getContextPath() + "/book/list");session 超时时间默认 30 分钟,可以在 web.xml 里统一配。购物车数据如果放 session,用户关浏览器就丢了,所以购物车建议落库,登录用户存 user_id 关联,未登录用户可以用 Cookie 临时存。
3.2 图书列表分页查询的 SQL 与 PageBean 封装
分页是列表页的核心。MySQL 用 LIMIT offset, size 实现,offset = (pageNum - 1) * pageSize。但要注意先查总数再查当前页,两次查询。
-- 查总记录数 SELECT COUNT(*) FROM book WHERE status = 1; -- 查当前页数据,按上架时间倒序 SELECT id, name, author, price, stock, cover, category_id FROM book WHERE status = 1 ORDER BY create_time DESC LIMIT ?, ?;PageBean 封装类:
public class PageBean<T> { private int currentPage; // 当前页 private int pageSize; // 每页条数 private int totalCount; // 总记录数 private int totalPage; // 总页数 private List<T> list; // 当前页数据 // totalPage 由 totalCount 和 pageSize 计算得出 public int getTotalPage() { return (totalCount % pageSize == 0) ? totalCount / pageSize : totalCount / pageSize + 1; } // getter/setter 省略 }参数说明:currentPage 从 1 开始,pageSize 建议 8 到 12,太多页面加载慢,太少翻页频繁。totalPage 用向上取整,别用整除,否则最后一页数据会丢。JSP 页面用 JSTL 的 c:forEach 遍历 list,分页条根据 currentPage 和 totalPage 动态渲染上一页下一页。
3.3 购物车的两种存储方案与增删改逻辑
购物车有 session 存和数据库存两种。session 方案简单,适合未登录场景,但换设备就没了。数据库方案用 cart 表,字段 user_id、book_id、quantity,登录用户持久化,体验更好。我一般两者结合:未登录存 session,登录后合并到数据库。
// CartService 添加购物车,已存在则数量累加 public void addToCart(int userId, int bookId, int quantity) { Cart cart = cartDao.findByUserAndBook(userId, bookId); if (cart == null) { cartDao.insert(new Cart(userId, bookId, quantity)); } else { cartDao.updateQuantity(cart.getId(), cart.getQuantity() + quantity); } }库存校验不能漏。加购时查 book 表的 stock,数量超过库存要拦下来,否则下单时才发现库存不足,用户体验很差。
// 加购前校验库存 Book book = bookDao.findById(bookId); if (book.getStock() < quantity) { throw new BusinessException("库存不足,当前仅剩 " + book.getStock() + " 本"); }购物车页面用表格展示,每行有数量输入框和删除按钮。修改数量用 AJAX 提交,避免整页刷新。删除就是按 cart_id 删记录,注意校验这个 cart 是不是当前用户的,防止越权删别人的购物车。
4. 订单生成、后台管理与项目部署
4.1 下单流程的事务控制与订单号生成
下单是整个系统最需要事务的地方。要同时做三件事:写 order 主表、写 order_item 明细表、扣减 book 库存。任何一步失败都要回滚,否则会出现「订单生成了但库存没扣」或者「库存扣了订单没生成」的脏数据。
// OrderService 下单,用事务保证一致性 public String createOrder(int userId, List<Cart> cartList) { Connection conn = null; try { conn = JDBCUtils.getConnection(); conn.setAutoCommit(false); // 开启事务 String orderNo = generateOrderNo(); BigDecimal total = BigDecimal.ZERO; for (Cart cart : cartList) { Book book = bookDao.findById(conn, cart.getBookId()); if (book.getStock() < cart.getQuantity()) { throw new BusinessException("《" + book.getName() + "》库存不足"); } // 扣库存,用乐观锁防止超卖 int rows = bookDao.reduceStock(conn, book.getId(), cart.getQuantity()); if (rows == 0) { throw new BusinessException("库存扣减失败,请重试"); } total = total.add(book.getPrice() .multiply(new BigDecimal(cart.getQuantity()))); } orderDao.insert(conn, orderNo, userId, total); orderItemDao.batchInsert(conn, orderNo, cartList); cartDao.clearByUser(conn, userId); conn.commit(); return orderNo; } catch (Exception e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } throw new RuntimeException("下单失败:" + e.getMessage(), e); } finally { try { if (conn != null) conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }扣库存的 SQL 用UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?,把库存判断放进 WHERE 条件,靠数据库行锁保证并发安全,这就是乐观锁的思路。返回影响行数为 0 说明库存不够,抛异常回滚。
订单号生成别用时间戳,高并发会重复。常见做法是「时间戳 + 用户 ID 后四位 + 随机数」,或者用数据库自增 ID 拼前缀。
private String generateOrderNo() { String time = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); int random = new Random().nextInt(9000) + 1000; return time + random; }4.2 后台图书管理的 CRUD 与文件上传
后台管理是管理员用的,核心是图书的增删改查和封面图上传。上传用 Servlet 3.0 的 Part 接口或者 Commons FileUpload。Part 更简单,不用引额外依赖。
// 图书新增,处理封面文件上传 @WebServlet("/admin/book/add") @MultipartConfig(maxFileSize = 2 * 1024 * 1024) public class BookAddServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String name = req.getParameter("name"); String author = req.getParameter("author"); BigDecimal price = new BigDecimal(req.getParameter("price")); int stock = Integer.parseInt(req.getParameter("stock")); Part part = req.getPart("cover"); String fileName = System.currentTimeMillis() + "_" + part.getSubmittedFileName(); String savePath = getServletContext().getRealPath("/upload"); File dir = new File(savePath); if (!dir.exists()) dir.mkdirs(); part.write(savePath + File.separator + fileName); Book book = new Book(name, author, price, stock, "upload/" + fileName); bookService.add(book); resp.sendRedirect(req.getContextPath() + "/admin/book/list"); } }参数说明:maxFileSize 限制单文件 2MB,防止传大文件拖垮服务。文件名加时间戳前缀避免重名覆盖。getRealPath 拿到的是部署目录,重启 Tomcat 后上传的文件可能丢,生产环境要存到项目外的固定目录,数据库只存相对路径。
4.3 打包 WAR 部署到 Tomcat 的完整步骤
IDEA 里配好 Artifact,选 Web Application: Archive,输出 war 包。或者用 Maven 的 package 命令,pom.xml 里 packaging 设成 war。
# Maven 打包 mvn clean package -DskipTests # 生成的 war 在 target 目录下,复制到 Tomcat cp target/bookshop.war $CATALINA_HOME/webapps/ # 启动 Tomcat $CATALINA_HOME/bin/startup.sh # Linux # 或 Windows 下双击 startup.batTomcat 会自动解压 war 包,访问http://localhost:8080/bookshop/就能看到首页。如果 404,先看 webapps 下有没有解压出目录,再看 logs/catalina.out 有没有报错。常见问题是 web.xml 版本和 Tomcat 不匹配,或者 Servlet 注解没被扫描到。
5. 这套系统最容易翻车的五个地方
5.1 中文乱码:POST 和 GET 要分开治
现象:表单提交的中文图书名存进数据库变成问号,或者页面显示乱码。
原因:POST 请求默认用 ISO-8859-1 解码,GET 请求在 Tomcat 8 之前也用 ISO-8859-1。数据库连接和表的字符集不统一也会乱。
解决:POST 在 Servlet 第一行加req.setCharacterEncoding("UTF-8"),或者写个 CharacterEncodingFilter 统一处理。GET 在 server.xml 的 Connector 加 URIEncoding="UTF-8"。数据库连接 url 加 characterEncoding=utf8,建库建表都用 utf8mb4。三处都对齐才彻底。
5.2 连接池耗尽:连接没归还
现象:系统跑一会儿就卡死,报「wait millis 3000, active 20」之类的连接池等待超时。
原因:DAO 里拿了 Connection 没在 finally 里 close,连接被占满。或者事务里抛异常没回滚,连接一直挂着。
解决:所有 JDBC 操作都用 try-finally 保证 close,或者用 try-with-resources。事务代码的 catch 块里必须 rollback,finally 里恢复 autoCommit 并 close。用 Druid 的话开监控页面,看 activeCount 是不是一直不降。
5.3 购物车数量为负:并发和校验缺失
现象:用户快速点加购或者改数量,购物车里出现负数或者超大数字。
原因:前端没做数量下限校验,后端 update 直接覆盖,没判断 quantity 是否合法。
解决:前端 input 加 min="1",后端 update 前判断 quantity > 0 且不超过库存。改数量用UPDATE cart SET quantity = ? WHERE id = ? AND user_id = ?,把 user_id 带上防止越权。
5.4 订单重复提交:刷新页面就多一单
现象:用户下单后按 F5 刷新,又生成一笔订单。
原因:下单是 POST 请求,刷新会重发。或者用户网络慢连点两次提交按钮。
解决:下单成功后用 redirect 跳转到订单详情页,别用 forward。前端提交按钮点击后 disable。更稳的做法是生成一个 token 存 session,下单时校验并删除,同一 token 只能用一次。
5.5 部署后图片 404:路径写成了绝对路径
现象:本地跑得好好的,部署到服务器后图书封面全裂了。
原因:JSP 里图片 src 写成了C:/upload/xxx.jpg这种绝对路径,或者用了getRealPath但重启后文件丢了。
解决:图片路径存相对路径,JSP 里用${pageContext.request.contextPath}/upload/xxx.jpg拼。上传目录配到项目外,Tomcat 的 server.xml 里加 Context 的 docBase 指向外部目录,这样重新部署 war 包不会覆盖上传的文件。
6. 用过滤器统一编码和登录校验的进阶技巧
前面每个 Servlet 都写一遍setCharacterEncoding太啰嗦,登录校验也是每个后台页面都要判断 session,重复代码多。用 Filter 一次性解决,这是这套系统从「能跑」到「像样」的关键一步。
编码过滤器:
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // POST 编码 request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); } }登录校验过滤器,只拦后台路径:
@WebFilter("/admin/*") public class AdminAuthFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(); Object admin = session.getAttribute("admin"); if (admin == null) { response.sendRedirect(request.getContextPath() + "/jsp/admin_login.jsp"); return; } chain.doFilter(request, response); } }参数说明:@WebFilter("/*")拦截所有请求,/admin/*只拦后台。过滤器顺序按类名字母序或者 web.xml 里的配置顺序,编码过滤器要排在前面,否则后面过滤器拿到的参数还是乱码。
验证过滤器有没有生效,可以在 doFilter 里打日志,看请求进来时编码是不是已经设好。如果登录校验没拦住,检查 session 里存的 key 和过滤器里取的 key 是不是一致,这种低级错误我踩过不止一次。
最后说个习惯:每加一个功能,先在脑子里过一遍「请求从浏览器到 Servlet 到 Service 到 DAO 到数据库再回来」这条链路,哪一环可能出问题心里就有数了。这套 JSP + MVC + MySQL 的图书购物系统,代码量不大,但把 JavaWeb 的核心链路全串了一遍,认真写完一遍,后面学 Spring 会轻松很多。希望帮到你。
本文还有配套的精品资源,点击获取