ENGINEERING NOTES · 工程笔记

微信支付 APIv3 回调验签:从平台证书到应答,一次讲透

做微信支付对接,下单、查单都不难,最容易栽跟头的是回调。我们做多租户小程序 SaaS(帮线下商户生成专属小程序),支付域是 27 个业务域之一,这条链路上踩过的坑,基本就是社区反复提问的那几类。这篇把 APIv3 回调从验签到应答一次讲透,重点放在两处:为什么这么设计,坑发作时怎么按日志定位。

一、背景与坑:为什么栽跟头的总是回调

APIv2 时代回调就是"拿密钥拼串做 MD5",一把密钥走天下。APIv3 换成完整安全体系:请求要签名、回调要验签、报文要解密,涉及两套密钥、多张证书。翻车高发区有三处:

证书轮换。 平台证书会定期轮换,且新旧并行期共存。若只在启动时加载一张证书,某天微信换用新证书发回调,序列号对不上,验签全挂——而且是"昨天还好好的"这种挂法。

序列号不匹配。 回调头 Wechatpay-Serial 标明"本次用哪张平台证书签名"。很多实现无视这个头,永远拿固定那张证书验,轮换一到就翻车。

验签通过但解不开报文。 验签用平台证书公钥(非对称),解密用 APIv3 密钥(对称,32 字节)。有人验签过了却拿证书去解 resource,或 associated_data 没参与运算,GCM 认证直接抛异常——两把钥匙,各管各的。

二、完整流程:验签 → 解密 → 幂等 → 应答

1. 验签:平台证书 + Wechatpay-Serial

回调 POST 带四个关键头:Wechatpay-TimestampWechatpay-NonceWechatpay-SignatureWechatpay-Serial。验签三步走:

  1. 防重放:时间戳与当前偏差超过 5 分钟(300 秒)直接拒绝;
  2. 选证书:按 Wechatpay-Serial 在本地证书列表找对应证书,找不到先刷新证书列表再找;
  3. 验签名:拼接验签串 timestamp\nnonce\nbody\n(三段、末尾带换行),用平台证书公钥做 RSA-SHA256 验签,签名为 Base64。

为什么这么设计:平台证书和商户证书的分工。 APIv3 里有两条方向相反的信任链:商户私钥只给我方发出的请求签名,向微信证明"请求是我发的";平台证书的私钥在微信手里,只给它发出的回调签名,向商户证明"回调确实是微信发的"。双方各持私钥、互拿公钥验签,谁也不用交出私钥。所以验回调绝不能拿商户证书——方向反了,等于拿自己的公钥验自己。想通分工,"该用哪把钥匙"不再靠背。

特别强调:body 必须用原始报文字符串参与验签,先反序列化再序列化,字段顺序一变必挂。

2. 解密:AES-256-GCM

验签过后解析 JSON,业务数据在 resource 里:ciphertext(Base64)、nonceassociated_data。用 APIv3 密钥做 AES-256-GCM 解密:nonce 作 IV,associated_data 作 AAD 传入,密文末 16 字节是认证 tag。参数不对就解密失败——GCM 完整性校验在兜底。

为什么验签用非对称、加密却用对称? 非对称擅长证明身份:平台公钥可放心分发,泄露不伤安全;但性能差,不适合加密业务报文。对称密钥快,前提是仅两方共享保密——APIv3 密钥正是这种"你知我知"的秘密。GCM 模式一次解决两件事:机密性之外附带完整性校验,associated_data 不加密但参与认证,报文动一个比特,tag 校验立刻失败——防篡改在算法层兜底,不靠业务代码判空。

3. 幂等:订单号 + 事件类型做幂等键

微信回调不保证只发一次,同一事件可能收到多次。正确姿势:out_trade_no + event_type 做幂等键,收到先加幂等锁;再查订单状态机——已是"已支付",再来 PAY.SUCCESS 直接应答成功。先查状态机再落库,落库与后续动作放同一事务。

4. 应答:200、失败与重试

处理成功返回 HTTP 200(或 204),body 为 {"code":"SUCCESS","message":"成功"};验签失败、解密失败、处理异常返回 4xx/5xx 并给出 {"code":"FAIL","message":"原因"}。微信对失败回调按衰减间隔重试,这意味着:回调必须幂等,重试一定会再来;回调里别做重活——超时同样触发重试,重活堆在回调里等于自造重试风暴。

三、脱敏伪代码(Java / Spring Boot 风格)

// 脱敏伪代码,占位符非真实值
@PostMapping("/callback/wxpay")
public ResponseEntity<Map<String, String>> onNotify(
        @RequestHeader("Wechatpay-Timestamp") String ts,
        @RequestHeader("Wechatpay-Nonce") String nonce,
        @RequestHeader("Wechatpay-Signature") String signature,
        @RequestHeader("Wechatpay-Serial") String serial,
        @RequestBody String rawBody) {   // 原始报文,验签专用,中途不得重新序列化

    // 1. 防重放第一道闸:时间戳窗口判断
    //    边界口径:|now - ts| > 300 秒拒绝;恰好 300 秒建议也拒(宁严勿松)
    //    前提:服务器走 NTP 校时,时钟漂移比攻击更常见
    if (Math.abs(nowSeconds() - parse(ts)) > 300) return fail("时间戳过期");

    // 2. 防重放第二道闸:nonce 去重,同一 nonce 在窗口内只允许出现一次
    if (!nonceCache.putIfAbsent(nonce, ttlSeconds(300)))
        return fail("重复 nonce");       // 截获旧报文原样重发的典型特征

    // 3. 按 serial 路由平台证书;找不到先刷新一次(应对轮换并行期)
    var cert = certManager.bySerial(serial);
    if (cert == null) {
        certManager.refresh();           // 拉取最新平台证书列表
        cert = certManager.bySerial(serial);
    }
    if (cert == null) return fail("无匹配平台证书"); // 把 serial 打进日志,排查全靠它

    // 4. RSA-SHA256 验签:ts、nonce、rawBody 三段各一个 \n,末尾也有一个
    String message = ts + "\n" + nonce + "\n" + rawBody + "\n";
    if (!rsaVerify(message, base64Decode(signature), cert.publicKey()))
        return fail("验签失败");

    // 5. AES-256-GCM 解密 resource(key = 32 字节 APIv3 密钥占位符)
    var event = aesGcmDecrypt(parse(rawBody).resource(), API_V3_KEY_PLACEHOLDER);

    // 6. 幂等:订单号 + 事件类型;先查状态机再落库
    if (!idemLock.tryLock(event.outTradeNo() + ":" + event.type()))
        return success();                       // 已在处理,重复通知
    var order = orderRepo.findByOutTradeNo(event.outTradeNo());
    if (order.state() != WAIT_PAY) return success();  // 状态机已推进
    tx(() -> orderRepo.markPaid(order, event.transactionId())); // 事务内落库

    // 7. 规范应答;重活全部丢进异步队列
    asyncQueue.publish(PAID_EVENT_TOPIC, event);
    return ResponseEntity.ok(Map.of("code", "SUCCESS", "message", "成功"));
}
// 脱敏伪代码:解密细节
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(DECRYPT_MODE,
    new SecretKeySpec(API_V3_KEY_PLACEHOLDER.bytes(), "AES"),
    new GCMParameterSpec(128, resource.nonce().bytes()));  // tag 128 位
c.updateAAD(resource.associatedData().bytes());            // AAD 不加密但必须传
byte[] plain = c.doFinal(base64Decode(resource.ciphertext())); // 末 16 字节为 tag

四、踩坑复盘:两个事故现场

现场一:证书轮换那天,"昨天还好好的"

一个普通的工作日上午,10 点 17 分,告警群开始弹验签失败,十分钟上百条:商户明明付款成功,订单全停在"待支付"。日志长这样(脱敏):

10:17:42 WARN  WxPayCallback - verify-fail serial=PLAT-****-B
10:17:42 WARN  WxPayCallback - local-certs=[PLAT-****-A]
10:17:43 WARN  WxPayCallback - reply FAIL, expect-retry

答案就在第二行:回调头里的序列号是 B,本地只有 A——微信切换了签名证书,而我们的证书只在启动时加载过一次。定位路径很短:验签失败 → 打印"请求的 serial"与"本地持有的 serial" → 对不上 → 结论是轮换。手动刷新一次证书列表,验签即恢复;随后把"按 Wechatpay-Serial 路由 + 找不到即刷新 + 仍无则告警"固化进代码。识别特征就一句:昨天还好好的,今天集中挂,失败点全在选证书一步。教训:排查靠的是日志里的信息,不是猜。

现场二:验签永远失败,逐项检查签名串

另一类坑更磨人:证书没拿错、时间戳没过期,验签就是不过。别乱试,把验签串按官方口径逐项对一遍:

  1. 三段顺序与分隔timestamp\nnonce\nbody\n,共三个换行,末尾那个最容易漏;
  2. timestamp:用请求头原值字符串,不转毫秒、不换成本地时间;
  3. nonce:头的原值,不要 trim、不要做 URL 解码;
  4. body:原始报文字节流,中间任何一次反序列化再序列化都会改变字节序列;
  5. 签名:Base64 解码后再送入 RSA 验签,别把字符串直接当签名值;
  6. 证书:确认拿到的是平台证书公钥,不是商户证书——前面五项全对,钥匙拿错也白搭。

我们那次的真实原因藏在一段好心的"优化"里:同事想让日志好看,把 body 解析成对象再序列化回去打印,顺手拿序列化结果去验签。JSON 字段顺序一变,签名立刻失配;回滚成原始报文直传,问题消失。这份清单后来贴在工位上,接手支付模块的人先抄一遍。

踩坑清单(条目 + 现象 + 定位方法)

  1. 拿商户 API 私钥验回调。现象:验签固定失败,与轮换、时间都无关。定位:查验签公钥来源——回调只能用平台证书公钥,两套密钥体系混用必错。
  2. 反序列化后再序列化去验签。现象:本地能过,线上全挂或偶发挂。定位:搜索回调 body 的 parse/stringify 链路,确保验签前只有原始字符串。
  3. 平台证书只加载一张、只加载一次。现象:某时间点起回调集中失败,日志见 serial 不匹配。定位:对比回调与本地 serial;修复即按 Wechatpay-Serial 路由加刷新兜底。
  4. 验签过了却解不开报文。现象:解密抛 GCM 异常,tag 校验失败。定位:九成是用错密钥(拿证书去解密)或漏传 associated_data;解密只用 APIv3 密钥,AAD 必须传。
  5. 回调里同步做重活导致超时。现象:同一 out_trade_no 短时间多次通知,线程耗时高。定位:看回调耗时分布,发货等重活移进异步队列,回调只留验签、落库、应答。

五、三句话总结

  1. 回调安全是两把钥匙一件事:平台证书(非对称)管验签,APIv3 密钥(对称)管解密,Wechatpay-Serial 决定用哪张证书。
  2. 微信的重试机制决定了回调必须幂等:订单号+事件类型做幂等键,先查状态机再落库,重复通知直接应答成功。
  3. 应答要快要规范:成功回 200 加 {"code":"SUCCESS"},重活异步化,别让超时引发重试风暴。

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

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

LET'S TALK

把方法用进你的生意

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

18601279913

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

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