
用户拿着海外软件后台写着Refunded的截图找客服质问为什么 App 里的钱包余额没有变。如果你的系统里只有一个refunded true字段客服此刻只能抓瞎说钱到了用户截图打脸说退款失败商户那边明明已经处理完了。这是一个典型的双层余额对账问题。在涉及稳定币和虚拟 Visa 卡的架构中卡内余额与数字钱包余额退款的状态机横跨商户、处理方、发卡侧三层系统任何一层的完成都不能直接等同于资金到账。本文拆解这套状态机的建模思路附对账模型与幂等回调的实现参考。以 MPChat 卡付款作为用户场景做通用架构示例不代表 MPChat 的现网接口、内部表结构或真实退款处理实现。一、要对齐的从来不是一个状态字段退款在系统间的传递用户发起或获得退款资格 ↓ 收款方记录退款申请、批准与发出 ↓ 支付处理链路处理原交易的退款 ↓ 原付款卡侧出现退款入账记录收款方批准退款并发出指令不代表支付链路已经处理完毕支付链路处理完也不代表发卡侧已经完成资金结算。每一层有自己的时间线任何一层写了完成都不代表下一层也完成了。还有一种更容易误判的情况预授权释放。当一笔消费已授权但商户最终未请款或撤销时用户看到可用资金恢复了。但这不是一笔针对已结算消费的退款只是授权占用释放。如果把授权释放硬套进退款对账流程系统会产生无法抹平的幽灵数据。对账系统应该保留授权释放和消费退款两种独立类型。二、拆成三组状态才能说清楚以下英文状态是本文建模用的归一化名称不是照搬任何支付服务商的真实返回值。层级状态示例含义商户退款SUBMITTED / PROCESSING / COMPLETED收款方视角的退款进度处理方RECEIVED / APPROVED / SETTLED退款在处理链路中的流转卡侧入账PENDING / POSTED退款是否实际回到了原卡用户界面最容易踩的坑把merchant_refund SUBMITTED直接翻译成资金已回卡。更克制、也更有信任感的文案是收款方显示已发起退款当前尚未在资金网络中收到入账记录。多几个字少很多工单。三、主键和金额不要想当然一对一一次原消费可以部分退款也可以分多次退。按原订单金额 退款金额做精确匹配一定会漏。跨币种结算时的汇率波动会让基于金额的绝对匹配直接失效。一个最小对账模型把原始支付、退款尝试与卡侧实际入账剥离保存original_payment: internal_payment_id merchant_order_ref card_transaction_ref amount currency settled_at refund_attempt: internal_refund_id original_payment_id merchant_refund_ref requested_amount currency merchant_status processor_status card_refund_entry: internal_entry_id source_event_ref original_card_ref posted_amount currency posted_at三个容易忽略的细节merchant_refund_ref可能只活在商户系统里卡组织网络不一定收到同一个编号跨币种消费的退款展示金额会因汇率产生差异不能只靠金额模糊匹配来认定原卡标识只保存业务必要的内部引用或脱敏信息日志里不要留完整卡号当两套系统没有共享交易标识时金额 币种 原消费时间 商户名称这些条件只能生成待人工确认的候选匹配自动认定是在赌。数据模型拆清楚了下一个问题是运行时怎么防住重复。四、回调要幂等补偿不能重复发退款def handle_refund_event(event): verify_event_authenticity(event) # 事件唯一标识由实际接入方提供不要自己用时间和金额拼接 if event_store.exists(event.provider, event.event_id): return with transaction(): event_store.insert(event.provider, event.event_id, event.payload) refund refund_store.find_by_external_ref( providerevent.provider, external_refevent.refund_ref, ) if refund is None: review_queue.add(event) # 无法映射原消费待人工核查 return refund_store.apply_status_transition(refund, event.status) if event.kind CARD_REFUND_POSTED: card_ledger.insert_once( providerevent.provider, external_entry_idevent.ledger_entry_id, refund_idrefund.id, amountevent.amount, currencyevent.currency, )两层幂等缺一不可外部事件重复投递状态机不能盲目推进同一笔卡侧退款记录重复同步内部账本只能写一次定时对账任务的职责是查状态为什么没对齐不是看到超时就再向商户发一次退款。现象应对商户显示已发出卡侧没有记录核查处理链路和可用关联信息卡侧出现退款记录用户总余额没变核查卡片与 Pay 两层余额及内部转移记录仅有授权占用原消费未结算查授权释放不要套用已结算消费退款的流程五、用户看到的状态也要有边界把后台复杂的异步状态翻译给用户时产品设计需要诚实。一个成熟的退款进度页应该像物流追踪一样清晰至少分别展示原付款渠道及原卡收款方当前给到的退款状态卡片侧是否找到退款入账最后一次核查时间信息缺失时应由哪一方继续核查系统只接到卡片侧数据诚实显示未掌握商户退款进度。只接到商户侧数据不要写已经回到卡上。对用户来说能看清哪一层有了确凿证据比一个过早亮起来的绿色成功有用得多。真正的成功标记应该是三层状态全部闭合之后才亮的。