简介:基于Eclipse构建的JavaWeb网上购物商城项目,面向初级开发者,适合学习Servlet、JSP、JDBC和MVC分层开发,并在此基础上进行二次扩展,解决从零搭建小型电商系统时对技术选型和项目结构不明确的问题。压缩包共1122个文件,大小8.52MB,其中235个HTML页面、171个CSS样式、137个JS脚本、157个PNG图片和69个JPG图片等构成前端展示资源;18个JSP页面、41个Java源文件、42个class文件、SQL脚本及XML配置等则用于后端业务逻辑、数据存储和项目部署配置,此外还包括properties、txt等辅助文件,从页面展示到数据持久化均有涉及,可直观看出项目的前后端构成。目前已有2078人浏览学习,适合入门者对照源码理解电商项目结构。通过该项目可掌握商品信息展示、购物车管理、会话跟踪、数据库读写等关键实现方式,理解Session与Cookie、JDBC操作、动态页面渲染等核心知识点,为后续完善订单处理、支付接口和后台管理等功能打下基础。整体代码结构相对清晰,便于初学者定位核心模块,是理解JavaWeb经典开发流程和电商基础功能的实用样例。
1. JavaWeb 商城购买项目:Eclipse 老技术栈为什么还能帮你把购买流程吃透
每年实训季和毕设季,「JavaWeb商城购买」这类题目看起来只有几个字,但真正下手的人大多会卡在同一排问题上:Eclipse 里 Tomcat 启动报 Bootstrap 主类找不到、MySQL 连接串配不对、购物车加了却提交不了订单。这套技术栈确实老,可它恰好把 Servlet 生命周期、Session 状态、JDBC 事务这些换了框架也不变的东西摊开摆在你面前。这篇笔记按我实际调通一个网上购物项目的顺序来写,从环境配对、数据表设计,到购物车、下单、扣库存、支付状态流转,最后把最常踩的启动、乱码、驱动、部署问题一次说清。适合正在做课设、毕设,或者想把 Servlet 和数据库基本功补扎实的人。方案用老派但好用的组合:Eclipse + JSP/Servlet + MySQL,不引入 SpringBoot。
2. 在 Eclipse 里跑通第一个 JavaWeb 商城页:JDK、Tomcat、MySQL 的版本配对与最小骨架
2.1 版本配对:JDK 8、Tomcat 9、Eclipse Enterprise 版才是一条稳路
很多人上手就装最新版 Eclipse,再配一个 Tomcat 11,结果项目一启动就报一串 ClassNotFoundException,然后开始怀疑自己代码写得不对。其实问题出在版本身上:Tomcat 10 和 11 属于 Jakarta EE,Servlet 的包名从 javax.servlet 换成了 jakarta.servlet,老教程里的 import javax.servlet.http.* 在编译期直接报红,网上拷下来的 JavaWeb 项目几乎全是这种老代码。
我一般会按下面这套组合配,能少受不少折腾:
| 组件 | 推荐版本 | 理由 |
|---|---|---|
| JDK | 1.8(编译级别 1.8) | 绝大多数教材和实训代码都是 1.8 写的,语法上最省心 |
| Eclipse | 2022-06 或更新,选 Enterprise Java and Web Developers 版 | 自带 Dynamic Web Project、Server 视图和 JSP/HTML 编辑器,少了这些后面寸步难行 |
| Tomcat | 9.0.x | 仍是 javax.servlet 命名空间,Servlet 4.0 规范,和教材代码兼容 |
| MySQL | 5.7 或 8.0 | 8.0 需要配对应驱动连接串,5.7 更宽松 |
| JDBC 驱动 | mysql-connector-java 8.0.x | 兼容 5.7 和 8.0,驱动类名写 com.mysql.cj.jdbc.Driver |
提示:Tomcat 11 本身不是坏选择,但它意味着你要把所有 javax 改成 jakarta,对 JavaWeb 商城这种以老教材为底子的项目来说,这个成本完全没有必要。
这里面还有一层容易被忽视的坑:Eclipse 从 2021 年之后的版本要求 JDK 11 以上才能启动 Eclipse 本身,而项目编译目标仍然可以选 1.8。两件事不冲突,但新人经常在这卡住——Eclipse 打不开,就去翻 eclipse.ini 看启动日志里是不是提示需要更高 JDK。安装 Eclipse 时如果图快选了 Java SE 版,后面会发现没有 Server 视图、没有 Dynamic Web Project 选项,等于废了一半,所以下载时认准 Enterprise Java and Web Developers 这个发行版。
2.2 新建 Dynamic Web Project:把 Tomcat 运行时挂给项目并跑通第一个 Servlet
从零到能跑通一个页面,完整操作是这样的:打开 Eclipse,菜单 File → New → Dynamic Web Project,项目名写 shop,Target runtime 下拉框里选中刚才配置的 Tomcat 9.0.x;Dynamic web module version 选 3.1 或 4.0,并勾上 Generate web.xml。这一步的关键是 Target runtime 不能留空白,留空白等于告诉 Eclipse 这是个没有服务器运行时的普通项目。
接着新建第一个 Servlet,验证整个链路是通的。右键项目 src 目录 → New → Servlet,类名 IndexServlet,URL mapping 填 /index。这是最直接的验证方式,比一上来就写一堆 DAO 和 JSP 更不容易把问题扩散。
// 首页跳转 Servlet:负责把请求转发到商品列表页 @WebServlet("/index") public class IndexServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 转发到 JSP:浏览器地址栏路径不变,请求和参数原样带到页面 req.getRequestDispatcher("/index.jsp").forward(req, resp); } }说明:@WebServlet 是 Servlet 3.0 起推荐的注解方式,和 web.xml 里的 加 等价。注意 url-pattern 必须以 / 开头,写成 "index" 不报错,但访问时的路由永远 404。第一次 Run on Server 时,Eclipse 会自动创建一个 Server 实例并把项目挂进去,左下角状态显示 started 就说明运行时挂对了。如果请求进不了 Servlet,先在 doGet 第一行打断点,看请求有没有被 Filter 拦截,而不是一上来就怀疑 Tomcat 装坏了。
2.3 JDBC 连接 MySQL 8:驱动类名、时区、SSL 参数一次配对
把 mysql-connector-java 8.0.x 的 jar 复制到 WebContent/WEB-INF/lib 目录下,Eclipse 会自动把它加入构建路径。最容易翻车的是把 jar 扔到项目根的任意文件夹,因为 JavaWeb 项目的依赖最终要从 WEB-INF/lib 发布到 Tomcat,位置错了编译期不报错,运行期直接 ClassNotFoundException。
public class DBHelper { // MySQL 8 的驱动类名,注意是 com.mysql.cj.jdbc.Driver,不是老版的 com.mysql.jdbc.Driver private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; // 四段参数:关 SSL、指定时区、指定字符集、允许公钥检索 private static final String URL = "jdbc:mysql://localhost:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "123456"; public static Connection getConnection() throws Exception { Class.forName(DRIVER); // 触发驱动注册到 DriverManager return DriverManager.getConnection(URL, USER, PASSWORD); } }参数说明:useSSL=false 关掉本地连接时 MySQL 8 默认的 SSL 握手告警;serverTimezone=Asia/Shanghai 解决“The server time zone value is unrecognized”的报错;characterEncoding=utf8 保证写入数据库字段的字符不乱码;allowPublicKeyRetrieval=true 解决 caching_sha2_password 认证插件下的 Public Key Retrieval is not allowed 问题。密码写在代码里只限本地学习,部署时至少放到配置文件里。这个工具类写完后先用一个带 main 的测试类直接调 getConnection,连通了再进 Web 层,能省一半调试时间。
3. 从购物车到提交订单:五张表设计、Session 方案与下单核心路径
3.1 商城购买最少五张表:用户、商品、购物车、订单、订单项的 DDL
一个能讲清楚的商城购买业务,表结构至少要覆盖用户、商品、订单、订单明细这四类信息。下面按全量口径列五张表,如果用 Session 购物车,cart 表这轮可以省掉,留到做购物车持久化时再补。
| 表名 | 关键字段 | 职责 |
|---|---|---|
| user | id, username, password, nickname, address | 登录人和收货信息 |
| product | id, name, price, stock, status | 商品快照与库存 |
| cart | id, user_id, product_id, count | 数据库版购物车 |
| orders | id, order_no, user_id, total_price, status, create_time, pay_time | 订单主表 |
| order_item | id, order_id, product_id, product_name, price, count | 订单行,冗余商品名与单价 |
orders 和 order_item 拆开的原因很直白:一个订单多个商品、不同商品下单价格不同、历史订单不能因为商品改名就变,这些语义一张表表达不了。表名叫 orders 而不是 order,因为 order 是 SQL 关键字,用带前缀的复数名最省心。用户表 password 建议存 MD5 或 SHA-256 加盐后的值,别存明文,答辩时这是送分也是送命题。
CREATE TABLE 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 DEFAULT 1 ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, -- 订单号唯一,防重复下单的兜底 user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已取消 3超时关闭 4已完成 create_time DATETIME NOT NULL, pay_time DATETIME NULL ); CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100) NOT NULL, -- 冗余:商品改名不影响历史订单 price DECIMAL(10,2) NOT NULL, -- 下单时单价,之后不要再查商品表 count INT NOT NULL );价格用 DECIMAL(10,2) 而不是 FLOAT,金额精度别用浮点,这是老生常谈但每届都有人踩。order_no 的 UNIQUE 索引除了查询方便,更是抵抗重复点击的最后一层防线:同一个订单号第二次 INSERT 会抛 Duplicate entry 异常,而不是默默生成两单。外键我建议用逻辑外键,应用层保证关联,因为物理外键在删除商品、用户时会带出一串级联限制,新手反而容易被绊倒。
3.2 购物车存 Session 还是存表:两种方案的取舍与加购代码
购物车有两条路:Session 方案把 Map<Integer, Integer>(商品ID → 数量)放进 HttpSession;DB 方案在 cart 表里按 user_id 存购物车行。Session 方案的优点是免建表、实现最快、容器天然帮你隔离不同用户;缺点是服务重启购物车清空、换设备看不到、也无法统计加购未支付的数据。DB 方案的好处是登录后任意设备可见,但要多写增删改四套 SQL,还要考虑未登录状态怎么合并。
我一般建议课设先把 Session 方案做通,把购买链路跑顺,购物车持久化作为答辩加分项。常见的折衷做法是未登录用 Session、登录后把 Session 购物车同步到 cart 表,不过第一版别碰这个,工作量不成比例。
// 加购接口:从页面拿到商品ID,往 Session 购物车里放一条 @WebServlet("/cart/add") public class CartAddServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int productId = Integer.parseInt(req.getParameter("productId")); HttpSession session = req.getSession(); // 购物车结构:key 是商品ID,value 是购买数量 Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } cart.put(productId, cart.getOrDefault(productId, 0) + 1); resp.sendRedirect(req.getContextPath() + "/cart.jsp"); // 重定向回购物车页 } }getOrDefault 避免了第一次加购时的空指针判断;重定向必须用 getContextPath() 拼前缀,硬编码 /cart.jsp 会丢掉 Web 应用上下文,部署后就是 404。购物车页面从 session.getAttribute("cart") 里拿 Map 循环渲染,改数量就是 put 新值,删除就是 remove(productId)。Session 方案还有一个隐形好处:不需要额外判断这个用户到底开没开过购物车,容器已经替你把它管好了。
3.3 提交订单的代码路径:后端重算价格、生成订单号、校验库存
提交订单不是单纯 insert 一条记录,顺序一般是:校验登录 → 取 Session 购物车 → 遍历购物车查商品最新行情 → 后端重算订单总价 → 生成唯一订单号 → 插入 orders → 批量插入 order_item → 扣减 stock → 清空购物车 → 跳转支付页。前端传的合计金额在这条链路里只当展示用,真正入库的总价必须从 product 表查出来再算,否则改一下页面金额就能低价下单。
private String createOrderNo(Integer userId) { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); String ts = sdf.format(new Date()); // SecureRandom 比 Random 冲突概率更低,四位随机够用 int rand = new java.security.SecureRandom().nextInt(9000) + 1000; return ts + userId + rand; }订单号用时间戳、用户ID、随机数拼出来,保证唯一性。userId 为空时应该提前在登录取向阶段拦截,不要拼出 null 字符串。这个方法的调用要放在事务里执行,和插入 orders 保持同一个 Connection。购物车为空时直接重定向回购物车页并提示,不要继续往下走。库存校验放在扣减这一步,而不是先读一遍库存够不够,因为「先查后扣」在并发下是废的,这个问题第 4 章会重点讲。
4. 付款瞬间才见真章:事务扣库存、订单状态机与超时回滚
4.1 订单状态机:待支付、已支付、已取消、超时关闭的流转约束
商城下单后,订单要区分至少五种状态:
| status | 含义 | 允许流转到 |
|---|---|---|
| 0 | 待支付 | 1(支付成功)、2(用户取消)、3(超时关闭) |
| 1 | 已支付 | 4(确认完成) |
| 2 | 已取消 | 无(终态) |
| 3 | 超时关闭 | 无(终态) |
| 4 | 已完成 | 无(终态) |
很多课设翻车就翻在状态机这里:所有状态都用一个 update 一把梭,用户取消订单之后又能去支付,支付成功一下,库存也扣了,订单状态却还是待支付,前后端各看各的。状态机不是架构名词,落地就是一句带条件的 UPDATE:支付接口只允许“待支付”转到“已支付”。
-- 支付成功的唯一入口:只影响处于待支付状态的那一行 UPDATE orders SET status = 1, pay_time = NOW() WHERE id = ? AND status = 0;逻辑说明:受影响行数为 1,说明支付成功且状态推进;受影响行数为 0,说明订单已经被取消、被关闭或者已经支付过,前端直接提示“订单状态不可支付”。这一个 UPDATE 同时解决了重复支付和状态跳变两个问题。比先 SELECT 再判断再 UPDATE 的方式稳妥得多——后者的判断窗口里,两个并发请求都能通过检查,最后状态被后到的覆盖,就出现支付两次只算一单的事故。
4.2 扣库存和下单必须同事务:setAutoCommit(false) 加乐观锁
血泪经验:订单 insert 和库存扣减分两次提交数据库,单机跑不出问题,一到多个用户同时下单,库存变负数、订单却在增加。原因是 Connection 默认 autocommit 模式,每条 SQL 独立提交,第一个操作成功第二个失败,数据库状态就是半成品。下单接口的完整事务骨架如下。
public boolean placeOrder(HttpServletRequest req, Integer userId) throws Exception { Connection conn = DBHelper.getConnection(); PreparedStatement ps = null; try { conn.setAutoCommit(false); // 关键:关闭自动提交,让两个动作绑定成原子操作 // 1. 遍历 Session 购物车,从 product 表读最新价格与库存,累加 totalPrice // 先做合法性校验,商品不存在或已下架就抛异常 // 2. 插入 orders 主表,得到自增主键 orderId // 3. 逐条插入 order_item // 4. 乐观锁扣库存:stock >= ? 这个条件保证不会扣成负数 String deductSql = "UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?"; // 执行后判断返回行数:0 行说明库存不足,抛异常回滚 // 5. 清空 Session 购物车 req.getSession().removeAttribute("cart"); conn.commit(); // 全链路成功,统一提交 } catch (Exception e) { conn.rollback(); // 任何一步失败,订单和库存一起还原,这就是后悔药 throw e; } finally { // 归还连接、关闭 PreparedStatement 和 ResultSet } }setAutoCommit(false) 之后,这个 Connection 上的所有 SQL 都只在事务里排队,直到 commit 才真正落盘。rollback 会把这一步插入的订单、扣减的库存全部还原,所以“库存不足”这种业务异常不能被 catch 吞掉,必须继续抛出让事务回滚。扣库存 SQL 里的 stock >= ? 是乐观锁最廉价的写法,它把“检查库存”和“扣减库存”合并成一条原子 UPDATE,不需要先 SELECT 再 UPDATE。资源关闭建议用 try-with-resources,否则连接池(C3P0/Druid)很快被耗尽,服务就变成黑匣子。PreparedStatement 也比 Statement 多了 SQL 注入防护,这条在答辩里也常被问。
4.3 超时未支付自动关单:定时扫描、状态置为关闭并回补库存
状态机里“超时关闭”没人手动触发,得靠程序自己扫地。常见做法是起一个定时任务,每分钟扫一次超过 10 分钟仍处于待支付的订单,把它们置为关闭,再把订单关联的商品库存加回去。回补库存必须按 order_item 里的实际数量,不能简单地加一。
// 每分钟执行:找出超时未支付订单 String findSql = "SELECT id FROM orders WHERE status = 0 " + "AND create_time < DATE_SUB(NOW(), INTERVAL 10 MINUTE)"; // 对每个订单:先置关闭 String closeSql = "UPDATE orders SET status = 3 WHERE id = ? AND status = 0"; // 再按订单项回补库存 String restockSql = "UPDATE product p JOIN order_item oi ON p.id = oi.product_id " + "SET p.stock = p.stock + oi.count WHERE oi.order_id = ?";DATE_SUB 的时间差精确到秒,10 分钟只是一个参数,按业务调 15、30 都行。closeSql 里的 AND status = 0 和 4.1 一个思路,防止订单刚被支付接口改成已支付,这边定时任务又给关了,两层保护不可省。回补库存用相对累加 stock = stock + oi.count,即使这个商品期间卖出过,也不会把现有库存顶没。定时任务在课设里用 Timer 或 ScheduledExecutorService,放在一个随 Tomcat 启动的 ServletContextListener 里,销毁时记得 shutdown 线程池。如果不想做超时关单,至少把“用户取消订单”接口做好,回补库存的逻辑复用同一套。
5. Eclipse 跑 JavaWeb 商城必看的避坑记录:启动失败、中文乱码、驱动与部署排错
这章集中讲真实调试里被反复问到的五个问题。每条按现象、原因、解决的顺序写,遇到哪条直接对号入座。
5.1 Tomcat 报「找不到或无法加载主类 org.apache.catalina.startup.Bootstrap」
现象:Eclipse 启动 Tomcat,控制台红字“找不到或无法加载主类 org.apache.catalina.startup.Bootstrap”,Server 状态一直 Starting 又变回 stopped。
原因:这个类在 Tomcat 安装目录的 lib/catalina.jar 里,报找不到通常是 classpath 里没有它。常见诱因有两个:一是项目没有关联 Tomcat 运行时,右键项目 Properties → Targeted Runtimes 是空的;二是把 servlet-api.jar、catalina.jar 这类容器 jar 复制进了 WEB-INF/lib,类加载冲突,服务器找不到自己该用的 Bootstrap。
解决:先确认用的是 Tomcat 9 而不是 Tomcat 11,11 下包名是 jakarta.servlet,老代码 import javax 会直接失败;然后在项目属性里勾上 Targeted Runtimes 里的 Tomcat,删掉 WEB-INF/lib 下多余的 servlet-api.jar 和 catalina*.jar;最后在 Servers 视图右键 Tomcat → Clean,项目右键 → Clean,重新启动。
5.2 Eclipse 里创建不了 Servlet / Filter
现象:右键 New 菜单里看不到 Servlet 和 Filter,或者手动建 class 写上 @WebServlet 后,访问路径 404。
原因:创建不了是 Eclipse 版本问题。官方 Eclipse 区分为 Java 版和 Enterprise Java and Web Developers 两个发行版,Java 版不带 JST Web 工具集,就没有 Servlet/Filter 向导。能建类但注解不生效,是项目没被识别为 Web 项目,Dynamic Web Module facet 没启用,Eclipse 不会把它当成动态 Web 项目去编译部署。
解决:做 JavaWeb 不要下 Java SE 版,下载页认准 Enterprise Java and Web Developers。已有旧项目,右键 Properties → Project Facets → 勾上 Dynamic Web Module,Java 版本调到 1.8。如果 facet 勾不动,直接新建 Dynamic Web Project 把源码拷进去,别跟 IDE 死磕,这个时间花得不值。
5.3 页面和接口中文全部乱码
现象:JSP 页面显示中文变成“锟斤拷”或者问号,表单提交到 MySQL 里的数据也是问号。
原因:编码在四个位置不一致:JSP 文件本身的 pageEncoding 字符集;服务器读取请求参数时的编码;响应输出到浏览器时的编码;数据库连接和表字段的字符集。最坑的是页面能看到中文,一提交到数据库就乱,这种通常是连接串没带 characterEncoding=utf8,或者建表没写 DEFAULT CHARSET。
解决:统一做法是写一个过滤器放在所有请求最前面设置编码:
@WebFilter("/*") public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); // 必须在读取任何参数之前 resp.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }说明:setCharacterEncoding 在读取参数之前调用才有效,所以要用过滤器统一拦截,而不是在每个 Servlet 里补。MySQL 建表统一写 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,utf8mb4 才是完整 UTF-8,emoji 也能存;老库已经建错的用 ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4 修复。
5.4 MySQL 8 驱动连接失败:类名、时区、SSL 一个都不能少
现象:Class.forName 报 ClassNotFoundException,或者连接时报“The server time zone value is unrecognized”,再或者报 Public Key Retrieval is not allowed。
原因:三类问题混在一起。驱动 jar 没放到 WEB-INF/lib;驱动类名写的是老版 com.mysql.jdbc.Driver,MySQL 8 下这个类已移除;连接串缺 serverTimezone 和 allowPublicKeyRetrieval,MySQL 8 默认认证插件 caching_sha2_password 对这两项很敏感。
解决:统一用 2.3 节那串 URL,驱动类名写 com.mysql.cj.jdbc.Driver。一个很有效的自检办法:写一个带 main 的测试类直接调 DBHelper.getConnection(),能通说明 JDBC 这层没问题,剩下问题在 Web 发布层,别每次都等 Tomcat 甩一段长报错才动手。
5.5 改了代码不生效或请求总是 404
现象:改 JSP 刷新没变化,改 Servlet 重新访问还是旧逻辑,或者新加的页面直接 404。
原因:Eclipse 里的「项目」和「Tomcat 上运行实例」是两回事。只 Build 只写了 class 到磁盘,没发布到 Tomcat 的 webapps,Tomcat 跑的还是上一次的旧文件。404 还有一种经典原因:Module 没发布上去,或者 Servlet 映射路径少了开头斜杠。
解决:在 Servers 视图里看当前 Tomcat 的 Modules 有没有你的项目;修改后右键项目 → Clean,再对 Tomcat 执行 Clean,最后 Restart Deploy。浏览器调试时按 Ctrl+F5 强制刷新。检查 Servlet 路径时,把 @WebServlet("/xxx") 和表单 action="/shop/xxx"(带上下文路径)两处对着看,别一个带前缀一个不带。
6. 把「能跑」做成「可信」:并发验证、状态约束与幂等收尾
6.1 并发验证:两个入口抢最后一个库存
功能跑通只是开始。我习惯把某个商品库存改成 1,然后开两个不同浏览器(Chrome 和 Edge)各登一个账号,同时把同一商品下单。理想结果是:一个订单成功,另一个提示库存不足,数据库里库存是 0 而不是 -1。用一条 SQL 就能收尾验证:
-- 下单后自查:库存不为负、订单状态与订单项数量对得上 SELECT p.id, p.name, p.stock FROM product p WHERE p.id = 1; SELECT COUNT(*) AS item_count FROM order_item WHERE product_id = 1; SELECT order_no, status FROM orders WHERE id = 2;如果库存出现 -1,基本可以断定扣库存的 UPDATE 没带 stock >= ? 条件,或者没走 4.2 的事务;出现两条订单但库存只扣了一次,说明订单插入和库存扣减不在同一个 Connection 事务里。这个问题答辩被问概率极高,建议动手前就把它想清楚。
6.2 给订单加唯一约束和状态流转约束
下单接口还有一个隐藏陷阱是重复提交按钮:用户双击提交,或者前端跳转前又发了一次请求,就可能同一个人下单两次。应付它有三道坎:前端按钮置灰只是体验;后端要判断订单号;数据库要有最后防线。order_no 的 UNIQUE 能保证相同订单号第二次插入失败,这也是为什么订单号里要带时间戳和随机数。状态流转则统一走「带旧状态的 UPDATE」,支付、取消、超时关闭全部写 and status=旧值,再用受影响行数决定是否继续。
以前我做网上购物课设,支付成功没做状态判断,测试时连点两次支付按钮,同一订单产生了两次已支付流水,库存按两单扣了,被答辩老师直接点破。后来我定下一条规矩:凡是订单状态变更,一律 UPDATE ... WHERE id=? AND status=?,不满足就报错。这个习惯后来在做 Spring 项目时换成 @Transactional 加乐观锁,本质还是同一个东西。希望你一次想清楚再动手,少留几个这种低级坑。希望帮到你。
本文还有配套的精品资源,点击获取