ENGINEERING NOTES · 工程笔记

模拟一个"会耍赖"的支付网关:回调时序、乱序与重放

上一篇《当沙箱不够用》讲了我们给多租户微信小程序 SaaS(Java 21 + Spring Boot + PostgreSQL)自建微信开放平台模拟器的整体思路。这一篇收窄到一个点:回调通知的模拟。它是整个模拟器里设计上最别扭的部分——因为要模拟的不是接口,而是对手的"脾气"。

一、背景:真实的回调不可控,所以测试的回调必须可控

先说结论:回调是第三方支付链路里唯一"不由你发起"的写操作,而它的行为恰恰最不可控。

线上真实发生过三种"耍赖"。迟到:下单后 41 秒回调才到,我们的查单补偿已经跑完两个来回。乱序:退款回调比支付成功回调早到 200 多毫秒。重复:同一条通知一晚上发了三遍。每一种都打过我们一个措手不及。最早那次乱序事故,值班同事在群里只发了一句话:"这退款怎么比付款还先成功?"——状态机没防住,订单卡在"已退款未支付"的矛盾态,最后靠人工修数据收场。

诡异的是,这些行为在测试环境里一次都复现不出来。测试网的回调永远立刻、按序、恰好一次,礼貌得像个机器人。你想测乱序,它偏按时来;你想测重放,它偏只发一遍。防御代码写了 300 多行,覆盖率报告一片绿,可它们防的那些场景,上线前一个都没真正跑过。

这就是全部设计目标:测试环境必须能"指使"异常发生。不是等对方耍赖,而是让对方照着剧本耍赖。

二、四类可控行为:把"脾气"拆成开关

我们把线上见过的回调行为归纳成四类,每一类做成模拟器里一个可拨的行为:

行为线上原型考验什么
时序:延迟 N 秒再回调回调迟到,补偿先跑完补偿与回调的竞态
乱序:成功与退款故意换序通知队列重排状态机的免疫力
重放:同一条回调发三遍对端重试导致重复送达幂等表的拦截
失联:永远不回调通知彻底丢失补偿任务的兜底

单看每一类都不难,难的是能用"声明"的方式组合。"失联 60 秒后恢复"就是失联加时序:前 60 秒当黑洞,之后把积压的回调一次性吐出来——这恰好是对端服务重启时的真实行为。

有两处细节比想象中讲究。

乱序必须在调度层做,不能在用例里做。一开始同事图省事:"先触发退款、再触发支付,这不就是乱序?"不对。那测的是"业务上先退款",不是"送达上先到退款"。真正的乱序是:两个事件都已在模拟器侧发生、报文时间戳也符合先后,只是送达顺序被调换。前者测不出状态机的免疫力——报文里的时间字段会诚实地暴露真实顺序。

重放的三遍不能贴着发。真实的重复送达之间隔着几秒到几分钟。三遍在同一毫秒发出,第二次还没处理完第三次就到了,测出来的是并发;带间隔的重放,测的才是幂等。线上要的是后者。

三、注入的实现:用例声明"我要什么样的第三方"

每个用例开头声明场景,模拟器照单执行。场景通过 HTTP 拨过去,调度器核心大致长这样:

# 脱敏伪代码:回调调度器
class CallbackScheduler:
    def dispatch(self, event):
        scene = self.scene  # 用例声明的场景,夹具里已强制 reset

        # 1) 失联:入队即丢弃,但要记账——"应发未发"必须可查
        if scene.blackhole(event.type):
            self.journal.mark_dropped(event)
            return

        # 2) 时序:按声明压后送达
        delay = scene.delay_for(event.type)  # 如 pay_success 压后 60s

        # 3) 乱序:与配对事件交换送达顺序
        if scene.swap_with(event.type):
            delay += scene.gap  # 拉开 200ms,模拟通知队列重排

        # 4) 重放:同一份报文按间隔投递 N 遍
        for i in range(scene.replay(event.type)):
            self.queue.push(event, at=now() + delay + i * scene.replay_gap)

        self.journal.mark_scheduled(event)

调度器配一本账(journal):每条回调处于"已发、压后、丢弃"哪个状态,随时可查。这不是锦上添花——断言"补偿跑完后,迟到的回调没有造成二次入账",前提就是先从账本确认那一条确实送达过。

一个真实的坑:压后的回调活得比用例长。某用例声明延迟 60 秒,用例本身 8 秒就跑完了,队列里那条延迟回调在下一个用例执行途中才送达,打到别人头上,两个不相关用例偶发互败。那晚手机每十几分钟震一次、CI 一直在红,排查到凌晨一点半,最后给 reset 加了"一并清空未触达队列"的语义,用例间隔离才算干净。

四、回调构造:验签必须走真流程

报文按契约逐字段构造,没什么可说的,真正的细节在验签字段怎么造。

最省事的方案,是让模拟器发"免验签"报文、主工程在测试模式下跳过验签。我们没这么做。回调链路上最值钱的代码就是验签与解密,跳过它,等于把这条链路唯一的锻炼机会划掉。所以模拟器自己持有一套测试证书:签发、报文签名、加密、主工程用配对公钥验签、解密,全链路走真流程,一步不跳。测试证书只存在于测试环境的密钥管理里,不进代码仓库,本文同样不展示任何密钥材料(真实证书更是绝不沾边)。

多花半天搭证书链,换来三种以前根本测不到的问题:证书配错、签名算法不匹配、时间戳超窗——现在都能在 CI 里暴露,而不是等到上线第一周。

五、与补偿任务的联调:闭环验收

失联场景值得单独说,因为它的验收标准最容易定错。

错的验收:补偿任务执行了、日志里看到查单了,就算过。任务跑了,不等于结果对了。

我们的验收是闭环的、双侧核对:模拟器侧——账本里这条回调处于丢弃状态、查单接口确实被调过;业务库侧——订单终态正确、入账记录齐全。两边最终状态对得上,用例才算过。完整旅程:回调进黑洞 → 60 秒后补偿查单 → 订单转已支付 → 积压回调恢复吐出 → 幂等表拦截 → 两侧对账逐条对平。这条跑通,补偿与幂等才算真正被验收过一次,而不是"看起来执行了"。

六、三句话总结

回调测试的全部价值藏在对方的"脾气"里,永远守时的回调测不出任何防御代码的价值。把脾气拆成时序、乱序、重放、失联四类开关,用例声明场景、调度器照剧本演。验签走真证书、验收做双侧对账,模拟才不是自欺。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询