做微信支付对接,下单、查单都不难,最容易栽跟头的是回调。我们做多租户小程序 SaaS(帮线下商户生成专属小程序),支付域是 27 个业务域之一,这条链路上踩过的坑,基本就是社区反复提问的那几类。这篇把 APIv3 回调从验签到应答一次讲透,重点放在两处:为什么这么设计,坑发作时怎么按日志定位。
一、背景与坑:为什么栽跟头的总是回调
APIv2 时代回调就是"拿密钥拼串做 MD5",一把密钥走天下。APIv3 换成完整安全体系:请求要签名、回调要验签、报文要解密,涉及两套密钥、多张证书。翻车高发区有三处:
证书轮换。 平台证书会定期轮换,且新旧并行期共存。若只在启动时加载一张证书,某天微信换用新证书发回调,序列号对不上,验签全挂——而且是"昨天还好好的"这种挂法。
序列号不匹配。 回调头 Wechatpay-Serial 标明"本次用哪张平台证书签名"。很多实现无视这个头,永远拿固定那张证书验,轮换一到就翻车。
验签通过但解不开报文。 验签用平台证书公钥(非对称),解密用 APIv3 密钥(对称,32 字节)。有人验签过了却拿证书去解 resource,或 associated_data 没参与运算,GCM 认证直接抛异常——两把钥匙,各管各的。
二、完整流程:验签 → 解密 → 幂等 → 应答
1. 验签:平台证书 + Wechatpay-Serial
回调 POST 带四个关键头:Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial。验签三步走:
- 防重放:时间戳与当前偏差超过 5 分钟(300 秒)直接拒绝;
- 选证书:按
Wechatpay-Serial在本地证书列表找对应证书,找不到先刷新证书列表再找; - 验签名:拼接验签串
timestamp\nnonce\nbody\n(三段、末尾带换行),用平台证书公钥做 RSA-SHA256 验签,签名为 Base64。
为什么这么设计:平台证书和商户证书的分工。 APIv3 里有两条方向相反的信任链:商户私钥只给我方发出的请求签名,向微信证明"请求是我发的";平台证书的私钥在微信手里,只给它发出的回调签名,向商户证明"回调确实是微信发的"。双方各持私钥、互拿公钥验签,谁也不用交出私钥。所以验回调绝不能拿商户证书——方向反了,等于拿自己的公钥验自己。想通分工,"该用哪把钥匙"不再靠背。
特别强调:body 必须用原始报文字符串参与验签,先反序列化再序列化,字段顺序一变必挂。
2. 解密:AES-256-GCM
验签过后解析 JSON,业务数据在 resource 里:ciphertext(Base64)、nonce、associated_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 路由 + 找不到即刷新 + 仍无则告警"固化进代码。识别特征就一句:昨天还好好的,今天集中挂,失败点全在选证书一步。教训:排查靠的是日志里的信息,不是猜。
现场二:验签永远失败,逐项检查签名串
另一类坑更磨人:证书没拿错、时间戳没过期,验签就是不过。别乱试,把验签串按官方口径逐项对一遍:
- 三段顺序与分隔:
timestamp\nnonce\nbody\n,共三个换行,末尾那个最容易漏; - timestamp:用请求头原值字符串,不转毫秒、不换成本地时间;
- nonce:头的原值,不要 trim、不要做 URL 解码;
- body:原始报文字节流,中间任何一次反序列化再序列化都会改变字节序列;
- 签名:Base64 解码后再送入 RSA 验签,别把字符串直接当签名值;
- 证书:确认拿到的是平台证书公钥,不是商户证书——前面五项全对,钥匙拿错也白搭。
我们那次的真实原因藏在一段好心的"优化"里:同事想让日志好看,把 body 解析成对象再序列化回去打印,顺手拿序列化结果去验签。JSON 字段顺序一变,签名立刻失配;回滚成原始报文直传,问题消失。这份清单后来贴在工位上,接手支付模块的人先抄一遍。
踩坑清单(条目 + 现象 + 定位方法)
- 拿商户 API 私钥验回调。现象:验签固定失败,与轮换、时间都无关。定位:查验签公钥来源——回调只能用平台证书公钥,两套密钥体系混用必错。
- 反序列化后再序列化去验签。现象:本地能过,线上全挂或偶发挂。定位:搜索回调 body 的 parse/stringify 链路,确保验签前只有原始字符串。
- 平台证书只加载一张、只加载一次。现象:某时间点起回调集中失败,日志见 serial 不匹配。定位:对比回调与本地 serial;修复即按
Wechatpay-Serial路由加刷新兜底。 - 验签过了却解不开报文。现象:解密抛 GCM 异常,tag 校验失败。定位:九成是用错密钥(拿证书去解密)或漏传
associated_data;解密只用 APIv3 密钥,AAD 必须传。 - 回调里同步做重活导致超时。现象:同一
out_trade_no短时间多次通知,线程耗时高。定位:看回调耗时分布,发货等重活移进异步队列,回调只留验签、落库、应答。
五、三句话总结
- 回调安全是两把钥匙一件事:平台证书(非对称)管验签,APIv3 密钥(对称)管解密,
Wechatpay-Serial决定用哪张证书。 - 微信的重试机制决定了回调必须幂等:订单号+事件类型做幂等键,先查状态机再落库,重复通知直接应答成功。
- 应答要快要规范:成功回 200 加
{"code":"SUCCESS"},重活异步化,别让超时引发重试风暴。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
