
订单金额算对了,交易就安全吗?这次问题给出的答案是:还不够。
事故现象是,算价时已经抵扣优惠券,实际提单却没有使用优惠券。订单享受了优惠,券却没有被消费,因此具备反复利用、持续造成资损的条件。
1. 两条路径没有对齐
假设商品 100 元,一张券抵扣 20 元,应付 80 元。算价路径返回 80 元,提单请求也提交 80 元。单看金额,完全一致。
但提单请求没有携带优惠券,后续领用券路径因此被跳过。系统实际形成的组合是"享受 20 元优惠,但没有消费这张券"。

这里的关键是两个不同的问题:
- 金额校验:本次应该付多少钱?
- 资产一致性校验:支撑这个价格的优惠券,是否进入实际使用流程?
重新算价可以帮助回答第一个问题,却不能自动证明第二个问题已经成立。
2. 根因藏在一个提前返回里
历史实现的关键逻辑可简化为以下伪代码:
java
if (!couponFeatureEnabled) {
return;
}
if (requestCouponIds.isEmpty() && requestSelectedCoupons.isEmpty()) {
return;
}
// 旧比较仅在算价旧字段与请求旧字段都非空时执行
if (!calculatedUsedCouponIds.isEmpty() && !requestCouponIds.isEmpty()) {
checkSameCoupons();
}
batchValidateAndUseCoupon();
当请求不带券时,逻辑已经返回,根本没有机会检查算价结果是否用了券。
此外,旧比较依赖双方旧字段都非空,不能覆盖"一边为空、另一边非空"的情况,也没有统一处理新参数 selectedCoupons。问题的本质是:用请求是否带券决定是否验证优惠券,而请求本身恰恰是需要被验证的一方。
3. 一致性检查应当在哪里执行
在本次复盘所对应的代码版本里,普通提单主链路如下。图式省略了与本问题无直接关系的业务步骤:
text
提单 Controller
→ OrderSubmitServiceImpl.submit
→ validateOrder:服务端重新算价,结果放入 OrderTxContext
→ validateAssets → validateCoupon
→ 比较请求券与算价券
→ batchValidCoupon:远程领用券调用
→ 库存预扣、其他资产处理
→ assembleUserCouponId:把领用返回的个人券 ID 回填券快照
→ doPrePay:构造订单,发送创建订单事务消息
→ 本地事务监听器 → OrderTxService.createOrder
→ 保存订单、店铺单、交易单、事务日志并调用预支付
CustCalculatePriceDTO.selectedCoupons 来自价格结果中实际使用的券。请求侧兼容 selectedCoupons 和旧版 couponIds;这两边才是本次要对齐的数据。
前序修复把一致性检查挪到了空券返回之前:
java
// 示意代码,省略具体 DTO 与异常构造
if (!isCouponSelectionMatched(request, calculatedPrice)) {
rejectAndRefreshSkuSpec();
}
if (noCouponsInRequest(request)) {
return;
}
validateAndUseRequestedCoupons();
这样,算价有券而请求没券时,两个集合不相等,提单在领用券和预扣库存之前被拒绝;双方都没券时,才允许跳过领用流程。

实现复用了现有异常类型和刷新规格指令,没有静默替用户选券,也没有引入新的交易编排。
4. 为什么随后又改成只比较 couponId
这次修复过程有两个不同的改动,不能合并成"某一次提交新增了全部校验"。
- 第一处改动:新增券集合一致性检查,放到空券提前返回之前,同时兼容新旧请求字段。
- 后续改动,也就是本文所依据的指定提交:新字段路径从比较
(couponId, userCouponId),改为只比较couponId,并增加对应测试。
couponId 在这里表示券批次;userCouponId 表示个人持有的券实例。后续提交明确允许"券批次相同,但算价与请求中的个人券 ID 不同"通过这一层集合比较。

实际实现用 CollectionUtils.isEqualCollection 比较映射后的列表。它不要求顺序相同,但会比较元素出现次数,因此不能随手换成去重后的 Set 比较。
这也不等于忽略个人券:后续调用仍把请求中的 couponId 和 userCouponId 传给领用券接口,返回结果继续进入订单上下文与券快照。
需要保留一个明确边界:本次提交和测试证明了"比较规则允许这种差异",并没有独立证明所有同批次个人券在所有业务条件下都可互换。个人券归属、有效期、剩余额度及重复消费控制,仍需由资产服务保证并做集成验证。
5. 为什么只补金额校验或加锁不够
如果只再次比较应付金额,示例中的两边都是 80 元,错误组合仍然可能成立。
如果只在领用券接口里加强幂等,也无法阻止根本没有调用领用券接口的请求。幂等解决的是重复执行;本次缺口是该执行的步骤被跳过。
另一种方案是完全依据服务端算价结果决定消费哪张券。这需要明确用户选择与重新报价的契约,并统一后续资产使用来源,改动范围更大。当前修复选择先拒绝不一致请求、让客户端刷新,能沿用既有业务流程。
6. 事务和补偿的边界
订单构造时,实际个人券 ID 取自领用返回结果,券快照取自算价结果,并通过回填步骤关联。创建订单通过 RocketMQ 事务消息触发本地事务落库。
领用券发生在订单本地事务之外。主流程异常时,现有代码根据上下文中的领用结果发起退券,同时处理库存等资产补偿。因此不能把数据库事务注解解释为已经覆盖远程券服务、Redis 和 MQ 的全局原子性。
外围还存在 Redis 布隆过滤器、限购及支付拉起数据缓存等操作;这两次变更没有重写这些机制。不能据此归因于 Redis,也不能宣称修复了所有并发或补偿问题。
7. 测试应覆盖哪些组合
历史测试类中有两条直接相关的用例:
| 已有用例 | 保护的行为 |
|---|---|
| 算价有券,请求券集合为空 | 拒绝提单,返回刷新规格指令 |
| 批次相同、个人券 ID 不同,模拟领用成功 | 通过这层资产校验 |
第二条用例 Mock 了远程券接口,不能作为真实资产核销、归属校验或并发安全的证据。
这次复盘最值得带走的检查点是:订单享受了哪一种优惠,就必须能找到与它对应的资产使用依据。金额对上,只完成了交易校验的一部分。