时间过得好快,Day07结束后把地址簿和购物车收尾,我以为苍穹外卖这个项目已经没多少硬骨头了。结果Day08一上来就给我上了一课:下单和支付这条链路,才是整个系统从“能逛”变成“能交易”的关键一跳,代码量未必大,但事务、幂等、并发、精度这些工程问题全挤在这一天里了。这篇笔记把当天下单模块、支付回调、超时未支付的处理思路和排查过程都记录下来,给正在学苍穹外卖或者想快速复盘外卖订单支付链路的同学做个参考。
1. 下单前的堤坝:购物车、地址簿与菜品状态的联动校验
下单接口看似简单,前端提交一个地址簿ID和备注,后端就把订单建好了。但真正动手写的时候会发现,这个接口背后要挡住的可不止是“参数没传”这种低级问题。
1.1 前端到底传了什么:DTO里只有addressBookId和remark
先看前端提交的数据结构,OrdersSubmitDTO基本就两个字段:addressBookId和remark。
你可能好奇,为什么购物车ID列表不一起传过来?因为下单的本质是“把当前登录用户购物车里的东西固化成订单”。用户身份从JWT令牌里解析出来,后端通过ThreadLocal里的userId能直接查到购物车数据,完全不需要前端再传一遍商品ID,这样反而避免用户伪造请求体、强行下单不属于自己的商品。
整个DTO设计遵循一个原则:前端只传“服务端无法自行获取”的信息。地址簿ID是用户这次下单选择的收货地址,备注是用户的附加说明,其他东西比如商品明细、价格、数量,后端必须从数据库里现查。这也是为什么下单接口的入参校验非常简单,真正的校验逻辑都在Service层。
1.2 服务端校验的三板斧:地址、购物车、在售状态
下单Service的第一步永远不是“插入订单”,而是先做一轮完整的前置校验。我总结为三板斧:
-
地址簿校验:根据addressBookId查地址,查不到直接抛异常。这里有个隐蔽问题:后期如果用MyBatis-Plus的逻辑删除,SQL会自动追加
is_deleted = 0条件,用户拿一个已经删除的地址ID去下单,查询结果就是null。所以校验不能只判断“地址存在”,还要考虑逻辑删除的干扰,后面排错部分会专门讲这个坑。 -
购物车校验:查当前用户购物车列表,如果为空,说明用户什么东西都没加就点了结算。虽然正常前端不会出现这种情况,但直接调接口的人可不管这些,必须兜住。
-
菜品/套餐在售状态校验:购物车里的商品不能直接信,要反查菜品表或套餐表的status字段。用户可能一周前把某个菜品加入购物车,这菜已经下架了,你让用户一个超时订单下单成功,后面商家根本没法接单,纯纯制造矛盾。
这三板斧顺序也有讲究:先地址、再购物车、最后状态,成本从低到高排。查地址最快,购物车次之,菜品表可能涉及多表联查,放最后。反正一旦有校验不通过,直接抛业务异常,事务不开启,数据库连写操作都不会有。
1.3 购物车里的金额不能直接信:取最新菜品价格而不是快照
购物车表里有一个amount字段,记录的是商品加入购物车时的价格。最初我的实现是直接累加购物车里的amount,结果被一顿批评:用户在购物车里放了三天的菜,商家中途调了价,下单时如果还按旧价格结算,要么商家亏,要么用户觉得被坑。
正确做法是下单时重新从菜品表或套餐表读取当前最新价格,重新计算每项明细的金额和订单总价。购物车里的amount只适合当作购物车页面的展示价,不能作为订单的最终结算依据。
实现时还是有一点性能讲究的:如果购物车里有20种菜品,循环里每查一次菜品表就是20次查询,虽然数据量小没问题,但更好的做法是先collect菜品的ID列表,一次性 in 查询出来,转成Map按ID索引,再遍历购物车去取价格,把N次查询优化成1次。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 订单落库的事务编排:从订单主表到明细表的完整写入链路
校验通过后,核心写库流程开始了。这一步看起来就是“插主表、插明细、清购物车”,但为什么顺序必须固定、事务边界画在哪里、哪些细节容易被忽略,实操下来会发现门道不少。
2.1 订单主表与明细表的字段设计复盘
先回看下单涉及的两张表。订单主表orders存储订单整体信息,包含下单用户、订单状态、金额、收货快照、支付方式等;订单明细表order_detail存储订单里的每一项商品,一个订单对应多个明细。
| 表 | 核心字段 | 说明 |
|---|---|---|
| orders | id、number、status、amount、address_book_id、phone、address、consignee、remark、order_time、checkout_time | number是订单号对外可见,手机号/地址/收货人是下单时刻的冗余快照 |
| order_detail | id、order_id、dish_id、setmeal_id、name、image、dish_flavor、number、amount | name/image也是冗余快照,防止菜品改名或图片替换后订单展示错乱 |
这里重点理解一个设计理念:快照。地址簿里的地址、菜品表里的菜名和图片,都是“可变数据”,用户随时可能改地址、商家随时可能改菜名。但订单一旦生成,它当时的收货信息、商品名称就必须被固化成一份独立的快照保存在订单表里,不能通过关联去实时查询。否则一年前的历史订单,商品名可能早就变成了另一个菜。
订单主表的冗余字段越全,订单详情页就越稳定,这也是为什么order_detail在设计时连dish_flavor(口味)都单独存一份的原因。用户下单时选了什么口味,订单里就要永久记住,菜品表后续怎么改都影响不到历史订单。
2.2 Service方法内的七步操作与事务边界
核心Service方法建议这样设计:
java复制@Transactional(rollbackFor = Exception.class)
public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {
// 1. 校验地址簿,取出地址信息
AddressBook addressBook = addressBookService.getById(ordersSubmitDTO.getAddressBookId());
if (addressBook == null) {
throw new BusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL);
}
// 2. 查询当前用户购物车(需要从ThreadLocal里拿到userId)
List<ShoppingCart> shoppingCartList = shoppingCartService.list(userId);
if (shoppingCartList == null || shoppingCartList.isEmpty()) {
throw new BusinessException(MessageConstant.SHOPPING_CART_IS_NULL);
}
// 3. 遍历购物车,组装订单明细,并累加总金额
// 注意:这里取菜品最新价格,而不是购物车里的amount
// 4. 构造订单主表实体,设置状态为待付款,生成订单号,插入orders表
// 5. 批量插入订单明细
// 6. 清空当前用户的购物车
// 7. 封装并返回OrderSubmitVO(订单号、总金额、下单时间给前端)
}
@Transactional注解必须加上rollbackFor = Exception.class,这是第一个容易踩的坑。默认情况下Spring事务只对RuntimeException回滚,如果代码里抛的是自定义异常且没继承RuntimeException,事务可能不会回滚。外卖项目的自定义BusinessException通常继承RuntimeException,所以一般没事,但养成显式声明rollbackFor的习惯,可以避免以后换异常体系时埋雷。
整个流程的每一步都依赖前一步成功。如果插入主表成功、插入明细失败,事务必须回滚让主表记录也不存在;如果明细都插好了、清空购物车失败,那用户下次再进购物车页面发现东西还在,再点一次下单就直接重复提交了,这属于最典型的脏数据场景。
2.3 为什么清空购物车要放在最后一步
这个问题如果只看结果,可能觉得清空放哪都一样,反正有事务兜底。但从业务语义上讲,清空购物车是“下单成功后的清理动作”,而不是“下单过程中的必要步骤”。放在最后写,代码读起来更符合真实流程:订单生成、明细固化、购物车完成历史使命。
另外有个隐藏好处:如果未来扩展“再来一单”功能,用户可能需要查自己的历史购物车内容。如果购物车在下单一开始就被清了,数据恢复就没有依据了——实际上我们还能通过order_detail反查当时的商品,但业务上把购物车数据和订单数据解耦,各自职责更清晰。
还有一点值得留意:接口的防重复提交。用户手抖连点两次“提交订单”,前端如果没把按钮置灰,后端会在极短时间内收到两次相同请求,于是生成两笔一模一样的订单。稍微稳妥一点的做法是前端提交后立即给按钮加loading状态并禁用,后端如果要兜底,可以在下单入口处根据userId+购物车内容做短时间内的幂等校验,或者用Redis setnx实现一个简单锁。课堂项目通常不做这一步,但面试时会被经常问到,建议至少理解这个问题的存在。
3. 订单号、金额精度与状态流转:必须一次做对的底层细节
如果说上一步是下订单的“大框架”,这一节就是框架里的“螺丝钉”。订单号怎么生成、金额用什么类型计算、订单状态怎么流转,每个点单独看都不难,但组合在一起就构成了下单模块的技术底色。这几个点如果没做对,后面测试阶段会一个接一个爆雷。
3.1 订单号生成:时间戳+随机数在单体应用里为什么够用
外卖项目的订单号生成策略比较务实:用当前时间戳拼接几位随机数,转成字符串作为订单号。
java复制String orderNumber = System.currentTimeMillis() + RandomUtil.randomNumbers(4);
有同学问,为什么不用UUID。UUID虽然能保证唯一,但作为订单号实在太丑了——32位无规律的字符串,用户报客服查单号的时候根本没法念,客服也没法只凭肉眼输入。而 19位时间戳+4位随机数 的格式,位数短,肉眼可读,按时间还有序。
又有人问,为什么不上雪花算法。雪花算法适合分布式场景,要维护workerId,要引入额外工具类,而当前项目就是单机单体应用,完全没必要为了一个订单号引入一套分布式ID方案。遇到超高并发且同一个毫秒内随机数碰撞的场景再说,当前阶段“时间戳+随机数”完全够用。当然如果订单号要基于Redis INCR生成严格递增的序列号,也是一种更严谨的升级方案。
这里还有个小细节:在多线程并发下,极端情况可能出现同一毫秒内两位用户得到相同随机数从而生成相同订单号。虽然概率很低,但为了保险,可以给订单号字段建立唯一索引,真撞了直接报异常,让上层重试,比在代码里反复校验更可靠。
3.2 金额计算只用BigDecimal:Double算钱迟早出事
做后端开发的应该都听过一个经典例子:0.1 + 0.2 用double算出来是 0.30000000000000004。对金额计算来说,这种精度问题不是“可以接受的误差”,而是直接导致账面不平的事故。所以下单模块里的所有金额计算,必须统一使用BigDecimal,禁止用double或float参与运算。
实际编码时有几点约定要统一:
- 金额的数据库字段用decimal,实体属性用BigDecimal。
- 累加金额时,起始值用 BigDecimal.ZERO。
- 除法运算时务必指定小数位和舍入模式,例如
amount.divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP),否则除不尽时会抛ArithmeticException。 - 和微信支付交互时,注意单位差异:数据库存的是“元”,微信支付的单位是“分”,转换时用
amount.multiply(new BigDecimal("100")).intValue(),这个转换也是新手高频踩坑点,漏乘100微信直接报“订单金额不合法”。
BigDecimal的代码量确实比double啰嗦,但这是金融安全的底线,宁可多写几行也别给自己埋雷。
3.3 订单状态字段的设计:状态值与流转方向
外卖项目的订单状态一般定义为一组int常量:
| 状态值 | 含义 | 说明 |
|---|---|---|
| 1 | 待付款 | 用户已下单,尚未支付 |
| 2 | 待接单 | 已支付,等待商家接单 |
| 3 | 已接单 | 商家已接单 |
| 4 | 派送中 | 骑手配送中 |
| 5 | 已完成 | 订单完成 |
| 6 | 已取消 | 用户取消或超时未支付系统取消 |
这组状态不能随手改,因为很多后续功能都依赖状态值:商家端工作台统计“待接单”数量,用户端查询“进行中订单”,都是基于状态字段过滤。如果状态值定义不统一,不同接口各搞一套魔法值,后面连联动调试都费劲。
状态流转也有限制:待付款只能流转到“待接单”(支付成功)或者“已取消”(用户取消/超时)。已支付后的订单不能随意再变回待付款,否则对账就乱了。更严谨的做法是更新时带上状态条件,update orders set status = 2 where id = ? and status = 1,用数据库保证状态只能单向跳转,这一手段在支付回调那一步尤其重要。
4. 支付接入与回调幂等:Day08真正的硬骨头
下单做完,订单状态还挂在“待付款”。要让整个交易闭环转起来,必须接入支付。支付这块是我当天耗时最长、踩坑最多的部分,核心集中在微信支付调起的完整链路和回调接口的幂等处理上。
4.1 微信支付调用链:从prepay_id到前端调起支付
微信支付的完整链路大概是这样的:
- 用户点击“去支付”,后端接收订单号,查询订单,校验订单属于当前用户且状态为待付款。
- 后端组装参数调用微信支付统一下单API,参数包括appid、mchid、out_trade_no(用我们的订单号)、amount.total(订单金额)、payer.openid(用户在小程序端的openid)、notify_url(微信支付结果回调地址)。
- 微信支付返回prepay_id,后端基于prepay_id生成前端调起支付所需的签名参数。
- 后端把签名参数返回给前端,前端调用小程序端的wx.requestPayment,弹出支付框。
- 用户输入密码完成支付,微信支付服务器异步调用notify_url,把订单支付结果推送给后端。
- 后端处理回调,验证签名无误、金额一致后,把订单状态从待付款改成待接单。
- 后端返回“success”给微信支付,微信支付停止重试回调。
这里最难理解的是“后端生成签名参数”这一步。小程序端发起支付并不直接使用prepay_id,而是需要appId、timeStamp、nonceStr、package(值为 prepay_id=xxx)、signType、paySign这六个字段。paySign是后端用商户API密钥对前面的参数做签名计算得来的。前端拿不到API密钥,所以必须由后端统一生成再下发。
本地开发如果没有真正的小程序环境,完全跑不通完整流程,比较务实的联调办法就是下一节的模拟支付方案。
4.2 回调接口为什么必须做验签和幂等
支付回调是整个支付流程中最容易被忽略又最致命的环节。微信支付服务器在用户支付成功后,会主动调用我们配置的notify_url,把支付结果以XML或JSON格式POST过来。这个接口有几个特殊性:
- 它来自外网,任何人都可能伪造一个“支付成功”的请求打过来,如果不验证签名,等于给别人留了一个“免费下单”的后门。
- 微信服务器可能因为网络原因重复推送多次回调,如果不做幂等,同一笔订单会被反复处理。
- 回调处理里更新数据库和返回“success”是两个动作,如果数据库更新成功但返回“success”时网络断了,微信会继续重试,下一次回调就得能识别“这个订单已经处理过”,不能重复改状态。
所以回调接口至少要保证三件事:
- 验签:校验请求头里的时间戳、随机串、签名,确认这个回调确实是微信支付服务器发出的,而不是伪装请求。实现时一般要用到微信支付平台证书或公钥做验签。
- 金额比对:根据out_trade_no查出本地订单,把回调里返回的支付金额和订单实际金额做比对,防止支付金额与订单金额不一致。这能挡住“支付一分钱把订单改成已支付”的漏洞。
- 状态幂等更新:更新订单状态时加上
where status = 1,只有待付款的订单才能被改成已支付。如果影响行数为0,说明订单已经被处理过了,直接返回success,不再重复处理。
之所以强调幂等,是因为改成已支付操作本身是不可重入的。如果回调来了两次,第一次已经把订单状态改成2,第二次再执行没有状态条件的update,就会覆盖一些可能已经发生的后续变化(比如这把状态改成已取消),引发严重的数据错乱。
4.3 本地没有商户号怎么联调:模拟支付网关的替代方案
不少同学学到Day08时卡在支付这里,因为注册微信支付商户号需要营业执照,个人开发者很难马上拿到。这时候可以做一个本地的模拟支付模块,不真的去调微信接口,而是模拟“支付成功”进行链路自测。
模拟支付的思路很直接:写一个测试接口,比如 /mock/pay/success/{orderNumber},调用后直接修改订单状态为已支付,并记录支付时间。更接近真实场景的做法是:在模拟接口里拼一个和微信支付结构类似的通知报文,直接往自己的回调接口url发一个POST请求,这样回调接口的验签、幂等逻辑也能一并测试到。
自己联调阶段这样做效率非常高,能完整验证“下单→支付→订单状态变更→商家端可见新订单”的主链路。但务必记得,模拟支付入口必须在生产环境彻底关闭,否则被人发现可以直接刷单。实际操作中,可以把它放到一个专门的测试Controller,用profile或配置开关控制启用。
4.4 订单超时未支付:定时任务、延迟队列与Redis过期监听的取舍
订单生成后一直不支付,不能永远挂着占用状态。实务上一般会做超时关闭。几种常见方案在这里做个对比:
| 方案 | 实现思路 | 优点 | 缺点 |
|---|---|---|---|
| Spring Task定时扫描 | 每隔1分钟扫描status=1且下单时间超过15分钟的订单,批量取消 | 实现简单,不引入额外中间件 | 存在分钟级延迟,扫描量会随订单量增大 |
| 延迟队列 | 下单后放入延迟队列,到期消费时查询是否已支付 | 实时性好,削峰效果好 | 需要引入MQ中间件,代码更复杂 |
| Redis过期监听 | 下单时给key设置过期时间,key过期时触发监听取消订单 | 结构简单 | Redis过期事件不保证可靠送达,可能丢消息 |
对苍穹外卖这个课堂项目来说,Spring Task定时任务是成本最低、最容易讲清楚的选择。Day08当天我也先按定时任务来实现,找出“status=1且下单时间提前于当前时间15分钟”的订单,批量修改为已取消。这里数据库索引别忽略,“(status, order_time)”联合索引能让扫描快很多。
如果以后项目规模更大了,当然值得切换到延迟队列方案,但那是后话。现阶段最重要的是把超时取消的定时任务跑起来,别让一堆僵尸订单堆积在待付款状态,影响商家端和用户端的订单列表。
5. 排错实录:从“下单报错”到“支付成功但订单未改”的完整排查链路
Day08一天下来,前后遇到了四类问题。每一个都很有代表性,我把当时的排查过程和原因写在这里,遇到类似问题的同学可以直接对号入座。
5.1 地址簿ID报错但数据库里明明有记录:逻辑删除被自动过滤
现象:前端结算后提示“地址信息不存在”,但打开数据库看address_book表,这条地址明明还在。
排查过程:
- 先确认请求参数,addressBookId传的是 22,数据库里id=22的记录存在。
- 打开SQL日志,发现执行查询时自动拼接了
AND is_deleted = 0。 - 再看这条记录的is_deleted字段,值是1。原来是用户之前删过这个地址,但因为某些原因前端又把这个ID传上来了。
根因是MyBatis-Plus的逻辑删除机制:一旦实体类上加了@TableLogic注解,所有查询都会自动带上 is_deleted = 0 条件。从业务角度看这个行为没问题,但问题在于前端可能在旧缓存中保留了已删除地址的ID。
解决办法是接口层面兜底:地址查询不到时给出明确提示“请重新选择地址”,并引导用户回到地址簿,而不是直接把null地址拿来拼SQL,否则会在下一步插入订单时产生数据库外键或空字段的报错。
5.2 订单ID传到前端后面几位全变0:Long精度丢失
现象:下单成功返回后,前端跳转订单详情页,发现URL里的订单ID变成了 2003580168457700000 这种以一堆0结尾的数字,实际数据库ID应该是 2003580168457700352。
根因是JavaScript的Number类型安全整数范围是 2^53 - 1,超过这个范围就会丢精度。MyBatis-Plus默认的雪花算法生成的ID是19位Long类型,恰好超过JS的安全范围,导致前几位对、后几位变成0。
解决办法:后端返回值里的订单ID、订单号(如果也是Long)统一转成String序列化。最方便的做法是在VO实体字段上加 @JsonSerialize(using = ToStringSerializer.class),或者直接把某些ID字段类型定义成String。前端拿到的订单ID必须能原样回传,否则后续查询订单详情时,主键都对不上,直接查无此单。
这个问题我建议所有使用雪花ID的项目在第一天就提前预防。不要等到前端联调时才发现,那会儿所有页面都要跟着返工。
5.3 支付回调来了两次,订单状态被覆盖
现象:微信或模拟支付回调被触发两次,第一次订单状态正确变成“已支付”,第二次又把订单改为其他错误状态,或者抛异常导致回调一直重试。
排查过程:
- 在回调接口入口处打印请求日志,确认同一订单的回调确实进来了两次。
- 查看更新SQL,发现之前直接
update orders set status = 2 where id = ?,没有任何状态条件。 - 第二次回调时订单已经是2,又一次更新成2看似无害,但如果在超时任务和回调并发时,可能把已取消的订单重新改回已支付,状态彻底失控。
修复方式就是前面强调的幂等更新:update orders set status = 2, checkout_time = ? where id = ? and status = 1。影响行数为0时,说明订单当前状态不允许更新,直接返回success,回调流程结束。这里还有个关键点:订单插入时默认status为1(待付款),所以用status=1作为更新条件既能保证幂等,也和业务语义吻合。
5.4 下单中间出错,数据半截落库:事务为什么没回滚
现象:模拟在批量插入订单明细时中途抛异常,发现订单主表里多了一条记录,而明细表是空的。
这个问题的排查方向基本可以锁定在事务失效上,从三个方向逐一排查:
- 检查@Transactional注解是否加在了private方法上。Spring的事务是基于AOP代理实现的,只有通过代理调用public方法,切面才能拦截到。private方法根本没资格被代理,注解形同虚设。
- 检查是否有同类内部方法调用。Service里一个方法使用了this.submitOrder()调用另一个方法,这个调用发生在目标对象内部而非代理对象上,事务通知拦截不到。需要把被调方法拆到另一个Bean里,或者通过注入自身代理对象来调用。
- 检查异常是否被catch住了。代码如下最典型:
java复制try {
orderMapper.insert(order);
orderDetailMapper.batchInsert(details);
} catch (Exception e) {
log.error("下单失败", e);
// 空catch,没有把异常抛出去
}
异常被吞掉后,事务管理器感知不到任何异常,自然也不会回滚。正确的做法是catch里记录日志后重新throw,或者干脆不catch,让异常向上抛给全局异常处理器。
Day08整天的调试让我有个很深的体会:下单支付这个模块,表面上考的是CRUD,实际考的是工程意识——事务边界怎么划、状态流转怎么约束、外部回调怎么防伪、金额精度怎么保证。这些能力靠看视频是看不出来的,必须自己在代码里踩一遍坑、改一遍错,才能真正记住。如果你正在学这个项目,强烈建议把Day08的代码自己重写一遍,不要直接复制粘贴,尤其是事务和幂等这两块,写错一次印象比看十遍都深。
