资讯详情 JavaWeb在线订餐系统源码解析:Servlet+JDBC下单事务与库存扣减
📅 2026/10/23 18:00:49
简介这份 JavaWeb 必胜客在线订餐系统压缩包面向学习 JavaWeb 与餐饮订餐业务实现的学生、初级开发者提供一套可运行的模拟必胜客在线订餐流程项目覆盖用户注册登录、菜单浏览、购物车、下单支付、订单管理及后台菜品与订单处理等场景。包内共1307个文件以png/jpg页面素材、java源码、class编译文件、jsp/html视图、js/css前端资源及jar依赖为主并含sql脚本与xml/properties配置压缩后约66.88MB便于完整还原项目结构。已有2518人学习下载适合作为课程设计、毕业设计参考、JavaWeb综合练习与项目实战训练素材。源码可帮助理解MVC分层、Servlet处理请求、JSP展示动态内容、JavaBean与DAO封装数据库操作以及前端AJAX交互和基础安全、异常处理思路配合数据库脚本还能梳理用户、菜品、订单等表关系提升从需求到实现的项目认知。1. 一份点餐系统真正的难点在下单按钮后面这份 JavaWeb 在线订餐源码能跑出什么点外卖时你在页面上只做三件事挑菜、加购物车、点结算。但后台要同时处理分类查询、库存判断、订单号生成、金额计算、地址快照、支付状态回写任何一步没兜住用户就会看到一张金额对不上的订单。这份 JavaWeb 在线订餐系统源码包完整地把这条链路用最朴素的方式写了一遍JSP 渲染页面、Servlet 接请求、JDBC 操作数据库没有 Spring 那一套注解和容器。它适合两类人一是想弄明白 request、response、session、filter 到底怎么协作的初学者二是拿到需求就要在一周内交出可演示系统的开发者。菜单浏览、购物车、下单、订单查询、后台菜品与分类管理、图片上传这些流程全都落在代码里改起来不用先啃框架文档。2. 技术栈与目录结构JSP Servlet JDBC 的三层划分落到哪几个包2.1 技术选型为什么这套系统宁可用 Servlet 也不上 Spring Boot打开压缩包第一眼看到的是WEB-INF/web.xml这就定了基调这是一个标准的 Servlet 容器项目不是 Spring Boot 那种内嵌容器的可执行 jar。整套技术栈大概是 Servlet 3.1 JSP 2.3 JSTL 1.2 JDBC MySQL前端是 Bootstrap 加 jQuery异步请求用原生XMLHttpRequest或$.ajax服务器是 Tomcat 8.5 或 9。我一般把这种组合叫「裸 JavaWeb」它的好处不是性能而是所有东西都摊在明面上。你在web.xml里能看到每一个 URL 映射到哪个 Servlet在 Servlet 里能看到request.getParameter()拿到什么在 DAO 里能看到PreparedStatement拼的哪条 SQL。出了 500堆栈第一行就是你自己写的类不存在「框架把异常吃了」这种事。对想搞懂 HTTP 请求生命周期的人来说这份代码比任何教程都直观。代价也很明显。没有依赖注入Service 对象得自己new没有事务注解setAutoCommit(false)要手写没有拦截器登录校验得自己写 Filter。常见做法是在util包里放一个DBUtil用 ThreadLocal 管理 Connection让同一请求线程里的多个 DAO 共用一个连接这样下单时才能跨表开事务。如果你后续想迁到 Spring Boot麻烦的地方恰恰在这儿——业务逻辑和 JDBC 耦合得比较紧DAO 层的ResultSet手动映射要一条条改成 MyBatis 或 JPA。MySQL 版本上5.7 和 8.0 都能跑区别在驱动包5.7 用com.mysql.jdbc.Driver8.0 用com.mysql.cj.jdbc.Driver连接串还要多带时区和 SSL 参数。这个细节在第 5 章的避坑里会专门说因为它是新手第一个卡住的地方。2.2 目录结构与包划分controller / service / dao / entity 各管什么源码的包结构基本是教科书式的分层我在本地展开后大致是这样src/ └── com/order/ ├── controller/ # 所有 Servlet负责收参数、调 Service、转发或重定向 │ ├── FoodServlet.java │ ├── CartServlet.java │ ├── OrderServlet.java │ └── AdminFoodServlet.java ├── service/ # 业务接口 │ ├── FoodService.java │ └── impl/FoodServiceImpl.java ├── dao/ # 数据访问接口 │ ├── FoodDao.java │ └── impl/FoodDaoImpl.java ├── entity/ # 与表一一对应的 JavaBean │ ├── Food.java │ ├── Order.java │ └── OrderItem.java ├── filter/ # 编码过滤器、后台登录过滤器 ├── util/ # DBUtil、PageBean、MD5Util、UUIDUtil └── vo/ # 页面展示用的组合对象比如带分类名的 FoodVO WebContent/ ├── WEB-INF/ │ ├── web.xml │ └── lib/ # 所有第三方 jar必须放这里 ├── jsp/ # 前台与后台页面 ├── static/ # css、js、图片 └── upload/ # 菜品图片上传目录这里有个容易忽略的点WEB-INF/lib才是 Web 项目真正的类路径。很多人习惯在 IDE 里把 mysql 驱动Add to Build Path编译能过Tomcat 一跑就ClassNotFoundException因为那个 jar 没进 war 包。我一般直接把 jar 复制到WEB-INF/lib下让 IDE 自己识别省得后面部署时再排查一遍。entity和vo分开是我比较推荐的习惯。Food严格对应t_food表字段FoodVO继承或组合Food额外挂一个categoryName专门给列表页用。这样 DAO 只负责单表查询连表展示的逻辑放到 Service 里组装页面就不会因为字段对不上而反复改。2.3 数据库表设计8 张表撑起整个点餐流程这套系统的表不多但覆盖了从浏览到履约的完整闭环。核心是下面这几张表名作用关键字段t_user前台会员id、username、password、phone、create_timet_admin后台管理员id、username、password、rolet_category菜品分类id、name、sort、statust_food菜品id、category_id、name、price、image、stock、statust_address收货地址id、user_id、receiver、phone、detail、is_defaultt_order订单主表id、order_no、user_id、total_amount、status、address_snapshot、create_timet_order_item订单明细id、order_id、food_id、food_name、price、quantityt_cart购物车持久化可选id、user_id、food_id、quantity两个设计细节值得单独说。第一t_order_item里冗余了food_name和price这不是反范式偷懒而是必要的快照菜品下架或调价之后历史订单必须还能显示下单当时的名字和价格否则用户翻旧订单会看到一堆「已删除菜品」。第二t_order里存的是address_snapshot字符串而不是address_id同理用户改了收货地址不该影响已完成订单的展示。建表时我一般会给order_no加唯一索引给t_food.category_id和t_order.user_id加普通索引CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号时间戳用户尾号随机数, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL COMMENT 金额一律用 DECIMAL不用 DOUBLE, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待付款 1已付款 2配送中 3已完成 4已取消, address_snapshot VARCHAR(255) NOT NULL COMMENT 下单时的地址快照, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;金额字段用DECIMAL(10,2)是血泪经验。用DOUBLE存价格三件商品一加就会出现19.900000000000002页面上直接显示成科学计数法。Java 侧对应BigDecimal从ResultSet取的时候用getBigDecimal()别先getDouble()再转精度在那一步就丢了。3. 核心链路拆解从菜品列表到订单落库的一次完整请求3.1 菜单展示与分类筛选一个 Servlet 里的两次查询首页左侧是分类右侧是菜品卡片点击分类要能筛选。这套代码没有做前后端分离走的是服务端渲染FoodServlet接收categoryId参数查完数据放进 request 域转发到food_list.jsp由 JSTL 的c:forEach循环输出。WebServlet(/food/list) public class FoodServlet extends HttpServlet { private final FoodService foodService new FoodServiceImpl(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 分类 id 为空表示「全部」用 0 兜底避免 DAO 里拼 null 判断 String cid req.getParameter(categoryId); int categoryId (cid null || cid.isEmpty()) ? 0 : Integer.parseInt(cid); // 页码默认 1每页 8 条PageBean 负责算总页数和 limit 偏移 int pageNum parseInt(req.getParameter(page), 1); PageBeanFoodVO page foodService.queryByPage(categoryId, pageNum, 8); req.setAttribute(page, page); req.setAttribute(categories, foodService.listAllCategory()); // 左侧分类栏 req.setAttribute(activeCid, categoryId); // 用于高亮当前分类 req.getRequestDispatcher(/jsp/food_list.jsp).forward(req, resp); } }categoryId 0这个约定比到处判空好维护DAO 里只需要if (categoryId 0) sql.append( AND category_id ?)一条分支搞定。PageBean里通常放list、pageNum、pageSize、total、totalPage五个字段totalPage用(total pageSize - 1) / pageSize算别用Math.ceil转 double数据量大时有精度隐患。分页 SQL 在 MySQL 里是LIMIT ?, ?两个参数分别是偏移量和条数偏移量 (pageNum - 1) * pageSize。这里有个常见的性能坑LIMIT 100000, 8会先扫描十万行再丢弃菜品表数据量小的时候感觉不到一旦上到几十万行就得改成「记住上一页最后一个 id」的游标分页。这份源码规模下不必过度设计但知道这条边界在哪改的时候心里有数。页面上分类高亮用activeCid配合 JSTL 的c:ifc:forEach items${categories} varc a href${pageContext.request.contextPath}/food/list?categoryId${c.id} classlist-group-item ${c.id activeCid ? active : } ${c.name} /a /c:forEach${pageContext.request.contextPath}不能省。项目部署时 context path 往往不是根路径比如/order写死/food/list会导致 404。这是 JSP 项目里出现频率最高的一类问题后面避坑章节还会提到。3.2 购物车Session 里的 Map 结构怎么存购物车没走数据库直接挂在 HttpSession 上结构是MapInteger, CartItemkey 是菜品 idvalue 里存菜品对象和数量。这样加购、改数量、删除全是内存操作响应快也不用担心游客未登录时没法加购。WebServlet(/cart/*) public class CartServlet extends HttpServlet { SuppressWarnings(unchecked) private MapInteger, CartItem getCart(HttpSession session) { // 首次访问时初始化用 synchronized 块兜住并发首次写入 MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { synchronized (this) { cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new LinkedHashMap(); // 保留加购顺序页面展示更自然 session.setAttribute(cart, cart); } } } return cart; } private void add(HttpServletRequest req, HttpSession session, int foodId, int num) { MapInteger, CartItem cart getCart(session); CartItem item cart.get(foodId); if (item null) { Food food new FoodServiceImpl().getById(foodId); if (food null || food.getStatus() 0) return; // 已下架直接忽略 item new CartItem(food, num); cart.put(foodId, item); } else { item.setQuantity(item.getQuantity() num); // 同一菜品累加不新增行 } } }LinkedHashMap而不是HashMap是因为页面上购物车列表的顺序会直接影响用户感知。HashMap在扩容后顺序会变用户加了三样菜刷新一下顺序乱了体验很怪。这个细节不影响功能但影响「像不像个正经系统」。CartItem里存的是Food对象的引用意味着菜品调价后购物车里显示的还是加购那一刻的价格。这恰恰是对的——下单时以购物车里的价格为准并在订单明细里落快照。如果每次渲染都回查数据库拿最新价用户会看到购物车金额莫名其妙地变投诉量立马上来。Session 购物车的代价是「换设备就没了」和「关浏览器可能丢」。如果要做持久化t_cart表就是为这个准备的登录后把 Session 里的 Map 合并进表下次登录再读回来。这份源码里这张表是留了但没完全接上的正好是二次开发的练手点。3.3 下单事务订单主表 明细表 库存怎么保证一致这是整个系统里唯一必须开事务的地方。一次下单要干四件事插订单主表、批量插订单明细、扣菜品库存、清空购物车。前三件必须同生共死否则会出现「订单生成了但明细没写进去」这种脏数据用户点开订单详情看到一片空白。public String submitOrder(int userId, MapInteger, CartItem cart, Address addr) { if (cart null || cart.isEmpty()) return null; Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交开启事务 // 1. 先算总金额用 BigDecimal全程不碰 double BigDecimal total BigDecimal.ZERO; for (CartItem it : cart.values()) { total total.add(it.getFood().getPrice() .multiply(new BigDecimal(it.getQuantity()))); } String orderNo OrderNoUtil.gen(userId); // 时间戳用户尾号4位随机 // 2. 插入主表返回自增主键 long orderId orderDao.insert(conn, orderNo, userId, total, addr.snapshot()); // 3. 批量插入明细并逐条扣库存库存不足抛异常回滚 for (CartItem it : cart.values()) { int affected foodDao.decreaseStock(conn, it.getFood().getId(), it.getQuantity()); if (affected 0) { throw new BizException(菜品「 it.getFood().getName() 」库存不足); } orderItemDao.insert(conn, orderId, it); } conn.commit(); cart.clear(); // 提交成功后再清购物车顺序不能反 return orderNo; } catch (Exception e) { DBUtil.rollback(conn); throw new BizException(下单失败 e.getMessage(), e); } finally { DBUtil.close(conn); } }几个关键点。扣库存用的是UPDATE t_food SET stock stock - ? WHERE id ? AND stock ?靠数据库的行锁和stock ?条件保证不超卖返回影响行数为 0 就说明库存不够。这比「先 select 查库存、再 update 扣减」安全得多后者在并发下必然超卖是两个事务同时读到库存 1 的经典问题。cart.clear()放在commit()之后。如果放在提交前一旦提交失败回滚购物车已经被清空用户得重新挑一遍菜这是最让人上火的一类 bug。OrderNoUtil.gen()生成规则是yyyyMMddHHmmss 用户 id 后四位 4 位随机数32 位以内加唯一索引兜底。别用UUID直接当订单号32 位十六进制串在客服电话里根本念不出来。3.4 后台菜品管理文件上传与图片路径的两个坑后台加菜品要传图片Servlet 3.0 之后可以用MultipartConfig注解不用再引commons-fileuploadWebServlet(/admin/food/save) MultipartConfig(maxFileSize 2 * 1024 * 1024, maxRequestSize 5 * 1024 * 1024) public class AdminFoodServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String name req.getParameter(name); BigDecimal price new BigDecimal(req.getParameter(price)); int categoryId Integer.parseInt(req.getParameter(categoryId)); Part part req.getPart(image); // 表单里 file 域的 name String imagePath null; if (part ! null part.getSize() 0) { // 取原始文件名后缀重命名防止中文名和重名覆盖 String ext part.getSubmittedFileName() .substring(part.getSubmittedFileName().lastIndexOf(.)); String fileName UUID.randomUUID().toString().replace(-, ) ext; // 存到项目外的固定目录避免重新部署被清空 String realDir D:/order-upload/; part.write(realDir fileName); imagePath /upload/ fileName; // 数据库里只存相对路径 } foodService.save(new Food(name, price, categoryId, imagePath)); resp.sendRedirect(req.getContextPath() /admin/food/list); } }两个容易翻车的点。第一part.write()的路径如果是getServletContext().getRealPath(/upload)文件会写进 Tomcat 的部署目录下次重新部署 war 包整个目录被覆盖图片全丢。我一般把上传目录放在项目外然后在 Tomcat 的server.xml里配一个 Context 的docBase指过去或者干脆用 Nginx 做静态映射。第二数据库里存相对路径/upload/xxx.jpg而不是绝对磁盘路径。存绝对路径的话换台机器部署所有图片链接全废只能挨个改数据库。4. 环境搭建实战从导入 SQL 到 Tomcat 里看到首页4.1 环境准备与版本对齐这套组合对版本比较敏感我先给一张我本地跑通过的对照表组件推荐版本说明JDK8 或 11源码用的javax.servlet别上 JDK 17 配 Tomcat 10Tomcat8.5 或 9.0Tomcat 10 的包名变成jakarta.servlet整份代码都编译不过MySQL5.7 或 8.08.0 要换驱动并加时区参数IDEIntelliJ IDEA 或 EclipseIDEA 需选 Java Enterprise 项目类型构建无 Maven 也可有WEB-INF/lib就能跑Tomcat 10 这条特别要强调。很多人图新直接下最新版结果所有 Servlet 报「找不到符号 HttpServlet」改半小时以为是 JDK 问题。Tomcat 9 及以前是javax.servlet.*10 之后是jakarta.servlet.*这是一次不兼容的命名空间迁移不是版本号小升级。4.2 导入 SQL 与改连接配置压缩包里一般带一个order_system.sql先建库再导入# 登录 MySQL创建库并指定字符集别用默认的 latin1 mysql -uroot -p -e CREATE DATABASE order_system DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; # 导入结构和初始数据菜品分类、管理员账号通常已预置 mysql -uroot -p order_system sql/order_system.sql # 确认表都进去了 mysql -uroot -p order_system -e SHOW TABLES;然后改DBUtil或db.properties里的连接信息。如果是 MySQL 8.0连接串要写成jdbc:mysql://localhost:3306/order_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueserverTimezone不写会报The server time zone value ... is unrecognizeduseSSLfalse是为了消掉启动时的黄色警告allowPublicKeyRetrievaltrue是 8.0 默认加密插件下远程连接需要的。这三个参数少一个都可能连不上属于典型的一步卡一天。驱动类名也要跟着换5.7 是com.mysql.jdbc.Driver8.0 是com.mysql.cj.jdbc.Driver。类名写错的表现是ClassNotFoundException和 jar 没放进WEB-INF/lib的报错一模一样排查时先看 jar 在不在再看类名对不对。4.3 部署到 Tomcat 与 context path 设置IDEA 里的操作是Run → Edit Configurations → → Tomcat Server → Local在 Deployment 标签页加一个 Artifact选xxx:war explodedApplication context 填/order。这个值就是前面反复提到的 context path页面里所有链接都要带上它。不用 IDE 的话手工打包更直观# 在项目根目录打包产物丢进 Tomcat 的 webapps jar -cvf order.war . cp order.war $TOMCAT_HOME/webapps/ # 启动Windows 用 startup.batLinux 用 startup.sh $TOMCAT_HOME/bin/startup.sh # 实时看日志报错第一时间能看到 tail -f $TOMCAT_HOME/logs/catalina.outcatalina.out是排障的主要入口。Servlet 初始化失败、数据库连不上、JSP 编译错误堆栈都在这个文件里。浏览器上只显示一个 500 页面看不到根因所以部署完先开着这个 tail。4.4 验证流程按角色走一遍完整闭环跑起来之后别急着看代码先按用户路径走一遍确认系统是活的打开http://localhost:8080/order/确认首页菜品卡片能加载、图片能显示、分类点击能筛选。注册一个新用户或使用预置账号登录加三样菜进购物车改一次数量删一样确认小计金额随数量实时变化。进结算页填收货地址提交订单看是否跳转到订单详情订单号、金额、明细是否对得上。退出登录用预置管理员账号进/admin在菜品管理里改一个价格回前台刷新确认新价格生效。回到数据库SELECT * FROM t_order ORDER BY id DESC LIMIT 1;和SELECT * FROM t_order_item WHERE order_id ?;核对金额和明细条数。第 5 步是很多人会跳过的。页面显示正常不代表数据落对了尤其是金额前端的 JS 计算和后端的BigDecimal计算很容易对不上。我习惯在下单成功后直接在控制台打一行总金额日志和数据库里的值比一眼能省掉后面一堆对账纠纷。预置账号一般在 SQL 文件的注释里或者t_admin表的初始化 insert 语句里密码如果是 MD5 存的就是 32 位小写串。找不到就用注册功能自己建一个再把role字段改成管理员。5. 避坑与排查这套 JavaWeb 组合最容易翻车的 5 个地方5.1 中文全变问号编码要三处一起改现象表单提交的中文菜品名存进数据库变成???或者页面上的中文显示成乱码。原因编码链条上有三个环节缺一处就断。一是请求体编码POST 默认按 ISO-8859-1 解二是数据库连接串编码三是表和字段的字符集。解决写一个EncodingFilter拦在所有请求前面web.xml里配/*doFilter里调request.setCharacterEncoding(UTF-8)和response.setContentType(text/html;charsetUTF-8)。连接串加useUnicodetruecharacterEncodingutf8建库时指定utf8mb4。GET 请求的参数编码由 Tomcat 的Connector决定8.0 之后默认 UTF-87.x 需要在server.xml里加URIEncodingUTF-8。5.2 404 但路径看着没错注解和 web.xml 打架了现象浏览器访问/food/list报 404控制台没有任何异常。原因Servlet 同时用WebServlet(/food/list)和web.xml里的servlet-mapping配了同一个路径或者web.xml的metadata-completetrue把注解扫描整个关掉了。也有一种情况是url-pattern写成了/food/list/*多一个星号精确匹配就失效了。解决先看web.xml的version和metadata-complete属性只要不是true注解就生效那就把重复的servlet-mapping删掉。再确认 Tomcat 日志里有没有Servlet [xxx] marked as unavailable有的话说明init()抛了异常往上翻堆栈。5.3 下单后库存没扣事务根本没生效现象订单提交成功主表和明细表都有数据但t_food.stock一点没变。原因DBUtil.getConnection()每次返回新连接Service 里setAutoCommit(false)设的是 A 连接DAO 里拿的是 B 连接B 连接仍然是自动提交模式。这种情况在不用 ThreadLocal 的写法里特别常见。解决把 Connection 绑到当前线程DBUtil里用ThreadLocalConnection同一请求内所有 DAO 都从getConnection()取close()时先remove()再真正关闭。改完在下单方法里打一行conn.getAutoCommit()确认为false再往下走。5.4 图片上传成功但刷新就 404文件写进了部署目录现象后台上传图片当下能预览重启 Tomcat 或重新部署 war 之后图片全挂。原因getServletContext().getRealPath(/upload)指向的是解压后的部署目录重新部署时整个目录被删掉重建。解决把上传目录挪到项目外比如D:/order-upload/数据库只存相对路径/upload/xxx.jpg再通过 Tomcat 的Context docBaseD:/order-upload path/upload/或者前置 Nginx 把这个路径映射出去。这样重新部署多少次图片都在。5.5 订单金额差一分钱double 在中间环节漏了现象购物车页面显示 59.90订单落库变成 59.899999999。原因前端 JS 的浮点运算、Java 里某个字段声明成double、或者ResultSet.getDouble()后再转BigDecimal任意一处都会引入误差。解决金额全链路用BigDecimal数据库用DECIMAL(10,2)Java 实体类字段类型是BigDecimal运算用add/multiply而不是和*。除法的场景比如分摊优惠必须指定setScale(2, RoundingMode.HALF_UP)否则ArithmeticException: Non-terminating decimal expansion会直接抛出来。6. 二次开发给订单表补一个状态机与库存扣减的改造思路拿到这套代码的人十有八九第一个需求是「订单状态要能流转」。现状是t_order.status一个TINYINT字段页面上直接显示中文后台改状态就是一句UPDATE ... SET status ?没有任何约束。用户点「取消」能把已完成的订单改成已取消库存也不回滚。我的改造顺序是先定状态表再动代码。状态流转写成枚举用一张表把允许的迁移列清楚代码里只认这张表当前状态允许流转到触发动作附带操作0 待付款1 已付款 / 4 已取消支付回调 / 用户取消取消时回滚库存1 已付款2 配送中 / 4 已取消后台接单 / 用户申请退款退款需人工审核2 配送中3 已完成骑手送达记录完成时间3 已完成无无只读4 已取消无无只读代码上Service 里加一个transition(orderId, targetStatus, operator)方法先SELECT ... FOR UPDATE锁住这行订单比对当前状态是否在允许列表里不在就抛业务异常。这个锁很关键两个后台管理员同时点「接单」和「取消」不加锁就是经典的双写覆盖。库存这块现在的扣减发生在下单时取消时没有回补。补的方法是在状态迁移到 4 的时候遍历明细UPDATE t_food SET stock stock ? WHERE id ?。注意这两步必须在同一个事务里订单状态改了库存没回补数据就永久对不上了。验证方法我一般写一个小的 JUnit 或者干脆用main方法跑构造一笔订单依次调transition到各个状态把违规的迁移比如 3 → 4也调一遍确认抛异常且数据库没被改动。测完SELECT一遍t_order和t_food人眼核对库存数字。这套笨办法比写一堆断言管用因为它逼你把数据真正读出来看一遍。从那以后我每次给这类老项目加业务字段都强制先把状态流转表和库存变更点写在一张纸上再动第一行代码。跳过这步直接改后面一定会在某个「取消订单」的分支里翻车而且往往是在演示前十分钟。希望帮到你。本文还有配套的精品资源点击获取