大型后台管理系统如何把业务操作做可靠(1):把业务规则写进订单和退款模型
一笔订单成交 120 元,实际支付 100 元,已经退了 20 元,还有一笔 30 元退款在处理中。客服看到「净收款 80 元」,此时最多还能发起 50 元的新退款。净收款回答财务已经发生了什么,可退余额还要考虑正在占用的额度。
先确定每个字段在哪个业务范围内成立
设计订单详情前,拿几张覆盖部分支付、部分发货、退款失败的单据,和产品、财务一起逐项说出金额的来源和变化时点。把同名但不同义的量拆开,再把定义放进接口契约和测试数据。美团的交易系统演进也从统一语言和限界上下文划分概念的适用范围。美团交易系统的领域建模实践
| 字段 | 示例(分) | 变化来源 |
|---|---|---|
orderTotalMinor |
12000 | 下单时确认的成交价 |
capturedMinor |
10000 | 已成功收取的金额 |
refundedMinor |
2000 | 已确认成功的退款 |
reservedRefundMinor |
3000 | 已受理且尚未结清的退款 |
availableRefundMinor |
5000 | 以上支付、退款和占用金额推导 |
在这个简化模型中,一笔订单只对应一种币种和一个退款资金池。跨支付方式时先决定每笔退款从哪笔支付扣减,再按支付记录分别维护额度。整数最小货币单位随币种约定,接口还要限定可表示范围。服务端应拒绝币种不匹配和负数金额。
支付、发货、退款应允许独立变化。支付成功、已经发货、退款处理中能同时成立。一笔订单也可以有多次部分退款,因此退款实体需要自己的 ID、金额、状态和外部流水。订单列表可以派生「部分退款」标签,但派生结果不应反过来决定可执行的退款动作。
接口表达业务意图
客服提交退款时只传订单、金额和原因,操作者与租户由服务端从已验证的上下文取得。下面是示例协议,服务端接收后生成退款对象,并返回可查的操作 ID。
POST /orders/O42/refund-requests
Idempotency-Key: r-17
Content-Type: application/json
{"amountMinor": 4000, "currency": "CNY", "reason": "PARTIAL_RETURN"}
页面里要分开三份数据。接口返回的订单摘要可刷新,页面展示数据负责格式化金额和展示操作提示,草稿则保存本次输入。重新查询订单后,可退额度从 50 元变为 20 元时,保留客服填的 40 元并给出校验反馈。服务端仍会在真正写入时检查额度,因为读取与提交之间可能发生另一笔退款。
把额度约束放到同一个写入边界
假设账本摘要、退款记录和待投递记录都在同一个数据库。下面用伪代码表示首次受理分支,重放请求由幂等记录识别。
开始事务
锁定当前租户下的订单资金池
校验操作者、订单状态、币种及金额
available = captured - refunded - reservedRefund
如果 amount > available,拒绝受理
增加 reservedRefund
创建退款记录,状态为 accepted
写入待投递记录
提交事务
锁必须覆盖所有竞争同一额度的写入。也可以使用带条件的原子更新,由数据库保证余额足够时才扣占用。退款成功后,在同一事务里将占用金额转到已退金额。确认失败后释放占用。结果仍未知时保留占用并查下游结果,否则下一笔退款可能提前用掉这笔额度。
审批通过和退款完成是两个时点。审批记录需要绑定金额和目标订单,审批后金额改变时由业务明确是否重审。即使有审批,执行退款也要核对当前额度。部分发货、拒绝审批或申请撤销都可以写成状态转换表,逐条定义触发动作、前置条件、写入结果与重复执行规则。
用流水和状态表推演额度怎么变化
给资金池列出允许改变余额的动作。把「可退额度」作为查询时计算的结果,写路径直接保护用于计算的三个原始量。这里把当前在途的 30 元记作 R1,新申请 40 元记作 R2。业务表里每笔退款也保存自己的金额,资金池的占用汇总可以和全部在途退款对账。
| 事件 | R2 状态 | 已退 | 占用 | 可退 |
|---|---|---|---|---|
| 初始 | 尚未创建 | 20 | 30 | 50 |
| 受理 R2 | 已受理 | 20 | 70 | 10 |
| 渠道调用超时 | 待确认 | 20 | 70 | 10 |
| 查到渠道成功 | 成功 | 60 | 30 | 10 |
| 重复收到成功回执 | 成功 | 60 | 30 | 10 |
表中的金额用元方便阅读,写入仍使用前文的整数金额。成功回执只允许从持有占用的状态进入终态。在一笔事务里推进退款状态、释放本笔占用并增加已退。如果更新结果显示该笔已经成功,直接返回已有结果。如果它已经明确失败并释放占用,此时又收到成功回执,转给对账处理。把它当成普通重试会掩盖渠道与本地账本的冲突。
这个模型假设受理即占用。若流程要求财务审批后才占用,提交时只能提示当下的可退金额。审批期间额度可能被其他申请用掉,审批页应说明这是估计,真正受理时再次检查。另一种做法是在申请时就占用,但要规定拒绝、撤回和超时何时释放,否则长时间无人处理的申请会一直挡住其他退款。
让 API 和页面保留两种解释
商品退货、运费补偿和客服差价退款可能都走同一个资金池,但审批原因和业务对象不同。可以让不同入口生成各自的业务请求,最后复用同一条额度检查。这样修改运费政策时,变的是允许申请的规则,账本约束仍由交易服务统一承担。
列表需要的是能扫读的摘要。退款详情需要按笔解释状态和流水,客服编辑页则需要当前额度、可选原因和临时草稿。给这些读取场景分别定义返回结构。系统也可以返回服务端计算的操作提示,例如 canRequestRefund 和 blockedReason,它们代表此次读取时的判断。提交时如果提示已过期,服务端返回稳定的业务错误码,页面更新额度并保留草稿。
评审时可让产品、前端和后端各自解释同一张状态表。如果有人把「审批通过」读成「已退款」,或者把「可退金额」直接等同于「净收款」,就应先修改词汇和接口契约。
验收业务模型
用上面的初始数据并发提交两笔 40 元申请。两笔都基于 50 元余额读到的页面,最终也只能有一笔成功占用,剩余可退 10 元。让这笔申请失败,余额回到 50 元。再让它成功,已退从 20 元增加到 60 元,原来那笔 30 元仍然占用,剩余可退仍是 10 元。用订单摘要、退款记录和审计记录分别核对这些变化。
总结
订单详情要能区分已经支付、已经退款和尚在途的占用。把这些量分别命名,把客服的意图写成退款命令,再让服务端在同一写入边界维护额度。这样页面可以提供即时反馈,最终结果仍由交易记录解释。