微信支付回调的正确处理:验签、幂等与对账
凌晨两点,财务说某几笔订单付了钱但系统里还是待支付。你翻日志,回调确实进来了,返回 500,原因是解密那步抛了异常。而在此之前你已经把订单改成「已支付」写进数据库——状态判断写在验签之前,这个顺序问题让你既没能正确应答,又留下一批状态不明的订单。支付回调不是「收到就改状态」,它需要按顺序演进。
一、回调不是唯一的事实来源
先摆正一个认知:回调是通知,不是权威数据源。它可能延迟、可能重复、可能因为服务挂了而根本没到。权威的是主动查询接口。
- 回调负责「快」。用户付完钱一两秒内就能更新状态、发货。
- 主动查询负责「准」。用户返回页面后的兜底确认,以及定时补偿捞丢失的回调。
- 两者共用同一套状态机与幂等锁,谁先到谁生效,后到的发现已是终态就直接返回成功。
只要系统里有这条兜底链路,单次回调失败就不会变成资金事故。
二、回调报文长什么样
主流版本的回调是信封式结构:外层是若干标识字段,真正的业务数据加密后放在 resource 里。不解密就看不到订单号、金额、支付状态。
{
"id": "EV-xxxxx",
"event_type": "TRANSACTION.SUCCESS",
"resource_type": "encrypt-resource",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "密文",
"associated_data": "transaction",
"nonce": "随机串",
"original_type": "transaction"
}
}
几个必须理解的点:id 是这条通知的唯一编号,可用来去重;event_type 是事件类型;ciphertext 是密文,需要解密才是订单详情;associated_data 与 nonce 是解密必需的参数。别想着从外层读出金额——summary 只是给人看的描述。
三、验签与解密的完整步骤
第一步:读原始报文
验签必须基于原始的、未经任何处理的请求体文本。很多框架会在你拿到 body 之前做解析或转码,重新序列化再验签一定失败,要在最早期保存原始字节。
第二步:验签
请求头里带时间戳、随机串、签名,以及一个证书序列号。用序列号找到对应平台证书的公钥来验签。平台证书有有效期,需要定期更新并在本地缓存;序列号可能不是你缓存里的那一个,遇到失败要触发一次证书拉取,而不是直接丢弃请求。
const message = `${timestamp}\n${nonce}\n${rawBody}\n`;
const ok = crypto.verify(
'RSA-SHA256',
Buffer.from(message),
platformPublicKey,
Buffer.from(signature, 'base64')
);
if (!ok) return res.status(401).send('invalid signature');
第三步:解密 resource
用商户平台配置的密钥做 AEAD_AES_256_GCM 解密,nonce 与 associated_data 从 resource 里取。解密后得到 out_trade_no、transaction_id、trade_state 等字段。解密失败通常意味着密钥不对或取错字段,不要无限重试,记日志并报警。
第四步:业务处理
到这里才允许动数据库。核对金额是否与订单原始金额一致、商户号与 appid 是否匹配。虽然已验签,业务层核对仍然要有。
四、幂等不是可选项
支付侧会在多种情况下重复推送同一条通知:服务超时、非 200 应答、连接中断,甚至只是响应慢了一点。这不是异常,是设计上的重试,处理必须能被重复执行而结果不变。
幂等键怎么选
两个候选:out_trade_no(你生成的商户订单号)与 transaction_id(支付侧单号)。建议用 out_trade_no 做主幂等键,它由你生成、天然唯一,两条链路都能拿到。transaction_id 做辅助校验,确认没有被不同支付单重复支付。
实现方式
最可靠的是唯一索引加状态机。给支付流水表加唯一索引,插入冲突说明处理过;订单状态机只允许从「待支付」到「已支付」这一条边,重复进入时发现已是终态就直接返回成功。不要用「先查再写」这种非原子判断,并发下会漏。
try {
await db.insert('payment_records', {
out_trade_no, transaction_id, amount, status: 'paid'
});
} catch (e) {
if (isDuplicateKey(e)) return successResponse(); // 已处理过
throw e;
}
// 只有待支付才能变已支付,affected 为 0 也是幂等成功
const affected = await db.update('orders')
.set({ status: 'paid' })
.where({ out_trade_no, status: 'unpaid' });
五、应答格式必须严格
处理成功返回 HTTP 200,且响应体是约定的 JSON。只返回 200 不带正确 body,或者返回一个 HTML 页面,都可能被认为失败从而触发重试。失败时返回非 200 并在 body 里给出错误码与描述。
// 成功
HTTP/1.1 200 OK
{ "code": "SUCCESS", "message": "成功" }
// 失败,请对方重试
HTTP/1.1 500 Internal Server Error
{ "code": "FAIL", "message": "处理异常,请重试" }
别在网关层把回调写成 302,也别因登录态校验拦成 401。
六、四类常见事故
把订单状态判断写在验签之前
最危险的一种。有人在验签前就把订单改成已支付,想着「反正后面会验」。结果伪造请求能直接把订单变已支付,或者验签失败后留下错误记录。铁律是:验签与解密全部通过之前,不改任何持久化状态。
网络抖动导致漏单
服务重启、容器滚动更新、连接池打满,任何一个都可能让回调在重试窗口内全部失败,之后就没有下一次了。所以必须有定时补偿任务。
回调地址返回 5xx 会被持续重试
这本身是好事,但会放大问题。如果处理逻辑里有一个必然失败的 bug,重试会把请求量成倍放大并污染日志。所以要区分可重试与不可重试:解密失败、参数缺失属于不可重试,应返回成功并记录待人工处理。
依赖回调完成发货
把发货、发券、通知下游都塞在回调里同步执行,下游一慢回调就超时,超时又触发重复发货。回调只应做「记录、改状态、投递消息」,其余异步。
七、对账与补偿任务
补偿任务的设计要点是范围可控、动作幂等、结果可观测。
- 只扫状态为待支付且创建时间超过阈值的订单,别全表扫。
- 按批处理,每批几十条,带超时控制,避免把查询接口的频次打满。
- 查到已支付就复用同一套幂等逻辑推进状态,不要另写一份。
- 仍未支付且超过失效时间的,关闭订单并释放库存与优惠券。
- 持续失败的订单进异常队列并报警,别让它在后台无声堆积。
对账是最后一道防线:每天下载对账单与本地流水逐笔比对,找出「对方有、本地无」「本地有、对方无」以及金额不一致三类差异,差异表要能下钻到订单并支持补偿。
八、回调状态与处理动作对照
| 回调/查询状态 | 处理动作 |
|---|---|
| 支付成功 | 校验金额与商户号,幂等写流水,推进为已支付,投递发货消息 |
| 重复的成功通知 | 唯一索引冲突或已是终态,直接返回成功,不重复发货 |
| 支付关闭或已撤销 | 关闭订单,释放库存与优惠券 |
| 用户支付中 | 返回成功,保持待支付,交给补偿任务 |
| 退款成功 | 幂等推进退款单为成功,扣减余额,通知用户 |
| 验签失败 | 不改状态,返回错误码并报警,触发证书刷新 |
| 解密失败 | 视为不可重试,落异常表并返回成功,等人工核对 |
| 金额不一致 | 拒绝推进状态,落差异表并报警 |
| 回调长期未达 | 补偿任务主动查询;仍待支付则按超时关单 |
记住这个顺序:保存原始报文、验签、解密、核对业务字段、幂等写入、返回成功、异步处理。任何一步提前或缺失,都会在某个凌晨变成一笔对不上的账。







