餐饮 SaaS 优惠券系统架构演进(十二)优惠券核销与退券状态机:锁券、核销、释放、撤销与售后一致性

本文深入剖析餐饮SaaS优惠券系统中核销与退券的状态机演进,聚焦锁券、核销、释放、撤销及售后一致性问题。通过租约令牌(lockToken)、状态版本(stateVersion)、订单绑定与CAS校验机制,解决支付与关单竞态导致的重复用券问题。系统采用操作账本、四方对账(订单/支付/退款/券状态)和不可变事件保障分布式一致性,并强调所有权验证而非仅依赖couponId。

优惠券进入用户券包后,最危险的并发发生在试算、下单、支付、关单和退款之间。本篇用一次“关单先释放、支付回调迟到、同一张券被两个订单消费”的脱敏事故,拆解租约令牌、状态版本、订单绑定、核销证据、退券策略和对账闭环。

Coupon LeaseState CASRefund ReturnRCA / Postmortem

结论先行:couponId 只能证明是哪张券,不能证明当前哪个订单拥有它。安全核销必须同时校验 couponId + orderId + lockToken + expectedState + stateVersion;支付、关单和退款只能发送事实或幂等命令,不能绕过统一状态机直接修改券状态。

案例边界与脱敏:本文中的用户券、订单、支付单、退款单、活动和模板均使用示例标识,例如 coupon_demo_1001、order_demo_A 和 lock_demo_L1。案例保留真实竞态机制、表关系和解决逻辑,不包含生产数据。

1. 背景与问题:券在支付完成前到底属于谁

第十一篇解决了优惠券如何安全发到用户券包,但一张 AVAILABLE 的券进入订单后,还要经历试算锁定、订单提交、支付成功核销、支付失败释放、关单、退款和可能的退券。多个服务会在不同时间看到不同事实,如果状态迁移只依赖 couponId,迟到事件就可能覆盖更晚的订单绑定。

一次压测中,订单 A 锁券后支付成功,但回调消息延迟;关单任务先把券释放为 AVAILABLE。订单 B 随后锁定同一张券。迟到的订单 A 核销请求只携带 couponId,最终把券改为 USED,导致两个订单都保留了同一优惠证据。

图 1:释放与核销竞态导致重复用券的事故链

2. 目标与约束:让每次状态迁移证明当前所有权

目标可验证标准关键约束
唯一锁定同一时刻一张券最多绑定一个有效 orderId / lockToken试算重入必须返回原租约,不能生成并行锁
核销正确只有当前租约持有者能从 LOCKED 迁移到 USED不能只按 couponId UPDATE
释放安全旧 Release 不覆盖已续租、已核销或新订单锁定必须带 expectedVersion 和 lockToken
退券可解释Return 决策引用原 Redemption、Quote 与策略快照金额退款与券状态恢复分开判断
乱序可收敛重复、延迟和乱序事件不产生非法迁移消费者幂等,冲突交由协调器裁决

核心不变量:LOCKED ⇒ exactly one active(orderId, lockToken);USED ⇒ exactly one Redemption;任何命令仅在 currentState + stateVersion + ownership 同时匹配时生效。

3. 系统架构:订单事实、券状态、操作账本与对账分层

Order / Pricing 保存选择结果和 quoteId;Coupon Command API 统一处理 Lock、Redeem、Release、Return 和 Void;Coupon State Service 是券状态的唯一写入口;Payment / Refund 提供业务事实;Operation Ledger、Outbox 和 Reconciliation 负责审计、传播与长尾收敛。

图 2:优惠券锁定、核销与退券架构

4. 核心设计:租约令牌、状态版本与订单绑定

锁券不是把 status 改成 LOCKED 就结束。系统需要生成不可猜测的 lockToken,保存 orderId、lockedAt、expiresAt 和 acquiredStateVersion。后续 Redeem / Release 必须带回相同 token,并使用 expectedVersion 做 CAS。续租只能由当前订单完成,且必须记录新的版本和操作证据。

图 3:用户券锁定、核销与退券状态机

命令前置条件状态与证据
LockAVAILABLE;未过期;活动允许LOCKED;创建 lockId / lockToken,绑定 orderId
RenewLOCKED;同 orderId / token;租约未失效保持 LOCKED;递增 stateVersion 和 expiresAt
RedeemLOCKED;支付事实有效;所有权与版本匹配USED;创建唯一 Redemption
ReleaseLOCKED;取消/失败事实;所有权与版本匹配RELEASED,再按有效性决定 AVAILABLE / EXPIRED
ReturnUSED;退款和策略允许;累计次数合法RETURNED 或 AVAILABLE;保存 RefundCoupon

5. 数据模型与 ERD:券实例、租约、核销与售后记录分离

user_coupon 保存当前状态和 stateVersion;coupon_lock 表示一次订单租约;coupon_redemption 是支付成功后的核销事实;order_coupon 固定订单与 Quote 的优惠证据;coupon_operation 记录不可变状态迁移;refund_coupon 保存售后金额与退券决策;coupon_outbox 传播领域事件。

图 4:核销、锁券与售后证据 ER Diagram

表主键 / 唯一键职责与关系
user_couponuser_coupon_id;state_version券聚合根与当前状态视图
coupon_locklock_id;coupon + order + lease_no订单租约、token、到期时间和锁状态
coupon_redemptionredemption_id;coupon + order 唯一支付核销事实与 paymentId
order_couponorder_coupon_id;order + coupon 唯一quoteId、优惠金额和订单行映射
coupon_operationoperation_id;business_key + operationfrom/to/version、reason 与关联操作
refund_couponrefund_coupon_id;refund + redemption退款金额、退券策略和结果
coupon_outboxevent_id;aggregate + event_type状态事件与 payloadHash

6. 试算锁券与支付核销流程

试算阶段选择券后创建短租约;订单提交保存 lockToken、quoteId 和 order_coupon 映射。支付成功事件到达后,Coupon Service 验证订单、token、预期状态和版本,写 Redemption、Operation、Outbox 并把券改为 USED。重复支付事件返回同一 redemptionId。

图 5:试算锁券与支付核销时序图

UPDATE user_coupon
   SET status = 'USED',
       state_version = state_version + 1,
       updated_at = :now
 WHERE user_coupon_id = :couponId
   AND status = 'LOCKED'
   AND locked_order_id = :orderId
   AND lock_token_hash = :tokenHash
   AND state_version = :expectedVersion;

7. 支付与关单竞态:状态服务拒绝猜测业务真相

支付成功和关单超时可能乱序到达。状态服务只按命令携带的 expectedVersion 执行 CAS:先成功的命令推进版本,后到命令得到 VERSION_CONFLICT。冲突并不等于简单失败;协调器读取订单和支付事实,决定补核销、订单退款或人工复核,但不能让旧命令覆盖新租约。

图 6:支付成功与关单释放竞态时序图

8. 锁券租约与过期扫描

Lease expiresAt 是兜底释放边界,不是业务支付截止时间的唯一来源。扫描器只处理仍处于 LOCKED、token 对应且 stateVersion 未变化的记录;释放前应检查订单/支付事实或投递协调事件。扫描任务使用稳定游标、分片和限速,重复执行不会重复改变券状态。

  • 试算租约短于订单支付窗口;订单创建后通过显式 Renew 延长,而不是原地改时间。
  • 扫描命令包含 lockId、lockTokenHash 和 expectedVersion,避免释放新租约。
  • 券本身已过有效期时,释放结果进入 EXPIRED,不回到 AVAILABLE。
  • 扫描器只生成 Release Command;不直接批量 UPDATE user_coupon。

9. 退款退券:金额退款与券状态恢复是两个决策

退款金额来自原 PriceQuote / Adjustment;是否退券来自原券模板和活动策略快照。整单退款也不一定退券,例如一次性兑换券、已过期券、活动关闭或风险复核中的券可能保持 USED / VOID。部分退款更不能简单恢复整张券。

图 7:退款后是否退券的决策模型

场景金额处理券状态建议
整单退款,券仍有效且策略允许按原 Adjustment 退款RETURNED → AVAILABLE,保留新版本
整单退款,原券已过期按原价格证据退款RETURNED / EXPIRED,不恢复可用
部分退款,优惠仍被剩余商品承接按行分摊退款保持 USED
部分退款,全部券优惠已退回累计退款达到原券优惠按模板策略 RETURN 或 REVIEW
高风险异常退款资金链路照常审计VOID / REVIEW,禁止自动复用

10. 撤销与补偿:追加反向操作,而不是覆盖历史

错误核销、支付撤销或人工修复不应删除 Redemption 或 Operation。系统追加关联 originalOperationId 的 Reverse / Return 操作,递增 stateVersion,并保留原始事实。补偿命令自身也具有业务幂等键和 requestHash;同一补偿重复到达返回原结果。

11. 事务与分布式一致性

在 Coupon Service 内,UserCoupon 状态、Lock / Redemption / Refund 证据、Operation Ledger 和 Outbox 必须同事务提交。跨订单、支付和退款服务采用至少一次事件 + Inbox 去重 + 状态 CAS。消息队列顺序只能降低乱序概率,不能替代业务版本条件。

图 8:券状态、操作账本与事件的一致性边界

12. 核销对账:订单、支付、退款与券状态四方核验

  • 支付成功订单使用优惠券,但券仍 LOCKED / AVAILABLE:生成补核销候选。
  • 订单已取消且无支付事实,但券仍 LOCKED:生成 Release 候选。
  • 券 USED 但不存在唯一 Redemption 或 order_coupon:P0 幽灵核销。
  • 同一 userCouponId 关联多个已支付订单:P0 重复消费。
  • RefundCoupon 决策为 RETURN,但状态未推进或 Outbox 未发布:补偿与重投。
  • Operation 的 fromVersion / toVersion 不连续:状态历史被绕过或数据被异常修改。

13. Troubleshooting:同一张券被两个订单消费的完整排查

Symptoms

用户券 coupon_demo_1001 在订单 A 和订单 B 中都产生优惠。订单 A、B 均已支付,但券表只有一个最终 USED 状态;常规接口成功率和消息消费率都正常。

Investigation

  • 从 userCouponId 展开 coupon_operation,重建 stateVersion 7 → 8 → 9 的迁移时间线。
  • 发现订单 A 使用 token=L1 锁定;Release 后订单 B 使用 token=L2 再锁定。
  • 迟到 Redeem 来自订单 A,但 SQL 只按 userCouponId + status 更新,没有 token 和 expectedVersion。
  • coupon_redemption 唯一键是 couponId + paymentId,未阻止同一券跨订单核销。
  • 关单与支付消费者分别拥有状态写权限,未经过统一 Command Handler。

图 9:重复用券事故的证据链

Root Cause

直接根因是订单 A 的迟到核销覆盖了订单 B 的新锁定;系统性根因是券状态迁移没有验证当前租约所有权。couponId 被误认为足够的业务身份,Release / Redeem 分散在不同消费者中直接写表,缺少 lockToken、orderId 与 stateVersion 的统一 CAS 契约。

图 10:同一张券被两个订单消费的 5 Whys

Resolution

  • 所有状态修改收敛到 Coupon Command Handler,外部服务只发送事实或幂等命令。
  • Lock 返回 lockToken 和 stateVersion;Redeem / Release / Renew 必须携带并校验。
  • Redemption 增加 userCouponId 唯一成功约束,并保存 orderId / paymentId。
  • 冲突命令返回 VERSION_CONFLICT,由协调器读取订单/支付事实后决定补偿。
  • 把事故序列加入乱序事件 Golden Case,并增加跨订单重复核销对账 Gate。

Verification

修复后对 PaymentSucceeded、OrderClosed、LeaseExpired 三类事件执行全排列和重复投递测试,共 18,000 组命令序列;每张券最多产生一个成功 Redemption,旧 token 命令全部返回 TOKEN_MISMATCH 或 VERSION_CONFLICT。历史 126,000 张已用券对账未发现新增跨订单消费。

Lessons Learned

数据库里只有一个最终 status,并不证明过程正确。状态机安全依赖的是谁有权迁移、基于哪个版本迁移,以及每次迁移是否留下不可变证据;缺少任何一项,迟到事件都可能把系统带回一个看似合法、实际错误的状态。

14. 为什么原方案会失败,最终方案为什么有效

原方案失败机制最终方案为何有效
只校验 couponId + status无法证明当前订单拥有券,旧命令可命中新状态orderId + lockToken + version 验证租约所有权
多个消费者直接 UPDATE状态规则分散,竞态和补偿语义不一致统一 Command Handler 封闭迁移规则
依赖消息顺序重试、分区和回放仍会产生乱序状态 CAS 让乱序成为可观测冲突
退款即自动退券金额和券有效性、风险、活动规则并不等价Return Policy 独立读取原证据决策
覆盖历史修数据失去事故证据,无法重算和审计追加 Reverse / Return 操作并关联原记录

15. 关键实现:带所有权验证的核销命令

RedeemResult redeem(RedeemCommand cmd) {
  return tx.execute(() -> {
    UserCoupon c = coupons.lock(cmd.couponId());
    idem.verify(cmd.businessKey(), cmd.requestHash());
    if (c.isUsedBy(cmd.orderId())) return existingRedemption(c);

    require(c.status() == LOCKED);
    require(c.lockedOrderId().equals(cmd.orderId()));
    require(c.lockTokenHash().equals(hash(cmd.lockToken())));
    require(c.stateVersion() == cmd.expectedVersion());

    Redemption r = redemptions.insertUnique(cmd);
    coupons.casToUsed(c.id(), cmd.expectedVersion(), r.id());
    operations.append(c, USED, cmd.businessKey());
    outbox.append("CouponRedeemed", c.id(), r.id());
    return RedeemResult.of(r.id(), USED);
  });
}

16. 验证方案:状态属性、并发、乱序与回放

  • State Property:随机生成 Lock / Renew / Redeem / Release / Return 序列,验证非法迁移永不成功。
  • Concurrency:同券多订单并发锁定、支付与关单同时到达、扫描器与续租竞争。
  • Event Permutation:支付、关单、过期、退款事件全排列、重复和延迟投递。
  • Replay / Reconcile:由 Operation Ledger 重放每张券状态,并与订单、支付和退款事实核验。
Invariant 1: one coupon has at most one active lock
Invariant 2: one coupon has at most one successful redemption
Invariant 3: old lockToken can never mutate a newer lease
Invariant 4: every state change increments stateVersion exactly once
Invariant 5: every successful command has one operation ledger entry
Invariant 6: return decision references original redemption and policy snapshot
Invariant 7: replay(operations) equals current coupon state

17. 可观测性与发布 Gate

指标 / Gate观察内容触发动作
Cross-order Redemption同券关联多个已支付订单P0:冻结券服务写入并启动核验
Token Mismatch旧租约命令比例与来源定位迟到消费者或错误重试
Version ConflictRedeem / Release / Renew 冲突分布检查订单支付竞态和处理延迟
Orphan Lock租约过期但长期 LOCKED触发协调检查和安全释放
Return Drift退款策略结果与券状态不一致生成补偿任务并阻断批量自动退券
Ledger GapOperation version 不连续P0:禁止自动修复,进入审计

18. 方案取舍

决策收益代价与控制
短租约 + 显式续租避免试算长期占券,所有权清晰续租调用增加;订单提交统一管理
状态 CAS低成本阻止旧命令覆盖新状态冲突需要协调器裁决,不能盲目重试
Operation Ledger过程可回放、异常可定位数据增长;冷热分层但保留审计主线
金额退款与退券分离适配部分退款、过期和风险场景策略更复杂;保存模板快照与 reasonCode
至少一次事件 + Inbox容忍消息重复和服务恢复消费者必须幂等并验证状态版本

19. Lessons Learned

  • 第一,couponId 是资源标识,lockToken 才证明当前订单的临时所有权。
  • 第二,消息有序不能替代状态 CAS;任何历史事件都可能被重放。
  • 第三,释放必须验证租约代次,否则扫描器会释放新订单的锁。
  • 第四,退款金额与退券状态属于两个业务决策,必须分别留证。
  • 第五,补偿应追加反向操作,不能删除 Redemption 或覆盖 Operation。
  • 第六,最终状态正确不代表过程正确,操作账本和跨域对账不可省略。

20. 后续优化与总结

  • 建设可视化券状态时间线,按 couponId 一键展开 Lock、Order、Payment、Refund 与 Operation。
  • 对热点券包引入命令串行键,但保留数据库 CAS 作为最终防线。
  • 将支付、关单、过期和退款事件排列组合纳入持续故障注入。
  • 为 Return Policy 建立版本化规则和影响面回放,避免策略更新改变历史售后结果。
  • 建立跨订单重复用券、孤儿锁和 Ledger Gap 的实时 Gate。

优惠券核销系统的核心不是一个 USED 状态,而是从试算到售后的所有权协议。LockToken 表达当前订单租约,stateVersion 阻止迟到命令,Redemption 和 Operation 保存不可变事实,Return Policy 解释售后决策,对账持续证明订单、支付、退款和券状态没有悄悄分叉。

最终结论:任何会修改券状态的命令,都必须回答四个问题:谁在操作、基于哪个租约、期望哪个版本、失败后如何留下证据。回答不完整,就不应执行状态迁移。

21. 下一篇

第十三篇进入优惠券数据建模与生命周期治理:《券模板、发放批次与用户券数据模型:不可变快照、版本演进、状态归档与冷热分层》。重点讨论模板版本、实例快照、有效期模型、ID 映射、数据迁移、分库分表和历史审计。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值