ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

支付回调的三道闸:验签、对账与幂等 —— 一个“付款自动发卡“链路的防线设计

支付回调的三道闸:验签、对账与幂等 —— 一个“付款自动发卡“链路的防线设计 接第三方支付网关时收到回调 → 发货/激活这一段代码是整个系统里攻击面最大的地方。本文按一条真实生产链路逐层拆解防线应该怎么摆。威胁模型回调接口面对的四种对手设计之前先想清楚谁会来敲门。一个暴露给支付网关的POST /notify端点至少要挡住伪造回调攻击者不付款直接 POST 一个长得像回调的请求赌你只信statuspaid金额篡改回调字段里带金额total_fee把 ¥299 的订单改成 ¥0.01 来结算重放把一笔真实已付的回调原样重发 N 次赌你发 N 次货丢失网关的回调因为网络/重启/超时没送到真实付了款的订单永远停在待支付。前三个是不该发的发了第四个是该发的没发。很多团队只防前三个结果真客户付了钱拿不到货 —— 在投诉率面前丢单的杀伤力一点不比伪造小。下面逐闸来看。第一道闸验签 —— 证明来自网关国内主流聚合支付本文以虎皮椒这类协议为例的回调都带hash字段把业务参数按 key 的 ASCII 排序拼成kvkv尾部拼上商户密钥做 MD5。服务端收到后用同一算法重算比对。function verifyNotifySign(data, appKey) { const s Object.keys(data) .filter(k ![hash, sign].includes(k) data[k] ! undefined String(data[k]) ! ) .sort() .map(k ${k}${data[k]}) .join() appKey; return crypto.createHash(md5).update(s, utf8).digest(hex) data.hash; }三个容易踩的坑空值字段要不要参与签名各家网关口径不同有的剔除空串、有的保留必须拿真实样例逐字段对齐猜不得验签失败要立刻拒返回非 success但日志必须落全量 body —— 换商户密钥、密钥轮换、网关侧 bug 全靠这些日志破案密钥只存服务端环境变量/密管绝不能进仓库、前端或日志。验签只能证明对方持有密钥它证明不了另一件事钱到了没。第二道闸金额核对 网关二次核验 —— 证明钱真的到了验签过了攻击者依然可以拿一笔未支付的订单号造一个带合法签名的回调吗不行 —— 签名本身已经把这类请求挡在门外。但还有一类更隐蔽的对手把订单金额在下单后改掉再回调或者网关侧参数透传出 bug。所以纵深要再叠两层金额双口径回调里如果带金额字段跟本地订单表核一次再向网关的查单接口out_trade_order维度核第二次 —— 以网关返回的total_amount为准。本地金额被脏写也能被网关口径拦下。支付状态二次确认不要信回调里的statusOD字段本身拿订单号去查一次网关。查单接口返回OD/paid_date/transaction_id才放行发货。这一步同时防住了伪造和未付款先发货两类场景代价是多一次网络往返。这里有一个必须拍板的取舍查单失败网关超时/5xx时你激活还是不激活我们的选择是 —— 无法判定时照常激活但 ERROR 留痕。因为客户付了钱没拿到货是资损 客诉 信任三杀而极小概率多放一单只是资损一项。宁可错放不可错杀但前提是第一道验签已经过了闸 —— 这条降级只给真实买家遇上网关抖动用不给伪造者留门。第三道闸幂等 —— 一次付款只发一次货网关对未收到 success 应答的回调会重试若干次同一笔订单还可能同时被回调路径和对账路径命中。发货动作必须天然幂等靠条件更新的状态机 影响行数做闸const [upd] await conn.query( UPDATE Orders SET StatusPAID, PaidAtNOW(6) WHERE HupijiaoTradeNo? AND StatusPENDING, [tradeNo] ); if (upd.affectedRows 0) return success; // 已被别的并发处理, 直接确认重试, 不重复发货几个配套细节状态机只允许PENDING → PAID一条边重复回调全部撞在这条边的WHERE StatusPENDING上自然失效如果改状态和发货是两个动作必须放同一个事务崩溃即整体回滚不存在钱付了单改了但码没发的中间态发码这类要写多行的动作事务内先SELECT ... FOR UPDATE锁订单行防两路并发同时进入。新增状态值时的隐形回归如果你的业务里有多种终态比如后来加的REFUND_PENDING记得全仓 grep 所有Status IN (...)的消费点 —— 我们真实踩过一次新状态没被退码链收录同码重兑撞唯一键把已修好的链路打回带伤。状态机是隐形的合同改它必须全量对账。第四道闸对账兜底 —— 证明没有单子被漏掉前三道都防不该发的发了这第四道防该发的没发。回调丢失是概率事件服务重启窗口、网关重试预算耗尽、网络分区随便一个都能让一笔真金白银永远卡在 PENDING。靠网关重试是不够的重试预算有上限。兜底做成两层就够-- 每小时: 30 分钟还没等到回调的单, 主动向网关查证 SELECT HupijiaoTradeNo FROM Orders WHERE StatusPENDING AND CreatedAt NOW() - INTERVAL 30 MINUTE;对 PENDING 超阈值的单逐个走查单接口网关说OD就复用既有的幂等发货链补账同一把状态机闸回调和对账并发也只生效一次网关说WP未支付就放着等下轮 —— 对账路径没有回调验签这个前提所以查单结论必须唯一可信任何无法判定一律跳过不误发。第二层做成触发式客户在支付页轮询查单时顺手对超过阈值仍未支付的当前单触发一次补账 —— 真付了款但回调丢了的客户刷新一下页面就自己救回来了不用等下个整点。补账动作加个 5 分钟节流同单 5 分钟内只查一次网关防止轮询把网关打爆。一个少有人防的边缘场景换支付商户的切换窗口如果哪天你要把收款主体从一个商户号切到另一个换公司主体、换费率、原账号风控有两件事几乎没人提前想到旧商户的迟到回调会撞上你的新密钥。切换后旧商户号下未支付的二维码可能仍有人扫比如客户翻出十分钟前的截图网关带着回调来了 —— 用旧密钥签的名你的新密钥验不过。这时别返回success假装处理完成也别返回会导致无限重试的错误网关按失败重试策略会反复敲门最稳的姿势是验签失败照常拒fail但日志单独标记旧商户回调给它们一个观察期。我们实测网关重试三次、八分钟左右就停了 —— 确认旧商户再无业务后这条日志分支才可以清理。验证链要按商户身份维度重走一遍不能只测代码。切换之后哪怕代码一行没动也要用新商户真实下一笔小额付款走通下单 → 回调 → 验签 → 对账 → 发货全链。只测本地单测、只测下单拿二维码都证明不了回调签名这条最关键的边在新密钥下是通的 —— 我们切商户当天用探针单验证到网关受理 查单状态两层但真实到账发卡必须由一笔真付款完成这是任何模拟都给不了的确定性。一句话密钥换了信任链就换了换信任链必须用真金白银验一遍。四道闸摆在一起| 对手 | 防线 ||---|---|| 伪造回调 | 验签 || 金额篡改 | 回调金额核对 网关 total_amount 双口径 || 未付款先发货 | 查单二次确认不信回调 status 字段 || 重放 | 条件更新状态机 同事务发货 FOR UPDATE || 回调丢失 | 定时扫 PENDING 查单触发式补账 |单看每一道都不复杂难的是把它们摆全。上线前拿这张表对着自己的代码打一遍勾比事后翻日志补防线便宜得多 —— 反正我们是都交过学费之后才把表凑齐的。我们把这套链路的完整实现用在了自己的产品里也踩平了从回调验签到对账兜底的每个坑。微客AI助手让微信客服自动化跑在生产级别的支付底座上。关于微信客服自动化的落地实践含这套支付底座的实际产品形态延伸阅读产品介绍与下载微客AI助手 - 桌面AI自动回复软件 | AI智能客服机器人 · 7天免费试用版本演进与更新日志更新日志 - 微客AI助手
返回列表