做了快十年的Java开发,带新人的时候总会遇到一个经典项目叫苍穹外卖。很多自学Java的朋友,从环境配置一路撸到购物车,前面都顺风顺水,结果一走到第8天的提交订单,就开始各种卡壳。这个模块在技术上不算难,但它同时牵扯到事务、多表写入、数据一致性、金额计算这些高频考点,几乎是把之前学的东西一次性串起来了。这篇文章就围绕苍穹外卖day08的提交订单功能,讲清楚整个下单流程的拆解思路、核心代码怎么落地、以及我自己实操中踩过的那些坑。
如果你正在跟着视频做苍穹外卖,或者准备把项目写进简历,想弄明白“提交订单”背后到底发生了什么,这篇文章就是给你准备的。即使你只是Java基础阶段,想了解一个真实的下单接口长什么样,也可以放心往下看,代码和思路我都会尽量拆到最细。
1. 提交订单功能的设计拆解:一个完整交易闭环的最后一跳
1.1 功能在整个项目中的位置与前置依赖
苍穹外卖是一个前后端分离的外卖点餐练习项目,技术栈就是最主流的Spring Boot加MyBatis加MySQL,用户端部分在day08之前已经完成了微信登录、分类浏览、菜品查询、购物车增删改查。到了提交订单这一步,等于用户把菜加进购物车看完之后,终于点下了“去结算”的那个按钮。
这个动作在业务上的分量很重。它是用户从“逛”到“买”的转折点,是交易闭环真正闭合的地方。之前所有购物车操作都是临时数据,丢了无所谓,但订单一旦落库,就变成了必须持久化、必须准确、必须可追溯的业务核心。这也是为什么项目组会把提交订单这块单独安排一天来讲,因为它第一次让学员面对“一次请求,同时写多张表”的完整过程。
前置依赖其实非常清晰:用户必须已经登录(拿到userId),购物车里必须有数据,用户在下单时选中的收货地址必须存在。这些都满足之后,后端要做的就是把购物车数据转换成订单数据,把地址信息快照到订单里,然后清空购物车,最后返回一个包含订单号、金额、下单时间的结果给前端。前端收到这个结果后,再去拉起支付。
1.2 订单主表和明细表为什么非要拆成两张
我第一次带新人做这个项目时,有人问我:“订单就一个表,每道菜存一行,不行吗?”技术上当然能存,但你会立刻发现问题:一个订单里有三道菜,那收货人、电话、地址、订单状态这些信息就要在表里重复出现三次。如果后续要改订单状态,你需要update三行,还得时刻保证这三行数据完全一致,维护成本直线上升。
所以正规设计都会拆成两张表。orders表叫订单主表,一条订单只有一行,记录收货人、电话、地址、订单金额、支付状态、下单时间这些“订单头”信息;order_detail表叫订单明细表,一个订单有多少道菜,这里就有多少行,记录菜名、图片、口味、数量、单价这些“订单项”信息。两张表通过order_id字段关联。
这个设计逻辑用生活类比特别好懂。就像你去网购,快递包裹外面贴的快递单,写的是收件人信息和包裹整体信息,这是orders表;打开包裹里面的物品清单,每件商品各写一行,这是order_detail表。快递员扫码只看快递单就够了,但你到底买了啥,得看清单。
1.3 订单状态机与金额单位的设计取舍
订单表里有两个状态字段特别容易混,一个是status,一个是pay_status。status表示订单本身走到哪一步了,苍穹外卖里定义的是:1待付款、2待接单、3已接单、4派送中、5已完成、6已取消、7退款。pay_status则只关心钱,0未支付、1已支付、2退款。提交订单时,新订单的status直接置为1待付款,pay_status置为0未支付,因为这时候用户还没真正付款。
这里有一个非常关键的细节:订单金额在数据库里是DECIMAL(10,2),但Java实体类里用的是Long,而且单位是分,不是元。为什么这么设计?因为double计算金额会出精度问题,0.1加0.2在很多场景下算出来不是0.3,这在钱上是不能忍的。用double存金额,哪怕只有一分钱的误差,对账的时候都会非常痛苦。BigDecimal精度没问题,但如果说项目里到处都用BigDecimal,写起来又繁琐,还是容易在转换时出幺蛾子。
直接把金额用Long存成整数分,加减乘除都是整数运算,既没有精度问题,也没有转换负担,展示给前端时再除以100转成元。这个习惯如果你在做别的项目,也可以直接沿用,凡是涉及钱的字段,优先考虑用最小单位的整数。
2. 下单核心流程:从请求到落库的完整链路
2.1 DTO、VO、实体类的职责划分
写后端接口之前,要先把三个类理清楚。OrdersSubmitDTO是前端传给后端的下单参数,里面包含:addressBookId(地址簿id)、payMethod(支付方式,微信或支付宝)、remark(备注)、estimatedDeliveryTime(预计送达时间)、packAmount(打包费)、tablewareNumber(餐具数量)、tablewareStatus(餐具数量状态)。这些字段对应的是用户下单页面上填的那些信息。
Orders是实体类,映射orders表,除了表字段之外,它里面还会有一个非表字段orderDetailList,类型是List,用来承载这个订单的明细数据。这个字段在数据库里没有对应列,MyBatis插入时要用动态SQL或者单独处理,不能直接当成普通字段去insert。
OrderSubmitVO是后端返回给前端的下单结果,包含id(订单id)、orderNumber(订单号)、orderAmount(订单金额)、orderTime(下单时间)。前端支付的时候需要用到这些数据。三个类各司其职,DTO管进来,VO管出去,实体管存储,这个分层习惯在真实项目中非常常见,也经常是面试官会问的点。
先看用户端Controller的代码,我加了@RestController并指定bean名称为userOrderController,因为管理端还有一个订单Controller,类名相同会发生冲突,用不同的bean名称可以避免这个问题。
@RestController("userOrderController") @RequestMapping("/user/order") @Api(tags = "C端-订单相关接口") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/submit") @ApiOperation("用户下单") public Result<OrderSubmitVO> submit(@RequestBody OrdersSubmitDTO ordersSubmitDTO) { log.info("用户下单参数:{}", ordersSubmitDTO); OrderSubmitVO orderSubmitVO = orderService.submitOrder(ordersSubmitDTO); return Result.success(orderSubmitVO); } }2.2 Service层五步走的代码实现
提交订单的Service核心方法submitOrder是全天内容的重点。整个方法按顺序做五件事:校验地址和购物车、构造订单主表数据、构造订单明细数据、批量插入订单和明细、清空购物车。
先把代码完整贴出来,然后一行一行拆开讲。
@Override @Transactional public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) { // 1. 地址簿校验 AddressBook addressBook = addressBookMapper.getById(ordersSubmitDTO.getAddressBookId()); if (addressBook == null) { throw new OrderBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL); } // 2. 购物车校验 Long userId = BaseContext.getCurrentId(); ShoppingCart shoppingCart = new ShoppingCart(); shoppingCart.setUserId(userId); List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart); if (shoppingCartList == null || shoppingCartList.isEmpty()) { throw new OrderBusinessException(MessageConstant.SHOPPING_CART_IS_NULL); } // 3. 构造订单主表数据 Orders orders = new Orders(); orders.setNumber(String.valueOf(System.currentTimeMillis())); orders.setStatus(Orders.PENDING_PAYMENT); orders.setUserId(userId); orders.setAddressBookId(ordersSubmitDTO.getAddressBookId()); orders.setOrderTime(LocalDateTime.now()); orders.setPayStatus(Orders.UN_PAID); orders.setPhone(addressBook.getPhone()); orders.setConsignee(addressBook.getConsignee()); String address = addressBook.getProvinceName() + addressBook.getCityName() + addressBook.getDistrictName() + addressBook.getDetail(); orders.setAddress(address); // 4. 构造订单明细数据并计算总金额 Long totalAmount = 0L; List<OrderDetail> orderDetailList = new ArrayList<>(); for (ShoppingCart cart : shoppingCartList) { OrderDetail orderDetail = new OrderDetail(); BeanUtils.copyProperties(cart, orderDetail); orderDetail.setOrderId(orders.getId()); orderDetailList.add(orderDetail); totalAmount += cart.getAmount() * cart.getNumber(); } orders.setAmount(totalAmount); orders.setOrderDetailList(orderDetailList); // 5. 插入订单主表 orderMapper.insert(orders); // 6. 批量插入订单明细 for (OrderDetail orderDetail : orderDetailList) { orderDetail.setOrderId(orders.getId()); orderDetailMapper.insert(orderDetail); } // 7. 清空购物车 shoppingCartMapper.clean(userId); // 8. 封装返回结果 return OrderSubmitVO.builder() .id(orders.getId()) .orderNumber(orders.getNumber()) .orderAmount(orders.getAmount()) .orderTime(orders.getOrderTime()) .build(); }第一步,查地址簿。用前端传过来的addressBookId去address_book表查记录,查不到说明用户下单时选中的地址已经不存在了,直接抛业务异常。这个异常会被全局异常处理器捕获,返给前端一个友好的提示,而不是让用户看到500页面。
第二步,查购物车。这里有个很关键的点,购物车表是按用户维度存储的,所以只要查当前userId下的所有购物车记录即可。如果queryWrapper查出来是空,说明这个用户的购物车里没东西,同样抛异常。
第三步,构造订单主表数据。很多字段都有明确的来源:订单号是当前毫秒级时间戳转字符串,状态码固定为待付款1,用户id从ThreadLocal里拿,支付状态固定为未支付0,手机号和收货人从地址簿快照过来,完整地址由省市区和详细地址拼接而成。这里有一个我的个人习惯:既然地址信息已经从地址簿里面查出来了,就顺手把这些字段塞进orders,后面同一个订单的收货信息就不会再变。
第四步,构造明细并算总额。遍历购物车列表,把购物车里的每个商品复制成OrderDetail对象。这里为什么用BeanUtils.copyProperties?因为购物车和订单明细的字段高度重合,都有name、image、dishId、setmealId、dishFlavor、number、amount,直接拷贝非常省事。要特别注意的是,拷贝完必须二次确认orderId有没有正确回填,这段代码里我先在循环里set了一次orderId,插完主表后又重新set了一次,就是为了避免主键回填时机导致的值丢失。金额累加这里用的是Long单位分,所以乘法结果不会出现小数精度问题。
第五步和第六步,插主表、插明细。先insert orders主表,触发MyBatis的主键回填,让orders.getId()变成真正自增出来的id,然后遍历明细列表逐条插入,并再次把orderIdset到每条明细上。这里虽然看起来是循环单条插入,效率不是最优,但在练习项目里完全够用,重点是把业务逻辑跑通。
最后一步,清空购物车。shoppingCartMapper.clean(userId)删除当前用户的全部购物车记录,返回一个包含订单id、订单号、订单金额、下单时间的VO。
2.3 菜品快照机制:为什么订单里的菜不许变
写代码的时候有个很容易忽略的细节:订单明细表里面的name、image、amount是从购物车复制过来的,而不是下单的时候再去菜品表关联查询。这个操作专业上叫快照,说白了就是把下单那一刻的菜名、图片、单价原封不动存进订单明细里。
为什么必须这么做?设想一个场景:用户下单时宫保鸡丁卖38元,第二天商家把价格改成了42元。如果订单明细只存了dish_id,用户查看历史订单时就会显示出42元,甚至菜品下架后图片和菜名都变成空的,这对用户来说完全无法接受。财务对账时也会出乱子,每笔订单的实际成交金额跟订单明细里展示的金额对不上,那就麻烦大了。
存快照的本质,是把“商品当前信息”和“交易时确认的信息”隔离开。用户支付时看到的是38元,那这笔交易的法律凭证就是38元,商家之后怎么改价格,都不应该影响历史订单。这个思维在电商、外卖、票务系统里都是通用的。你可以把快照理解成一份合同的影印件,合同签完字,后面内容再修改,影印件依然是签合同时的样子。
3. 事务与数据一致性:下单不能只靠写代码
3.1 @Transactional 是怎么保证原子性的
上面那五步操作涉及三张表的写入:address_book表是查询,shopping_cart表要删数据,orders表要插主数据,order_detail表要插明细数据。一旦中间任何一步出错,比如明细插入失败,但购物车已经被清空了,用户就会发现购物车里的菜没了,订单也没生成,这种状态谁遇到都会崩溃。
所以我要求所有看过这篇文章的人记住一句话:凡是涉及多张表写操作的业务方法,必须加上事务。代码里的@Transactional注解就是干这件事的。
Spring的声明式事务本质上是通过AOP代理实现的。入口方法被调用时,Spring会从连接池拿一个数据库连接,把连接的自动提交模式调成关闭,然后执行业务方法。方法正常结束就执行commit;方法抛出RuntimeException,就执行rollback,把这一步的所有数据库操作全部撤销。整个过程,对外表现为“要么全部成功,要么全部失败”,不存在中间状态。
配合前面的代码来看,如果第6步明细插入抛了异常,第7步清空购物车的操作根本就不会执行;而即使第7步已经执行了,事务回滚也会把清空购物车这个delete操作一起撤销掉。购物车数据还在,用户重新提交一次就行。
3.2 事务没生效的几个坑位
加了@Transactional就万事大吉了吗?我见过太多新人在这个上面翻车,最常见的有四种情况。
第一种,方法内部自调用。同一个类里,A方法调B方法,而B方法上有@Transactional,B的事务不会生效。因为Spring事务是靠代理实现的,外部调用会走代理,但this调this是直接走原生对象,代理根本没机会拦截。解决办法很简单,把两个方法拆到不同的Service类里,或者通过ApplicationContext手动拿代理对象调用。
第二种,异常被吞掉。方法里写了try-catch,把RuntimeException捕获之后没往外抛,甚至打印了个日志就结束了。这种时候Spring根本感知不到异常,它看到的是方法正常返回,自然就commit了。事务方法里的异常处理一定要谨慎,要捕获后重新抛出去,或者直接不捕获。
第三种,方法不是public。@Transactional注解标注在private方法上,代理无法生效。虽然Spring Boot 2.x之后对非public方法会给出警告日志,但很多人根本没注意看日志。
第四种,数据库表引擎不支持事务。MySQL的MyISAM引擎是不支持事务的,只有InnoDB支持。如果你在建表时用了默认的MyISAM,那加再多注解都没用。练习项目中表一般不会出这种问题,但如果你在复习原理时被面试官问到,要能说出这一点。
我一般检验事务有没有生效,会在润detailMapper.insert的地方故意抛一个RuntimeException,然后运行整个下单流程,看看购物车是否被清空。如果事务生效,购物车应该保持原样;如果购物车被清空了,那就说明事务根本没起作用,该去查代理或者异常处理了。
3.3 订单号生成:从时间戳到分布式ID
苍穹外卖这个项目里,订单号直接用了System.currentTimeMillis(),也就是当前毫秒时间戳转成字符串。这种做法在练习项目里完全没有问题,因为单服务器、单线程,并发量几乎可以忽略不计。但如果你将来要把项目优化成简历亮点,这个点是可以拿出来展开讲的。
真实生产环境里,订单号的要求比这严格得多。首先不能暴露订单量,用数据库自增ID当订单号,竞争对手一看订单号连续增长就能估算出你的日单量,这是商业机密。其次要保证全局唯一,在分布式多服务部署的情况下,两个服务实例在同一毫秒内生成的订单号可能一样,直接造成主键冲突。
业内最常用的方案是雪花算法。雪花ID是一个64位的Long型数字,其中1位是符号位,41位是毫秒时间戳,10位是机器ID,12位是序列号。同一毫秒内可以生成4096个不重复的ID,并且趋势递增,几乎完美满足了订单号的生成需求。很多中间件比如MyBatis-Plus的ASSIGN_ID雪花策略、美团的Leaf,都是这个思路的实践。
我带的学员里,不少人会在简历里写“项目使用雪花算法生成订单号”,但真被问到雪花算法由哪几部分组成时,又支支吾吾说不清楚。既然今天聊到了,建议你把这一小节多看两遍,将来面试时这就是加分项。
4. 提交订单踩坑实录与排查速查表
4.1 下单后购物车没清空
这是我被问得最多的一个问题。明明代码里写了shoppingCartMapper.clean(userId),下单成功了,购物车里的商品却还在。
遇到这种情况,第一反应应该去查SQL日志。看看clean方法实际执行的SQL是什么,userId传的是不是当前登录用户的id。BaseContext.getCurrentId()拿的是ThreadLocal里存的用户id,这个值是在JWT拦截器里面手动放进去的。如果你在拦截器里没放,或者放错了字段,那拿到的userId就可能是null,导致delete条件不匹配,一条数据都删不掉。
还有一种可能,你没加@Transactional,而且实际上清空购物车的代码是在插入订单之后执行的,如果前面的插入操作抛了异常,清空代码确实执行不到。但事务问题在上面已经讲过了,这里就不重复了。
4.2 主键回填失效
订单详情表里的order_id老是插不进去,或者插进去是null,根源几乎都是订单主表插入后没有回填主键。
MyBatis框架里要在insert语句上配置useGeneratedKeys="true"和keyProperty="id",Mapper接口的插入方法才能真正把数据库自增的id赋回实体对象。如果你用的是注解式SQL,类似@Options(useGeneratedKeys = true, keyProperty = "id")也只能用在@Insert上。一旦漏配,orders.getId()返回的就是null,后面set到orderDetail里的orderId自然也是null。
排查方法非常简单,在orderMapper.insert(orders)这一行下面加一条log.info("生成的订单id:{}", orders.getId()),如果打印出来是null,那就说明主键回填没生效。别小看这个细节,我见过不少项目已经上线了,这个bug还埋在代码里,只是因为测试数据碰巧没触发。
4.3 金额多一分少一分
下单金额不对,如果数据是从前端直接传过来的,那你就要警惕前端单位转换的问题。前端页面上显示的金额单位是元,比如38.5元,但是后端如果按Long去接,前端传过来的JSON数字会在反序列化时报错,或者被截断成38。
正确的做法是:前端传金额时统一用分,或者后端在接收到元之后自己乘100转成分,入库和计算都用分。千万不要在代码里同时出现“元”和“分”两种单位的字段,时间一长,连自己都容易记混。
我之前带过一个小伙伴,他写了一个工具类MoneyUtil,里面提供yuanToFen和fenToYuan两个静态方法,所有金额转换都必须走这两个方法,不允许在业务代码里手动乘100除100。这个习惯很好,建议你也给自己的代码立一个类似的规矩。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 地址为空 | addressBookId传错或地址被删 | 打印DTO参数,直接查数据库确认 | 前端回显地址时带上id,后端校验后抛出友好异常 |
| 购物车为空 | userId不存在或购物车表无记录 | 查shopping_cart表,看当前userId有没有数据 | 检查Token解析逻辑和ThreadLocal存放时机 |
| 订单明细order_id为null | 主键回填配置缺失 | insert后日志打印orders.getId() | Mapper上配置useGeneratedKeys和keyProperty |
| 下单后购物车还在 | clean SQL条件不匹配或事务未生效 | 开启MyBatis SQL日志 | 确认userId取值,确认@Transactional生效 |
| 金额对不上 | 元分单位混乱,或前端传过来的就是错的 | 打印订单金额和明细金额 | 统一用Long存分,写工具类转换 |
| 事务不生效 | 自调用、异常被吞、非public方法 | 在事务方法中故意抛异常验证 | 拆Service、异常重抛、方法设为public |
我还想多提醒一句,做这个模块的时候,最好把MyBatis的日志级别调成debug,这样每个SQL语句和执行参数都能看得清清楚楚。很多你以为的“玄学bug”,在看到SQL日志的那一刻就真相大白了。
5. 个人实操建议与后续扩展方向
5.1 上手练习时的几个小建议
如果你现在是在跟着视频敲代码,我建议你提交订单这一节不要只看不敲。你可以在自己电脑上把controllerservice、mapper三层全部手写一遍,写完再和源码对比,哪怕只是字段名不一样也要弄明白是为什么。
练习时强烈建议做一次破坏性测试:把购物车清空的过程注释掉,或者故意让明细插入失败,然后重新跑完整流程,看看会出现什么样的脏数据。这个过程会让你对事务的理解比看十遍文档都深刻。
项目里有一个名为BaseContext的ThreadLocal工具类,专门用来保存当前登录用户id。你的代码里所有需要知道“是谁在操作”的地方,都要通过它拿用户id。理解它的作用机制,比背代码重要得多,因为所有多用户系统的权限和操作归属都离不开它。
5.2 提交订单之后还能做什么
写完提交订单,苍穹外卖这个项目也就到中后段了。但如果想把它变得更有竞争力,我推荐你研究几个扩展点。
第一个是超时未支付自动取消订单。用户下单后如果一直不付款,订单不能永远挂着。实现思路是延时队列或者定时任务,把超过15分钟没支付的订单状态改成已取消。这个逻辑在真实外卖系统里是标配。
第二个是商品库存扣减。外卖系统里商家会有每日备货量,下单时扣减库存,支付失败或超时取消要回补库存。扣库存时要注意并发问题,SQL里要加上库存数大于零的条件去更新,受影响行数为零就说明库存不足。
第三个是订单状态的推送和查询。用户下单后要知道订单什么时候被商家接单、什么时候开始配送,这些状态变化可以通过WebSocket或轮询接口实现。写一个订单状态查询接口,前端每隔几秒请求一次,是很多练习项目里常见但也很考验基本功的功能。
我在实际操作中最深的体会是,提交订单这个功能虽然代码量不大,但它像一根线,把之前所有学过的Java知识串到了一起。你能把它真正讲清楚,Spring的事务机制、MyBatis的主键回填、ThreadLocal的用户上下文、BigDecimal与Long的金额设计这些面试高频话题,就都有了落地经验。项目从来不怕简单,怕的是做完之后一知半解。认真把这个模块吃透,你的Java学习之路会走得比大多数人都稳。