
简介基于JSPMVC模式和MySQL构建的网上图书购物系统是一份Java Web课程设计/毕业设计参考项目适合作为大作业、工程实训或初期项目立项的起步模板。项目采用典型MVC分层结构将页面展示、业务逻辑与数据库操作分离涵盖图书浏览、购物车、订单等核心流程能帮助有一定Java基础的小白或进阶学习者快速理解ServletJSPMySQL的协作方式。压缩包共76个文件、约47.8MB包含jsp页面、Java源码、class文件、sql数据库脚本、xml及css配置文件以及项目展示视频和说明文档覆盖从环境配置、数据库导入到部署演示的完整链路。目前已有135人学习/下载。需要说明的是代码定位为参考资料而非定制成品读者需具备基本读码和调试能力借助包内项目展示视频、数据库脚本和清晰目录结构可快速梳理模块划分与运行流程并在此基础上自行扩展功能、修改逻辑适合作为初期项目立项的参考蓝本。1. 为什么 2025 年还有人用 JSPMySQL 做图书购物网站这套组合放在今天不算时髦但网上图书购物系统恰好能把 Web 开发最核心的几块地基一次铺全MVC 分层、会话状态管理、事务与库存一致性、文件上传、分页查询。JSP 负责视图渲染Servlet 当控制器JavaBean 做模型MySQL 存数据没有框架遮挡每一行代码在做什么都能被看见。对于正在做课程设计的学生、接手老教学项目的维护者、以及想从 CRUD 过渡到分层架构的开发者这套技术栈反而比 Spring Boot 全家桶更容易讲清楚问题。为什么不用 JSP 里直接写 JDBC 的 Model1 方式图书购物系统有用户登录、图书展示、购物车、下单、后台管理五组功能页面多、状态多如果把业务逻辑全塞进 JSP后期任何一个下单逻辑的改动都要在一堆脚本片段里挣扎。MVC 模式在这里不是理论摆设是保证项目写到一半还能改得动的底线。MySQL 8.x 是目前大多数运行环境的目标JDBC 驱动、连接参数、事务隔离级别这些细节比框架更早决定项目能不能跑起来。2. 架构与数据模型MVC 分层后数据库设计才是购物系统的地基2.1 用职责表把 MVC 每一层钉死这个项目采用 JSPServlet 实现的 Model2 模式也就是经典的 MVC 设计模式。View 是 JSPController 是 ServletModel 由 JavaBean、Service、DAO 组成。之所以不用 Struts 或 Spring MVC 替代是因为标题明确要求 JSPMVCServlet 当控制器是对这套模式最直接、依赖最少的解释。各层职责如下MVC 层次典型目录/类在图书购物系统中的具体职责Viewjsp/下的 user、admin、common 目录展示图书列表、购物车、订单页面只用 EL 和 JSTL 读取数据Controllercom.bookshop.web包下的 *Servlet接收 HTTP 请求校验参数调用 Service转发或重定向 JSPServicecom.bookshop.service包购物车结算、订单生成、库存扣减、状态流转DAOcom.bookshop.dao包对 MySQL 表做增删改查封装 JDBC 操作ModelBook、User、CartItem、Order 等 JavaBean承载数据状态不编写业务逻辑实际项目里最常见的错误是去掉 Service 层让 Servlet 直接调 DAO。图书下单这个动作涉及订单插入、订单明细插入、库存更新三个写入操作如果分散在 Servlet 里事务边界很难控制。即使代码行数多几行也必须把 Service 独立出来让事务在 Service 层统一管理。2.2 MySQL 表结构6 张表把图书、用户、订单串起来建库时使用 utf8mb4排序规则用 utf8mb4_unicode_ci。这是 MySQL 8 的默认字符集支持生僻字和特殊符号。下面是核心建表 SQLCREATE DATABASE IF NOT EXISTS bookshop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE bookshop; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT 存 SHA-256 哈希后的值, nickname VARCHAR(50) DEFAULT 书友, phone VARCHAR(20) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT用户表; CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sort INT NOT NULL DEFAULT 0 COMMENT 排序值越小越靠前 ) ENGINEInnoDB COMMENT图书分类表; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100) DEFAULT NULL, price DECIMAL(10,2) NOT NULL COMMENT 售价, stock INT NOT NULL DEFAULT 0 COMMENT 库存, cover VARCHAR(255) DEFAULT NULL COMMENT 封面图 URL, description TEXT, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_title (title) ) ENGINEInnoDB COMMENT图书表;这段建表脚本里stock 用 INT 配合事务行锁做库存扣减price 用 DECIMAL(10,2) 精确到分避免 FLOAT 带来的金额误差category_id 和 title 上建了普通索引覆盖分类筛选和模糊搜索场景。购物车这里没有建表因为我会选择 Session 方案未登录用户也能加购毕设和教学项目省掉 cart 表反而更贴近真实电商的第一步。订单相关的两张表继续CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务订单号, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1待发货 2待收货 3已完成 4已取消, receiver_name VARCHAR(50) NOT NULL, receiver_phone VARCHAR(20) NOT NULL, receiver_address VARCHAR(200) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_order_no (order_no) ) ENGINEInnoDB COMMENT订单表; CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, book_id INT NOT NULL, book_title VARCHAR(200) NOT NULL COMMENT 商品名快照, price DECIMAL(10,2) NOT NULL COMMENT 下单时价格快照, quantity INT NOT NULL ) ENGINEInnoDB COMMENT订单明细表;两个关键设计orders 表里冗余了 receiver_name、receiver_phone、receiver_address 三个收货字段避免关联用户表查地址等地址变了订单无法追溯order_item 里存了 book_title 和 price 快照图书后续改价改名都不影响历史订单的展示。2.3 用连接池替代 JSP 里的 DriverManager早期教材常在 JSP 页面里直接写Class.forName(com.mysql.jdbc.Driver)再DriverManager.getConnection()。这种写法不是不能跑而是每次请求都新建连接MySQL 的线程开销翻倍访问量稍大就报 Too many connections。常见做法是用 Druid 连接池在 WEB-INF/lib 放入 druid-1.2.x.jar 后写一个工具类package com.bookshop.util; import com.alibaba.druid.pool.DruidDataSource; import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; public class DBUtil { private static final DruidDataSource ds new DruidDataSource(); static { ds.setUrl(jdbc:mysql://localhost:3306/bookshop ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai allowPublicKeyRetrievaltrue); ds.setUsername(root); ds.setPassword(你的密码); ds.setInitialSize(5); ds.setMaxActive(20); ds.setMinIdle(5); } public static Connection getConnection() throws SQLException { return ds.getConnection(); } }逐个参数说明useUnicodetrue 和 characterEncodingutf8 解决 JSP 页面到 MySQL 的中文乱码serverTimezoneAsia/Shanghai 解决 MySQL 8 时序报错和日期相差 8 小时问题allowPublicKeyRetrievaltrue 解决 MySQL 8 默认 caching_sha2_password 插件下首次连接报 Public Key Retrieval is not allowed 的错。这些参数在 mysql workbench 里看不出来只存在于 JDBC URL 里。MySQL 8 的驱动类名也变成了com.mysql.cj.jdbc.Driver旧项目里那个com.mysql.jdbc.Driver必须替换。3. 图书展示、分页与购物车ServletJSP 写通用户主流程3.1 图书列表的分页参数与 MySQL 排序图书列表页需要具备分类筛选、关键词搜索、排序、分页四个能力。参数设计是这样约定的pageNo当前页码从 1 开始默认 1pageSize每页条数默认 8图书列表 8~12 比较合适keyword按书名模糊匹配categoryId分类筛选0 表示全部sortnew 按创建时间倒序price_asc 按价格升序price_desc 降序DAO 层对应 SQL 用 MySQL 原生的 LIMIT 完成分页SELECT id, title, author, price, cover, stock FROM book WHERE status 1 AND (#{keyword} OR title LIKE CONCAT(%, #{keyword}, %)) AND (#{categoryId} 0 OR category_id #{categoryId}) ORDER BY CASE WHEN #{sort} price_asc THEN price END ASC, CASE WHEN #{sort} price_desc THEN price END DESC, created_at DESC LIMIT #{offset}, #{pageSize};这段 SQL 用 CONCAT 拼出 LIKE 模式比直接写%#{keyword}%更规范能避免用户在关键词里输入 % 或 _ 造成通配符膨胀ORDER BY 用两个 CASE WHEN 表达式处理升序和降序比在 Java 里拼字符串更直观。LIMIT 的第一个参数 offset 由页面页码换算(pageNo - 1) * pageSize。如果排序方案更多可以像这样再加 CASE 分支但要留意 CASE WHEN 里的表达式要对齐类型否则 MySQL 会按隐式转换规则给出意外结果。总页数的计算放在 Service 层int total bookDao.countBooks(keyword, categoryId); int totalPage (total pageSize - 1) / pageSize; request.setAttribute(totalPage, totalPage);(total pageSize - 1) / pageSize是向上取整的经典写法。比如 total17、pageSize8计算结果为 3正好三页。注意 countBooks 和分页列表要保证查询条件完全一致否则会出现页码和内容对不上的问题。3.2 用 JSPELJSTL 渲染图书列表不用 scriptletController 查询后把数据放进 request再 forward 到 book_list.jsprequest.setAttribute(bookList, list); request.setAttribute(totalPage, totalPage); request.setAttribute(currentPage, pageNo); request.getRequestDispatcher(/jsp/user/book_list.jsp).forward(request, response);JSP 页面用 JSTL 核心标签库渲染避免在页面里写 Java 代码% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % div classbook-grid c:forEach items${bookList} varbook div classbook-card h3c:out value${book.title}//h3 p作者c:out value${book.author}//p p价格${book.price}/p c:if test${book.stock 0} a hrefcart/add?bookId${book.id}加入购物车/a /c:if c:if test${book.stock 0} span classsold-out已售罄/span /c:if /div /c:forEach /divc:out输出书名和作者时会把、、引号转义成 HTML 实体XSS 攻击在这里被挡掉c:if根据 stock 值切换按钮和售罄文案状态由后端给出前端不参与逻辑判断。EL 表达式${book.price}会自动调用 Book 的 getPrice()所以 Book 这个 JavaBean 必须提供符合命名规范的 getter 方法。页面循环里不要出现% %或% %那会破坏模板的可读性也不利于前端同事理解和后续维护。3.3 购物车放 Session 还是数据库购物车在 Session 里维护核心对象是 Cart 和 CartItem。用户点击加入购物车时CartServlet 执行逻辑HttpSession session request.getSession(); Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); } CartItem item new CartItem(); item.setBookId(bookId); item.setTitle(book.getTitle()); item.setPrice(book.getPrice()); item.setQuantity(1); cart.add(item);Cart 内部用MapInteger, CartItem记录条目key 是图书 id。添加同款图书时先检查 Map 里是否已有该 id有就把数量累加没有就新建条目。这个设计的好处是 JSP 页面展示购物车时循环一次 Map 就能得到所有条目的最新数量不需要每次都去 MySQL 查图书信息。为什么不落数据库用户未登录时也能加购落库需要先登录访问购物车页零 SQL 查询响应速度快。缺点自然是换设备不共享、关闭浏览器后购物车丢失。对图书购物系统而言这个取舍完全合理。如果一定想落库就在加购接口里多写一条 insert把用户 ID、图书 ID、数量写入 cart_item 表在用户登录时通过 user_id 找回但这样就需要处理游客加购和登录后合并的额外逻辑。3.4 下单事务防止库存扣成负数的关键用户提交订单CartServlet 把请求转到 OrderService.submit()。这个方法必须用事务包住三个操作写 orders 表、写 order_item 表、扣减 book 表库存。事务和 DAO 的关系是这样处理的public void submit(Cart cart, Order order) throws SQLException { Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); OrderDAO orderDAO new OrderDAO(); long orderId orderDAO.insertOrder(conn, order); orderDAO.insertItems(conn, orderId, cart.getItems()); for (CartItem item : cart.getItems()) { int rows bookDAO.deductStock(conn, item.getBookId(), item.getQuantity()); if (rows 0) { throw new RuntimeException(库存不足 item.getTitle()); } } conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } }扣减库存的 SQL 是这样写的UPDATE book SET stock stock - ? WHERE id ? AND stock ?;affected rows 为 0 说明库存不足事务回滚订单和明细全部撤销。SQL 语句里stock ?是原子条件MySQL 对命中的行加行级锁更新期间其他事务的扣减操作必须等待因此在并发场景下不会出现两个请求同时读到 stock1然后都扣成功导致库存变成 -1 的情况。初学者最容易写错的是“先 select stock 再判断是否足够然后 update”。两个并发请求同时 select 到 stock1都认为库存充足然后都执行 update库存变成 -1。把判断条件放进 update 的 where 里才是正确方案。commit 之后要把连接归还连接池也就是 close()finally 里恢复 setAutoCommit(true) 是为了避免连接池复用连接时残留事务状态。4. 管理端图书上下架、封面图片上传和 MySQL 更新语法边界4.1 用 Servlet 3.0 MultipartConfig 完成 JSP 封面上传后台新增图书需要上传封面。不需要引入 cos.jar 和 commons-fileupload 这些老库Servlet 3.0 自带文件上传能力。管理员打开 book_form.jsp 填写表单编码类型必须是 multipart/form-dataform actionadmin/book/save methodpost enctypemultipart/form-data input typetext nametitle placeholder书名 / input typetext nameauthor placeholder作者 / input typenumber nameprice step0.01 placeholder价格 / input typefile namecover acceptimage/* / button typesubmit上架/button /form对应的 Servlet 接收文件并保存MultipartConfig(maxFileSize 2 * 1024 * 1024, maxRequestSize 10 * 1024 * 1024) WebServlet(/admin/book/save) public class BookSaveServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part part req.getPart(cover); String fileName UUID.randomUUID().toString().replace(-, ) .jpg; String savePath req.getServletContext().getRealPath(/upload/); part.write(savePath File.separator fileName); // fileName 写入 book.cover 字段 Book book new Book(); book.setCover(fileName); bookService.saveBook(book); resp.sendRedirect(admin/book/list); } }MultipartConfig 参数含义maxFileSize 限制单张封面 2MBmaxRequestSize 限制整个表单 10MB超限抛 IllegalStateException页面要在 catch 块里给出提示。封面文件名用 UUID 重新生成是为了防止用户上传同名图片互相覆盖。part.write() 的路径来自 getRealPath(/upload/)这是教学项目最常见的写法文件和工程放在一起好管理但要注意 Tomcat 重新部署时 upload 目录会被清空生产环境要把保存路径配到外部绝对路径。4.2 图书信息 update如何做到只更新非空字段图书编辑页和新增页共用同一个保存接口时会遇到“没让用户填的字段不要覆盖成空值”的问题。比如只改了价格没有重新上传封面如果执行无条件 updatecover 字段会被清掉。常见做法是动态拼接 SQLStringBuilder sql new StringBuilder(UPDATE book SET ); ListObject params new ArrayList(); if (title ! null !title.isEmpty()) { sql.append(title?, ); params.add(title); } if (price ! null) { sql.append(price?, ); params.add(price); } if (coverFileName ! null) { sql.append(cover?, ); params.add(coverFileName); } sql.setLength(sql.length() - 2); sql.append( WHERE id?); params.add(id);sql.setLength(sql.length() - 2) 是把最后一个字段后面的逗号和空格去掉。每一个 append 之间用了统一的标准模式判空、追加字段名、追加占位符和逗号、收集参数。整个过程用 PreparedStatement 执行参数通过 setObject 按索引传入避免字符串拼接注入。这种写法对书名、作者、价格、封面、描述都适用但要注意 update 时唯一索引冲突会被 MySQL 报告 Duplicate entry 错误在表里设计一个 status 软删除字段而不是物理删除重复的图书记录可以绕开这个问题。4.3 订单状态流转与批量更新后台订单处理是管理端的重头。状态字段用 TINYINT 存储含义在 JSP 或者 Java 里用常量类定义执行更新时必须带预期状态的校验条件UPDATE orders SET status #{newStatus} WHERE id #{orderId} AND status #{expectStatus}后面的 expectStatus 就是状态机的约束待支付(0)只能转为已取消(4)待发货(1)只能转为待收货(2)。前端管理员点击“改为已发货”时如果订单当前已经处于 3 或 4这条 SQL 影响的行数为 0Service 层抛出异常提示状态已变更。这个 SCHEMA 设计能防止两个管理员同时对同一个订单做不同操作导致状态覆盖。批量发货场景用一条 SQL 更新多个订单IN 列表用 PreparedStatement 动态生成占位符UPDATE orders SET status 2, ship_at NOW() WHERE status 1 AND order_no IN (?, ?, ?)动态占位符的数量要与参数列表数量一致这个细节在 navicat 里测试不出来只有在代码里手写循环占位符才会暴露问题。IN 列表长度一般不会超过几百不会触碰 MySQL max_allowed_packet 的默认限制但绝不能把 order_no 列表直接拼成字符串那既慢又不安全。4.4 后端校验的边界JSP 校验不等于安全校验JSP 个人信息展示页面可以写 JS 校验手机号格式但那只负责提升交互体验真正能拦截非法数据的是 Servlet 入口校验。每次提交都要校验价格是正数、数量大于 0、手机号匹配正则、订单总额和明细合计一致。try { bookService.saveBook(book); resp.sendRedirect(admin/book/list); } catch (IllegalArgumentException e) { req.setAttribute(error, e.getMessage()); req.getRequestDispatcher(/admin/book_form.jsp).forward(req, resp); }这里用 forward 而不用 redirect转发能保留 request 里的 error 信息JSP 页面用${error}展示错误原因同时用户当前的表单输入还在内存里回显不丢失。如果用了 redirectrequest 属性随着请求结束就被销毁了页面刷新后什么提示都看不到。这个三角色组合是 Servlet 处理表单校验的正确姿势校验失败就回到原页面带上错误信息校验成功再重定向到列表页避免刷新时重复提交表单。5. 藏在 JSP 细节里的坑与一个库存校验验证技巧5.1 改了 JSP 不生效去 Tomcat 的 work 目录看一眼JSP 在第一次被访问时由 Jasper 转译成 Servlet 源码并编译成 class。生成的 Java 文件在 Tomcat 的 work 目录下路径是work/Catalina/localhost/项目名/org/apache/jsp文件名叫book_005flist_jsp.javaclass 文件在同目录。修改 JSP 后开发模式会自动触发重新编译但如果用 IDE 直接把 JSP 文件拷进 webapp 目录覆盖Tomcat 偶尔会命中旧的 class 文件缓存页面不变。此时清理 work 目录里对应的项目文件夹重启 Tomcat 再刷新即可。编译报错时直接打开这个 .java 文件能精确定位到转译后的第几行出了问题比看日志猜测高效得多。5.2 购物车离开页面提示的屏蔽与放行网上图书购物系统里有大量跳转链接用户加购后可能误点链接离开结算页面。通常用 beforeunload 事件做离开提示但要配合购物车状态判断window.addEventListener(beforeunload, function (e) { if (hasCartItems !orderSubmitted) { e.preventDefault(); e.returnValue 购物车还有商品确定离开吗; } });要点有两个现代浏览器从 Chrome 109 开始屏蔽了自定义提示文本无论 returnValue 写什么弹窗都显示浏览器默认文案这个 API 的语义变成了“是否离开”的确认用户提交订单成功后需要置orderSubmitted true否则关闭页面或跳转时还会弹出多余的离开提示。这是一组容易被人忽略的组合逻辑。5.3 并发扣库存的验证脚本数据一致性一看便知本文第三、四章里库存扣减依赖UPDATE book SET stock stock - ? WHERE id ? AND stock ?这一条 SQL。写完可以立刻验证在 IntelliJ 里用 HTTP Client 或直接用 jMeter 对同一本书发 20 个并发购买请求数量都是 1初始库存设为 10。验证时查三处数字下单成功数、订单明细的购买总量、图书表的剩余库存。如果成功数等于购买总量、购买总量加剩余库存等于初始库存事务和行锁逻辑就没有问题如果出现下单数大于库存变化数说明哪里有代码绕过了事务。这条验证路径比堆单元测试更快定位问题因为它是从用户视角检查数据一致性。最后的落点是把这套验证脚本固定在项目 README 里下次改到购物车或订单代码十分钟内就能回归一遍。本文还有配套的精品资源点击获取