支付逆向工程:退款链路的并发控制与防刷机制实战
支付逆向工程:退款链路的并发控制与防刷机制实战
很多刚接触支付系统的开发者会有一种错觉:退款不就是把支付的流程反过来走一遍吗?调用一下微信或支付宝的 refund 接口不就行了?
在线上真实的复杂业务场景中,退款的复杂度甚至远超正向支付。正向支付失败了,大不了用户重新下一次单;但逆向退款一旦出现漏洞,面临的往往是直接的资金流失(如被黑产“薅羊毛”超额退款)。退款链路的设计,是一场与并发和精度的极限拉扯。
1. 独立的状态机:退款单与支付单的解耦
绝对不能在原有的“支付单”上直接加一个“已退款”状态来处理退款逻辑。
真实的交易往往伴随着多次部分退款。例如用户买了一笔 200 元的订单,包含了 A、B 两个商品,用户可能今天退了 A(50元),明天又退了 B(150元)。 因此,退款必须拥有自己独立的领域模型和状态机:退款单(Refund Order)。
- 1 个支付单 -> N 个退款单: 每次发起的退款请求,都必须生成唯一的退款单号,记录本次退款的金额、原因和状态(初始化 -> 退款处理中 -> 退款成功/失败)。
- 幂等重试的基石: 当调用底层通道退款超时时,网关只能拿着这个全局唯一的“退款单号”去查询或重试,从而避免通道发生重复退款。
2. 金融级大坑:并发控制与超退防刷
黑产最喜欢攻击的接口往往不是支付,而是退款。最经典的攻击手段是:利用脚本在毫秒级并发发起两笔退款请求。
如果你的代码逻辑是先查询可退余额,再调用退款接口,最后扣减可退余额(典型的 Read-Modify-Write 反模式),在并发下,两次请求都会读到原始的满额可退余额,最终导致 100 元的订单退出了 200 元。
实战防刷战术:
- 强悲观锁控制: 任何退款动作发生前,必须以“原支付单 ID”或“原交易单 ID”作为 Key 加上分布式锁,或者在数据库层面利用
SELECT ... FOR UPDATE锁住原订单记录。 - 数据库兜底校验: 仅靠业务代码拦截是不够的。在支付单表中必须维护一个
refunded_amount(已退总金额)字段。每次更新时采用乐观锁或条件更新:UPDATE payment_order SET refunded_amount = refunded_amount + 50 WHERE id = 123 AND (pay_amount - refunded_amount) >= 50;只要这条 SQL 影响的行数为 0,立刻拦截报错。
3. 最复杂的算术题:营销资产的按比例分摊
在电商或外卖业务中,订单极少是纯现金支付的。 假设用户买了一单 100 元的外卖,使用了 20 元的平台红包,自己实际用微信支付了 80 元。现在用户申请退其中一个价值 30 元的菜品。请问:应该退给用户多少现金?退多少红包?
这就是支付系统中最让人头疼的**资产分摊(Asset Allocation)**问题。
处理原则通常是等比例拆分,并在最后一笔进行“兜底抹平”:
- 现金退款比例 = 80 / 100 = 80%。
- 本次退现金 = 30 * 80% = 24 元。
- 本次退红包 = 30 * 20% = 6 元。
最容易引发 Bug 的是除不尽产生的精度丢失问题(例如三分之一)。因此,所有的计算必须使用 BigDecimal,并在发生最后一笔全额退款时,不能再用比例计算,而是必须用 总实付现金 - 历史已退现金 来倒挤出最后一笔应退金额,确保账面绝对平掉。
总结
退款链路是系统的“后悔药”,这副药绝不能有任何毒副作用。在设计逆向流程时,必须收起对代码完美执行的盲目自信,假设每一次查询都会被并发覆盖,假设每一次远程调用都会超时。用强锁控制并发,用数据库兜底金额,用严谨的数学逻辑处理分摊,才能守住平台的资金大门。