javaWeb购物车开发全流程:Session存储、MySQL表结构与Servlet实现

发布时间:2026/9/29 4:51:18
javaWeb购物车开发全流程:Session存储、MySQL表结构与Servlet实现 简介这是一套面向JavaWeb初学者的简易购物车项目源码基于Servlet与JSP实现商品浏览、加入购物车、数量修改、删除及订单相关流程可帮助开发者快速理解HTTP请求、Session会话保持、JDBC数据库访问及MVC分层开发等Web核心机制。压缩包共54个文件包含14个Java源文件、6个JSP页面、数据库相关文件及项目配置文件等整体仅1.58MB轻量便于下载后直接导入IDE阅读。资源内附配套笔记资料和项目安装说明txt覆盖数据库表设计思路、关键代码逐段解释、Tomcat部署及环境配置指引适合初学者对照源码捋清前后端数据流转。目前已有4125人学习对想从增删改查层面入手构建完整Web业务闭环的开发者而言是一份实用且便于拆解的学习样本。1. 简易购物车为什么值得自己敲一遍从“会写”到“能跑”很多人第一次拿到 javaWeb 简易购物车源代码时以为这就是“把商品塞进 Session 再读出来”的 Demo做完才发现购物车模块几乎能暴露一个 CRUD 系统所有的短板Session 超时会清空、商品下架了还在结算页报价、库存会扣成负数、用户双击提交会生成两笔订单。这些问题都指向同一件事——购物车不只是“往 Map 里放商品”它还涉及会话存储、mysql 表结构、事务边界、幂等控制和前端防重复提交。这篇不吹不黑从存储选型讲起理清表结构再把 ServletJSP 的实现链路、结算落库和避坑清单铺开最后一章给你一份可以直接抄的购物车测试点。适合正在用 IDEA 运行 javaWeb 项目却卡在配置上的学生也适合要给现有购物车模块补测试的新手开发。2. 购物车的存储与表结构设计Session 方案还是 mysql 落地两者都要会2.1 三种存储方案的取舍Session、Cookie 与数据库存储方案会在很大程度上决定购物车能做多稳定。很多简易购物车例子只在 HttpSession 里放一个 Cart 对象代码确实是短但有两个很难受的边界Tomcat 默认 Session 超时 30 分钟超时后购物车直接清空服务端重启、应用热部署时 Session 也会重建用户明明加了几个商品一刷新就变空车。另一派做法是把购物车序列化进 Cookie服务端不存状态但 Cookie 体积上限只有约 4KB塞十几个条目就顶满而且价格、数量都存在客户端用户可以手工改。所以我的建议是匿名浏览用 Session一旦登录就切换到 mysql 购物车表。把这个方案做出来也就基本具备了一个 javaweb 项目完整案例的骨架。存储位置优点缺点适合场景HttpSession代码量最小天然按用户隔离超时/重启丢失内存占用匿名用户浏览期Cookie服务端无状态实现简单体积受限价格可被篡改临时促销活动页mysql 表持久化跨设备可对账需要表和事务读写有开销登录用户正式购物车从这个对比能得出一条经验Session 不是不能用了而是要清楚它的失效边界。对这份简易购物车项目来说我一般会采用“匿名期 Session 登录后 mysql 表”的组合既保住开发效率又给结算和库存扣减留出事务位置。如果你只是在课程设计里演示Session 方案能省很多事但如果想把它扩成 javaweb 项目完整案例并接 mysql 持久化表结构这一节就跳不过。2.2 mysql 最小表结构用户、商品、购物车与订单表按数据库方案落地最小可用的表我习惯拆五张用户表、商品表、购物车条目表、订单主表、订单明细表。下面是建表 SQL列名和类型都是最常用的写法可以直接套用。-- 用户表 CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 商品表 CREATE TABLE t_product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10, 2) NOT NULL, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 -- 1上架 0下架 ); -- 购物车条目表 CREATE TABLE t_cart ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_product (user_id, product_id) ); -- 订单主表 CREATE TABLE t_order ( id VARCHAR(32) PRIMARY KEY, user_id INT NOT NULL, total_amount DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已取消 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 订单明细表 CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(32) NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100) NOT NULL, price DECIMAL(10, 2) NOT NULL, -- 下单时价格快照 quantity INT NOT NULL );t_cart 里 user_id 和 product_id 的组合唯一键是这张表最关键的约束。同一用户重复点“加入购物车”时SQL 用INSERT ... ON DUPLICATE KEY UPDATE quantity quantity 1既保证不出现两行同样商品又能在一次请求里完成数量累加。t_order_item 必须冗余 product_name 和 price因为商品表的名称和价格会随运营操作变化下单后的快照不能被商品表的更新影响这也是电商订单对账的常规做法。黑马 javaWeb 笔记里的分层习惯和这套很接近如果你照着那套笔记学过 MVC这个表结构应该一眼就能对上。2.3 领域模型Cart、CartItem 与 Product 的类设计分层上我习惯把商品实体和购物车对象分开。Product 对应 t_product 表CartItem 是购物车里的一行Cart 是整辆车。CartItem 里持有 Product 引用而不是只存 productId是为了在页面输出商品行时直接取名称、单价、小计避免每次循环都查一次数据库。/** * 购物车条目一个商品 数量 */ public class CartItem { private Product product; private int quantity; public CartItem(Product product, int quantity) { this.product product; this.quantity quantity; } public double getSubtotal() { return product.getPrice() * quantity; } public Product getProduct() { return product; } public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity quantity; } }Cart 采用 LinkedHashMap 保存条目以商品 id 为 key可以保持加入顺序也方便按商品定位。public class Cart { private final MapInteger, CartItem items new LinkedHashMap(); public void add(Product product, int quantity) { if (product null || quantity 0) { return; } CartItem item items.get(product.getId()); if (item null) { items.put(product.getId(), new CartItem(product, quantity)); } else { item.setQuantity(item.getQuantity() quantity); } } public void update(int productId, int quantity) { if (!items.containsKey(productId)) { return; } if (quantity 0) { items.remove(productId); // 数量改成0或负数等于删除该条目 } else { items.get(productId).setQuantity(quantity); } } public void remove(int productId) { items.remove(productId); } public double getTotalPrice() { double total 0d; for (CartItem item : items.values()) { total item.getSubtotal(); } return total; } public MapInteger, CartItem getItems() { return items; } }add 方法处理两种情形从没加过的商品直接插入加过的累加数量。update 方法把数量改成 0 或负数时直接移除条目这样前端只要把 input 里的值改成 0 提交就天然完成了“删掉该行”的操作。getTotalPrice 只用于页面展示真正的结算价格必须在订单生成时从数据库商品表重新计算这一点会在第 4 章展开。不要把这个 Cart 对象和 t_cart 表混为一谈Cart 是运行时的内存聚合t_cart 是持久化条目等登录后要合并 Session 购物车再把 CartItem 逐条映射到 t_cart 的行。3. 从空项目到跑通加购IDEA 运行 javaWeb 项目与 Servlet 实现链路3.1 环境配置JDK 8 配 Tomcat 9IDEA 里怎么注册新手花在环境上的时间往往比写代码还多。一个典型的坑是拿 JDK 17 去跑 Servlet 老代码报ClassNotFoundException: javax.servlet.*。javax 还是 jakarta 命名空间的问题根源是 Tomcat 版本Tomcat 9 用 javax.servletTomcat 10 切到 jakarta.servlet。所以做这个项目我建议 JDK 8 Tomcat 9 javax.servlet-api 4.0.1这是最稳的组合。如果你们课程要求 JDK 17那就换成 jakarta.servlet-api 5.x代码里的 import 全部改成 jakarta.servlet 前缀逻辑不变。IDEA 运行 javaWeb 项目配置分四步走File → Project Structure → Artifacts点“”新建 Web Application Exploded再到 Run → Edit Configurations添加一个 Tomcat Server Local在 Deployment 页签把这个 Artifact 加进去Application context 写/cartdemo注意前后的反斜杠最后看 Server 页签的 HTTP port8080 被占用就改成 8081。第一次启动报端口冲突时不要只换端口去查一下是否有残留的 Tomcat 进程Windows 下经常是之前没关干净。提示Application context 会影响你代码里的所有根路径跳转务必和开发时保持一致。代码里凡是写重定向的地方最好用req.getContextPath()动态拼接而不是写死/cartdemo。3.2 项目骨架与依赖Servlet 注解注册和 web.xml项目骨架用 Maven 统一管理依赖结构如下cartdemo/ ├── pom.xml ├── src/main/java/com/cartdemo/ │ ├── entity/Product.java │ ├── cart/Cart.java │ ├── cart/CartItem.java │ ├── dao/ProductDao.java │ └── web/CartServlet.java └── src/main/webapp/ ├── WEB-INF/web.xml ├── index.jsp └── cart.jsppom.xml 里只需要三个关键依赖dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependenciesjavax.servlet-api 的 scope 必须写成 provided表示编译时需要、打包和运行时由 Tomcat 提供。如果漏掉这个 scopeWAR 里会多出一份 servlet 实现Tomcat 加载时很容易类冲突。JSTL 是 JSP 页面用c:forEach遍历购物车条目用的没有它页面直接报错。mysql-connector-java 只在走数据库方案时需要纯 Session 演示可以先不引。Servlet 类上直接用WebServlet(/cart)注册web.xml 即使存在也可以只放 session 超时和欢迎页路径映射交给注解代码量少且直观。3.3 加入购物车Session 购物车的 Servlet 实现与参数校验购物车模块的核心集中在 CartServlet。doGet 负责渲染购物车页面doPost 根据 action 参数区分 add、update、remove、clear。下面是能直接跑通最小流程的完整实现WebServlet(/cart) public class CartServlet extends HttpServlet { private final ProductDao productDao new ProductDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Cart cart getCartFromSession(req); req.setAttribute(cart, cart); req.getRequestDispatcher(/cart.jsp).forward(req, resp); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 处理中文参数必须放在读取任何参数之前 req.setCharacterEncoding(UTF-8); String action req.getParameter(action); if (add.equals(action)) { int productId Integer.parseInt(req.getParameter(productId)); int quantity Integer.parseInt(req.getParameter(quantity)); if (quantity 0 || quantity 99) { resp.sendRedirect(req.getContextPath() /product/list); return; } // 商品信息永远从数据库读前端传过来的价格一律忽略 Product product productDao.findById(productId); if (product null || product.getStatus() ! 1) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, 商品不可购买); return; } Cart cart getCartFromSession(req); cart.add(product, quantity); resp.sendRedirect(req.getContextPath() /cart); return; } if (update.equals(action)) { int productId Integer.parseInt(req.getParameter(productId)); int quantity Integer.parseInt(req.getParameter(quantity)); Cart cart getCartFromSession(req); cart.update(productId, quantity); resp.sendRedirect(req.getContextPath() /cart); return; } if (remove.equals(action)) { int productId Integer.parseInt(req.getParameter(productId)); Cart cart getCartFromSession(req); cart.remove(productId); resp.sendRedirect(req.getContextPath() /cart); } } private Cart getCartFromSession(HttpServletRequest req) { Cart cart (Cart) req.getSession().getAttribute(cart); if (cart null) { cart new Cart(); req.getSession().setAttribute(cart, cart); } return cart; } }getCartFromSession 把“取不到就新建”收敛到一个方法里doGet 和 doPost 共用避免两处逻辑不一致。quantity的范围校验做了两层第一层是浏览器 input 的 min/max第二层就是这里的 if 判断因为直接 POST 请求可以完全绕开前端限制。商品状态也在后端重新查了一遍前端传过来的价格完全不可信这是购物车模块最基本的安全底线。sendRedirect的目标路径用req.getContextPath()拼出来在 IDEA 里改过 Application context 后代码不用跟着改。3.4 购物车列表与数量修改JSP 输出与后端更新页面侧用 JSTL 遍历 Cart.items注意 items 是 Map所以是遍历 entry用entry.value取到 CartItem% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % table c:forEach items${cart.items} varentry tr td${entry.value.product.name}/td tdfmt:formatNumber value${entry.value.product.price} pattern0.00//td td form methodpost action${pageContext.request.contextPath}/cart input typehidden nameaction valueupdate input typehidden nameproductId value${entry.value.product.id} input typenumber namequantity value${entry.value.quantity} min1 max99/ button typesubmit修改数量/button /form /td td${entry.value.subtotal}/td td form methodpost action${pageContext.request.contextPath}/cart input typehidden nameaction valueremove input typehidden nameproductId value${entry.value.product.id} button typesubmit删除/button /form /td /tr /c:forEach /table这里有个小细节删除用了 POST 表单而不是a链接因为 GET 请求可能被爬虫或浏览器预取触发GET 做删除操作是购物车模块最常见的坏味道。fmt:formatNumber用来输出两位小数的价格避免出现 19.9 显示成 19.9 还是 19.90 的格式焦虑。数量修改提交后后端cart.update()会根据数量是否小于等于 0 决定是保留条目还是删除条目所以用户把数量改成 0 提交实际上就是删除。4. 结算与订单生成从购物车到 mysql 落库这一步绕不开4.1 结算前的最小登录态登录拦截与用户身份绑定Session 购物车可以匿名使用但生成订单必须关联用户。简易项目不引入登录框架时我一般只在登录成功时把用户 id 放进 Session然后结算接口判断这个值是否存在不存在就重定向到登录页。这个判断要同时放在前端按钮和后端 Servlet 里前端是体验后端是安全边界。很多教程只遮前端按钮POST 请求直接打结算地址也能生成订单这就是测试时的第一个漏洞。拦截逻辑放在 CheckoutServlet 的 doPost 最前面Integer userId (Integer) session.getAttribute(userId); if (userId null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; }登录页登录成功后需要执行一次 Session 购物车合并到数据库的操作这一节先不展开4.3 再讲。确认 userId 存在后下一步不是先算钱而是先看购物车是不是空的。如果购物车为空直接重定向回购物车页别继续往下走。4.2 订单生成的事务逻辑主表加明细表一次扣库存订单生成涉及四张表t_order 主表、t_order_item 明细表、t_cart 购物车表、t_product 库存。这四步必须在同一个数据库事务里否则就会出现“订单生成了库存没扣”或“库存扣了订单没生成”的对账问题。我使用 JDBC 的setAutoCommit(false)手动控制事务边界下面是核心代码Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); String orderId System.currentTimeMillis() _ userId; // 1. 插入订单主表状态默认待支付 PreparedStatement insertOrder conn.prepareStatement( INSERT INTO t_order(id, user_id, total_amount, status) VALUES(?, ?, 0, 0)); insertOrder.setString(1, orderId); insertOrder.setInt(2, userId); insertOrder.executeUpdate(); // 2. 遍历购物车写入订单明细并扣库存 double total 0d; for (CartItem item : cart.getItems().values()) { Product latest productDao.findById(item.getProduct().getId()); if (latest null || latest.getStatus() ! 1) { continue; } // 明细里的价格必须从数据库现查不能用购物车里的缓存价格 PreparedStatement insertItem conn.prepareStatement( INSERT INTO t_order_item(order_id, product_id, product_name, price, quantity) VALUES(?, ?, ?, ?, ?)); insertItem.setString(1, orderId); insertItem.setInt(2, latest.getId()); insertItem.setString(3, latest.getName()); insertItem.setBigDecimal(4, latest.getPrice()); insertItem.setInt(5, item.getQuantity()); insertItem.executeUpdate(); // 扣库存条件里带 stock ?返回 0 表示库存不足 PreparedStatement deduct conn.prepareStatement( UPDATE t_product SET stock stock - ? WHERE id ? AND stock ?); deduct.setInt(1, item.getQuantity()); deduct.setInt(2, latest.getId()); deduct.setInt(3, item.getQuantity()); int rows deduct.executeUpdate(); if (rows 0) { throw new RuntimeException(库存不足: latest.getId()); } total latest.getPrice().doubleValue() * item.getQuantity(); } // 3. 回写订单总金额 PreparedStatement updateTotal conn.prepareStatement( UPDATE t_order SET total_amount ? WHERE id ?); updateTotal.setBigDecimal(1, BigDecimal.valueOf(total)); updateTotal.setString(2, orderId); updateTotal.executeUpdate(); // 4. 清空购物车 cartDao.clearByUserId(userId); session.removeAttribute(cart); conn.commit(); } catch (Exception e) { if (conn ! null) { conn.rollback(); } throw new ServletException(e); } finally { if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } }库存扣减的WHERE id ? AND stock ?就是防超卖的关键这条 SQL 在并发下依然安全因为 MySQL 行锁会串行化同一商品的扣减。先 SELECT 再在 Java 里判断数量这种写法并发时必然漏别用。订单金额回写在明细插入之后避免循环里累计到一半发生异常导致金额不对。任何一步抛异常都会触发 rollback订单、明细、库存全部不生效这是事务最基本的价值。4.3 匿名购物车与登录购物车的合并直接覆盖还是逐个合并用户在未登录时加购了商品登录后需要把 Session 里的 Cart 合并进 t_cart 表而不是直接覆盖数据库里已有的购物车。覆盖会把用户之前加的条目全丢掉。正确做法是逐项累加Cart sessionCart (Cart) session.getAttribute(cart); if (sessionCart null || sessionCart.getItems().isEmpty()) { return; } for (CartItem item : sessionCart.getItems().values()) { // 合并时也要过滤下架商品避免把不可购商品带进数据库 Product latest productDao.findById(item.getProduct().getId()); if (latest null || latest.getStatus() ! 1) { continue; } cartDao.addOrUpdate(userId, item.getProduct().getId(), item.getQuantity()); } session.removeAttribute(cart);cartDao.addOrUpdate 在底层用INSERT ... ON DUPLICATE KEY UPDATE quantity quantity VALUES(quantity)让同一用户同一商品自动累加数量。合并完成后立刻把 Session 里的匿名购物车 removeAttribute 掉避免同一份数据在两个地方同时存在后面再来一次结算时出现“到底以哪个购物车为准”的玄学问题。合并进库的商品价格也一律以数据库最新价格为准Session 里旧的购物车价格只当参考这是整条链路里最统一的一个原则。5. 购物车避坑与排查会话失效、乱码与数据不一致5.1 Session 失效导致购物车“神秘清空”现象用户把商品加了又加隔几分钟回来刷新购物车页页面显示购物车是空的。商品列表页又正常唯独购物车里空无一物。原因通常有两个。第一个是 Tomcat 默认 session-timeout 为 30 分钟用户停顿超过这个时间服务端删除 Session浏览器下次请求带着旧 JSESSIONID找不到对应 Session 就会新建一个空会话。第二个只在开发阶段出现IDEA 运行 javaWeb 项目时点了重新部署Tomcat 重新加载应用内存中的 Session 全部丢失旧 Cookie 自然失效。这两个原因叠加购物车真的会“神秘清空”。解决方法是分两层。第一层在 web.xml 里调大超时时间session-config session-timeout120/session-timeout /session-config第二层检查 IDEA 部署配置不要勾选 Clear application data on server shutdown。如果你的购物车需要长期保存那就要么把条目同步进 t_cart 表要么直接全量走数据库方案不要再依赖 Session。Session 适合当缓存不适合当持久化存储。5.2 中文乱码的完整排查链路请求、响应与数据库连接串现象购物车页面里商品名称混进问号或乱码直接入库的商品名称在 Navicat 里看到的是问号。乱码可能出现在三个环节按顺序排查。第一段是 POST 请求参数doPost 里没有 setCharacterEncoding 时Tomcat 默认按 ISO-8859-1 解码中文必乱。第二段是 JSP 文件本身的编码和响应头的 contentType。第三段是 JDBC 连接串没声明 characterEncoding 的话驱动可能用错字符集。解决办法依次是// 放在 doPost 方法最顶部读取任何参数之前 req.setCharacterEncoding(UTF-8); // 设置响应编码 resp.setContentType(text/html; charsetUTF-8);jdbc:mysql://localhost:3306/cartdb?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai最后还有一个容易被忽略的点IDEA 里 JSP 文件右下角显示的编码必须是 UTF-8。如果源文件本身是 GBK 存储无论 contentType 怎么写都会乱码。打开 Settings → File Encodings把 Global Encodings、Project Encodings、Default encoding for properties files 全部改成 UTF-8再检查一次该文件是否显示为 UTF-8。5.3 商品下架和价格变化后购物车条目如何兜底现象商品在后台被下架用户购物车页面还在显示购买入口结算时订单价格和商品列表页看到的明显不一样。原因CartItem 里缓存的 Product 是加购时的快照商品表和购物车之间没有任何同步机制。渲染页面和结算时直接用缓存数据自然就与后台变更脱节。购物车是“运行时状态”商品是“业务数据”业务数据变了运行时状态必须重新校准。解决思路是渲染和结算前都做一次校准。遍历购物车条目按商品 id 从数据库查出最新状态下架的直接从购物车移除价格不同的以数据库最新价格为准for (CartItem item : cart.getItems().values()) { Product latest productDao.findById(item.getProduct().getId()); if (latest null || latest.getStatus() ! 1) { cart.remove(item.getProduct().getId()); continue; } if (!latest.getPrice().equals(item.getProduct().getPrice())) { item.getProduct().setPrice(latest.getPrice()); priceChanged true; } }这种做法能顺手解决“结算价和页面价不一致”的投诉。价格变了之后在页面加一行“商品价格已更新”的提示而不是默默改价让用户下单后才发现对不上。库存同理控制填充数量最大是Math.min(item.getQuantity(), latest.getStock())避免用户拿着已超库存的旧数量去结算。5.4 重复提交把库存扣成负数幂等键与条件更新现象用户快速双击结算按钮数据库里出现两笔订单商品库存变成负数。原因前端按钮没有防重复提交后端也没有做任何幂等保护。订单号用时间戳加用户 id 生成时如果双击跨了不同的毫秒订单号不同两笔订单都能插入成功库存被扣两次。解决要分三层。第一层是前端button 首次点击后设置 disabled并清空表单里的提交标记防止连续点击这层只改善体验不能当安全边界。第二层是后端条件更新库存就是 4.2 里那行UPDATE t_product SET stock stock - ? WHERE id ? AND stock ?受影响行数为 0 直接回滚数据库行锁保证同一商品同时只有一个扣减成功。第三层是订单幂等让订单号带上更细的粒度System.currentTimeMillis() _ userId在同一个毫秒内重复请求时会生成相同主键第二个 INSERT 会因主键冲突失败如果跨毫秒则需要前端防重复或后端做短期检查配合没有哪一层能单独挡住所有情况。6. 购物车测试点自查与进阶改造从点按钮到接口验证6.1 购物车核心测试点加购、改数、删项、结算的边界购物车测试点不用追求数量要覆盖住状态变化的每个边界。我最常用的一张自查表测试点操作步骤预期结果首次加购对从未加过的商品点加入购物车列表出现该商品数量为 1重复加购同一商品再加一次不出现新行数量变成 2修改数量把数量改为 0 提交条目被移除不保留负号或零下架商品后台下架后打开购物车条目自动移除或不可点击结算商品改价修改价格后点结算订单明细按新价格生成库存不足把库存改成 0 后结算提示库存不足订单不生成重复提交快速双击结算按钮只生成一笔订单库存只扣一次会话超时等待超时后刷新购物车不会出现旧条目的残留尸体测完这张表购物车模块的基本质量就有了底。更严谨的做法是把这些用例写成 JUnit但对简易项目来说手动点一遍并截图记录也算有效交付。6.2 用日志和浏览器开发者工具验证会话与 SQL测试时我常会打开浏览器开发者工具的 Application 面板看 Cookies 里的 JSESSIONID 在每次刷新后有没有变化。如果它一直在变说明 Session 没有稳定保存下来十有八九是热部署或超时配置的问题。这个信号比任何报错信息都直接。后端也要配合做一件事在 DAO 里把 SQL 打出来。简单做法是System.out.println或者引入 slf4j 统一输出。重点看两类 SQLt_cart 的INSERT ... ON DUPLICATE KEY UPDATE是否真的是“重复加购累加”以及 t_product 的stock stock - ? WHERE stock ?在库存不足时是否返回 0。看不到这两条 SQL整个购物车流程就是黑盒在跑后面出了故障只能靠猜。6.3 从 Session 购物车迁移到 Redis三个必改的点项目将来如果要做多实例部署Session 购物车会因为请求打到不同节点而变空迁移到 Redis 是低成本的方向。迁移不需要重写整套逻辑但有三个点必须改第一Cart 和 CartItem 要实现 Serializable确保 Redis 能序列化存储第二CartServlet 里 getCartFromSession 改成从 RedisTemplate 读取key 设计成cart:用户ID读取不到再 new 一个第三结算成功后的“清空购物车”要同时删 Redis key 和 t_cart 表两个地方的数据不能只清一处。迁移的收益是购物车不再随 Servlet 容器重启消失代价是每个请求多一次 Redis 网络往返。简易项目不建议一上来就上 Redis等 Session mysql 的组合确实跑不动了再按这三个点做改造路径会清晰很多。测试完之后我一般还会做一件小事把同一份购物车源代码分别跑一遍“未登录加购再登录”和“登录后直接加购”两条路径对比 t_cart 表里的数据是否一致。这个动作能暴露合并逻辑里最隐蔽的覆盖 bug。购物车模块的难点从来不在写出来而在各种状态切换之间的边角情况多测几遍把上面每一条都落实到自己的清单里才是这个简易项目真正的收获。希望帮到你。本文还有配套的精品资源点击获取