ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaWeb在线订餐系统源码解析:Servlet+JDBC下单事务与库存扣减

JavaWeb在线订餐系统源码解析:Servlet+JDBC下单事务与库存扣减

简介:这份 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.Driver,8.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_time
t_admin后台管理员id、username、password、role
t_category菜品分类id、name、sort、status
t_food菜品id、category_id、name、price、image、stock、status
t_address收货地址id、user_id、receiver、phone、detail、is_default
t_order订单主表id、order_no、user_id、total_amount、status、address_snapshot、create_time
t_order_item订单明细id、order_id、food_id、food_name、price、quantity
t_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

金额字段用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); PageBean<FoodVO> 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:if>:

<c:forEach items="${categories}" var="c"> <a href="${pageContext.request.contextPath}/food/list?categoryId=${c.id}" class="list-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 上,结构是Map<Integer, CartItem>,key 是菜品 id,value 里存菜品对象和数量。这样加购、改数量、删除全是内存操作,响应快,也不用担心游客未登录时没法加购。

@WebServlet("/cart/*") public class CartServlet extends HttpServlet { @SuppressWarnings("unchecked") private Map<Integer, CartItem> getCart(HttpSession session) { // 首次访问时初始化,用 synchronized 块兜住并发首次写入 Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { synchronized (this) { cart = (Map<Integer, 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) { Map<Integer, 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, Map<Integer, 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-fileupload:

@WebServlet("/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 10
Tomcat8.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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true

serverTimezone不写会报The server time zone value '...' is unrecognized,useSSL=false是为了消掉启动时的黄色警告,allowPublicKeyRetrieval=true是 8.0 默认加密插件下远程连接需要的。这三个参数少一个都可能连不上,属于典型的一步卡一天。

驱动类名也要跟着换:5.7 是com.mysql.jdbc.Driver,8.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 exploded,Application context 填/order。这个值就是前面反复提到的 context path,页面里所有链接都要带上它。

不用 IDE 的话,手工打包更直观:

# 在项目根目录打包,产物丢进 Tomcat 的 webapps jar -cvf order.war . cp order.war $TOMCAT_HOME/webapps/ # 启动(Windows 用 startup.bat,Linux 用 startup.sh) $TOMCAT_HOME/bin/startup.sh # 实时看日志,报错第一时间能看到 tail -f $TOMCAT_HOME/logs/catalina.out

catalina.out是排障的主要入口。Servlet 初始化失败、数据库连不上、JSP 编译错误,堆栈都在这个文件里。浏览器上只显示一个 500 页面,看不到根因,所以部署完先开着这个 tail。

4.4 验证流程:按角色走一遍完整闭环

跑起来之后别急着看代码,先按用户路径走一遍,确认系统是活的:

  1. 打开http://localhost:8080/order/,确认首页菜品卡片能加载、图片能显示、分类点击能筛选。
  2. 注册一个新用户或使用预置账号登录,加三样菜进购物车,改一次数量,删一样,确认小计金额随数量实时变化。
  3. 进结算页,填收货地址,提交订单,看是否跳转到订单详情,订单号、金额、明细是否对得上。
  4. 退出登录,用预置管理员账号进/admin,在菜品管理里改一个价格,回前台刷新,确认新价格生效。
  5. 回到数据库,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;charset=UTF-8")。连接串加useUnicode=true&characterEncoding=utf8,建库时指定utf8mb4。GET 请求的参数编码由 Tomcat 的Connector决定,8.0 之后默认 UTF-8,7.x 需要在server.xml里加URIEncoding="UTF-8"。

5.2 404 但路径看着没错:注解和 web.xml 打架了

现象:浏览器访问/food/list报 404,控制台没有任何异常。

原因:Servlet 同时用@WebServlet("/food/list")和web.xml里的<servlet-mapping>配了同一个路径,或者web.xml的metadata-complete="true"把注解扫描整个关掉了。也有一种情况是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里用ThreadLocal<Connection>,同一请求内所有 DAO 都从getConnection()取,close()时先remove()再真正关闭。改完在下单方法里打一行conn.getAutoCommit(),确认为false再往下走。

5.4 图片上传成功但刷新就 404:文件写进了部署目录

现象:后台上传图片,当下能预览,重启 Tomcat 或重新部署 war 之后图片全挂。

原因:getServletContext().getRealPath("/upload")指向的是解压后的部署目录,重新部署时整个目录被删掉重建。

解决:把上传目录挪到项目外,比如D:/order-upload/,数据库只存相对路径/upload/xxx.jpg,再通过 Tomcat 的<Context docBase="D:/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,人眼核对库存数字。这套笨办法比写一堆断言管用,因为它逼你把数据真正读出来看一遍。

从那以后我每次给这类老项目加业务字段,都强制先把状态流转表和库存变更点写在一张纸上,再动第一行代码。跳过这步直接改,后面一定会在某个「取消订单」的分支里翻车,而且往往是在演示前十分钟。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进