[{"data":1,"prerenderedAt":193},["ShallowReactive",2],{"blog-pay-refund":3},{"id":4,"title":5,"body":6,"category":178,"date":179,"description":180,"extension":181,"meta":182,"navigation":183,"path":184,"seo":185,"stem":186,"tags":187,"__hash__":192},"blog\u002Fblog\u002Fpay-refund.md","支付逆向工程：退款链路的并发控制与防刷机制实战",{"type":7,"value":8,"toc":170},"minimark",[9,13,22,30,35,38,49,65,69,72,79,84,111,115,121,124,131,142,157,160,163],[10,11,5],"h1",{"id":12},"支付逆向工程退款链路的并发控制与防刷机制实战",[14,15,16,17,21],"p",{},"很多刚接触支付系统的开发者会有一种错觉：退款不就是把支付的流程反过来走一遍吗？调用一下微信或支付宝的 ",[18,19,20],"code",{},"refund"," 接口不就行了？",[14,23,24,25,29],{},"在线上真实的复杂业务场景中，",[26,27,28],"strong",{},"退款的复杂度甚至远超正向支付","。正向支付失败了，大不了用户重新下一次单；但逆向退款一旦出现漏洞，面临的往往是直接的资金流失（如被黑产“薅羊毛”超额退款）。退款链路的设计，是一场与并发和精度的极限拉扯。",[31,32,34],"h2",{"id":33},"_1-独立的状态机退款单与支付单的解耦","1. 独立的状态机：退款单与支付单的解耦",[14,36,37],{},"绝对不能在原有的“支付单”上直接加一个“已退款”状态来处理退款逻辑。",[14,39,40,41,44,45,48],{},"真实的交易往往伴随着",[26,42,43],{},"多次部分退款","。例如用户买了一笔 200 元的订单，包含了 A、B 两个商品，用户可能今天退了 A（50元），明天又退了 B（150元）。\n因此，退款必须拥有自己独立的领域模型和状态机：",[26,46,47],{},"退款单（Refund Order）","。",[50,51,52,59],"ul",{},[53,54,55,58],"li",{},[26,56,57],{},"1 个支付单 -> N 个退款单："," 每次发起的退款请求，都必须生成唯一的退款单号，记录本次退款的金额、原因和状态（初始化 -> 退款处理中 -> 退款成功\u002F失败）。",[53,60,61,64],{},[26,62,63],{},"幂等重试的基石："," 当调用底层通道退款超时时，网关只能拿着这个全局唯一的“退款单号”去查询或重试，从而避免通道发生重复退款。",[31,66,68],{"id":67},"_2-金融级大坑并发控制与超退防刷","2. 金融级大坑：并发控制与超退防刷",[14,70,71],{},"黑产最喜欢攻击的接口往往不是支付，而是退款。最经典的攻击手段是：利用脚本在毫秒级并发发起两笔退款请求。",[14,73,74,75,78],{},"如果你的代码逻辑是先查询可退余额，再调用退款接口，最后扣减可退余额（典型的 ",[18,76,77],{},"Read-Modify-Write"," 反模式），在并发下，两次请求都会读到原始的满额可退余额，最终导致 100 元的订单退出了 200 元。",[14,80,81],{},[26,82,83],{},"实战防刷战术：",[85,86,87,97],"ol",{},[53,88,89,92,93,96],{},[26,90,91],{},"强悲观锁控制："," 任何退款动作发生前，必须以“原支付单 ID”或“原交易单 ID”作为 Key 加上分布式锁，或者在数据库层面利用 ",[18,94,95],{},"SELECT ... FOR UPDATE"," 锁住原订单记录。",[53,98,99,102,103,106,107,110],{},[26,100,101],{},"数据库兜底校验："," 仅靠业务代码拦截是不够的。在支付单表中必须维护一个 ",[18,104,105],{},"refunded_amount","（已退总金额）字段。每次更新时采用乐观锁或条件更新：\n",[18,108,109],{},"UPDATE payment_order SET refunded_amount = refunded_amount + 50 WHERE id = 123 AND (pay_amount - refunded_amount) >= 50;","\n只要这条 SQL 影响的行数为 0，立刻拦截报错。",[31,112,114],{"id":113},"_3-最复杂的算术题营销资产的按比例分摊","3. 最复杂的算术题：营销资产的按比例分摊",[14,116,117,118],{},"在电商或外卖业务中，订单极少是纯现金支付的。\n假设用户买了一单 100 元的外卖，使用了 20 元的平台红包，自己实际用微信支付了 80 元。现在用户申请退其中一个价值 30 元的菜品。请问：",[26,119,120],{},"应该退给用户多少现金？退多少红包？",[14,122,123],{},"这就是支付系统中最让人头疼的**资产分摊（Asset Allocation）**问题。",[14,125,126,127,130],{},"处理原则通常是",[26,128,129],{},"等比例拆分，并在最后一笔进行“兜底抹平”","：",[50,132,133,136,139],{},[53,134,135],{},"现金退款比例 = 80 \u002F 100 = 80%。",[53,137,138],{},"本次退现金 = 30 * 80% = 24 元。",[53,140,141],{},"本次退红包 = 30 * 20% = 6 元。",[14,143,144,145,148,149,152,153,156],{},"最容易引发 Bug 的是",[26,146,147],{},"除不尽产生的精度丢失问题","（例如三分之一）。因此，所有的计算必须使用 ",[18,150,151],{},"BigDecimal","，并在发生最后一笔全额退款时，不能再用比例计算，而是必须用 ",[18,154,155],{},"总实付现金 - 历史已退现金"," 来倒挤出最后一笔应退金额，确保账面绝对平掉。",[31,158,159],{"id":159},"总结",[14,161,162],{},"退款链路是系统的“后悔药”，这副药绝不能有任何毒副作用。在设计逆向流程时，必须收起对代码完美执行的盲目自信，假设每一次查询都会被并发覆盖，假设每一次远程调用都会超时。用强锁控制并发，用数据库兜底金额，用严谨的数学逻辑处理分摊，才能守住平台的资金大门。",[14,164,165],{},[166,167,169],"a",{"href":168},"\u002Fblog\u002F","返回博客列表",{"title":171,"searchDepth":172,"depth":172,"links":173},"",2,[174,175,176,177],{"id":33,"depth":172,"text":34},{"id":67,"depth":172,"text":68},{"id":113,"depth":172,"text":114},{"id":159,"depth":172,"text":159},"支付架构","2026-03-19","很多刚接触支付系统的开发者会有一种错觉：退款不就是把支付的流程反过来走一遍吗？调用一下微信或支付宝的 refund 接口不就行了？","md",{},true,"\u002Fblog\u002Fpay-refund",{"title":5,"description":180},"blog\u002Fpay-refund",[188,189,190,191],"支付系统","退款","并发控制","资产分摊","56E7j9Sb_QBBTFJogT-_tLYOeCZTJEvFTFoPsjTG3Ic",1779959652910]