优惠券为何能反复使用?一次算价与提单不一致的资损复盘

订单金额算对了,交易就安全吗?这次问题给出的答案是:还不够。

事故现象是,算价时已经抵扣优惠券,实际提单却没有使用优惠券。订单享受了优惠,券却没有被消费,因此具备反复利用、持续造成资损的条件。

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 了远程券接口,不能作为真实资产核销、归属校验或并发安全的证据。

这次复盘最值得带走的检查点是:订单享受了哪一种优惠,就必须能找到与它对应的资产使用依据。金额对上,只完成了交易校验的一部分。

相关推荐
RAG学徒loading31 分钟前
我把 RAG 召回从 57.6% 做到 78.8%,然后发现 75% 的真实问题它答不了
后端
用户425693999962031 分钟前
我的 HashMap 查找全废了,但 36 个测试全绿
后端
小番茄程序猿38 分钟前
Redis 编码压测 59 轮:HSET 快 23 倍是假象,SMEMBERS 慢 30 倍是真的
redis·后端
Moment1 小时前
代码都能让 Agent 写了,我们为什么还要学前端?
前端·后端·面试
AI入职记1 小时前
Spring AI 实测:不挂工具,模型真的会瞎编吗?
后端·ai编程
wei_shuo1 小时前
SQL Server数据迁移实战:内置故障诊断工具与根因分析实践
后端
一条Go1 小时前
告警指纹:一次故障为什么只产生一条 FIRING 记录
后端·架构·github
鶴哥只手遮天2 小时前
《Redis 8.10.3 深度剖析》系列 ·第 13 章 Set:三种编码、集合运算与随机采样
redis·后端·开源