微信支付回调的正确处理:验签、幂等与对账

admin 微信QQ技巧 2026-10-15 7 0

凌晨两点,财务说某几笔订单付了钱但系统里还是待支付。你翻日志,回调确实进来了,返回 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,重试会把请求量成倍放大并污染日志。所以要区分可重试与不可重试:解密失败、参数缺失属于不可重试,应返回成功并记录待人工处理。

依赖回调完成发货

把发货、发券、通知下游都塞在回调里同步执行,下游一慢回调就超时,超时又触发重复发货。回调只应做「记录、改状态、投递消息」,其余异步。

七、对账与补偿任务

补偿任务的设计要点是范围可控、动作幂等、结果可观测。

  1. 只扫状态为待支付且创建时间超过阈值的订单,别全表扫。
  2. 按批处理,每批几十条,带超时控制,避免把查询接口的频次打满。
  3. 查到已支付就复用同一套幂等逻辑推进状态,不要另写一份。
  4. 仍未支付且超过失效时间的,关闭订单并释放库存与优惠券。
  5. 持续失败的订单进异常队列并报警,别让它在后台无声堆积。

对账是最后一道防线:每天下载对账单与本地流水逐笔比对,找出「对方有、本地无」「本地有、对方无」以及金额不一致三类差异,差异表要能下钻到订单并支持补偿。

八、回调状态与处理动作对照

回调/查询状态处理动作
支付成功校验金额与商户号,幂等写流水,推进为已支付,投递发货消息
重复的成功通知唯一索引冲突或已是终态,直接返回成功,不重复发货
支付关闭或已撤销关闭订单,释放库存与优惠券
用户支付中返回成功,保持待支付,交给补偿任务
退款成功幂等推进退款单为成功,扣减余额,通知用户
验签失败不改状态,返回错误码并报警,触发证书刷新
解密失败视为不可重试,落异常表并返回成功,等人工核对
金额不一致拒绝推进状态,落差异表并报警
回调长期未达补偿任务主动查询;仍待支付则按超时关单

记住这个顺序:保存原始报文、验签、解密、核对业务字段、幂等写入、返回成功、异步处理。任何一步提前或缺失,都会在某个凌晨变成一笔对不上的账。