微信支付的退款结果通知是回调体系里比较特殊的一类。它不像普通支付成功通知那样直接把业务数据明文放在报文里,而是把退款详情加密后放在resource字段中,开发者需要先用微信支付平台证书验证整个报文的签名,再用商户API证书对应的密钥对resource做AES-256-GCM解密。不少人在处理退款通知时验签总是失败,或者解密时抛出异常,根源往往是对这套双重机制理解不到位。本文以v3版本接口为例,完整讲解Node.js环境下的验签与解密实现。

一、退款结果通知的报文结构与验签原理
微信支付在退款状态变化时,会向商户配置的回调地址推送POST请求,报文主体是JSON格式。典型的退款通知结构如下:
{
"id": "EV-2018022511223320873",
"event_type": "REFUND.SUCCESS",
"resource_type": "encrypt-resource",
"summary": "退款成功通知",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "基base64编码的密文",
"associated_data": "transaction",
"original_type": "refund",
"nonce": "随机字符串"
}
}HTTP请求头中包含三个关键字段:Wechatpay-Timestamp(时间戳)、Wechatpay-Nonce(随机串)和Wechatpay-Signature(Base64编码的签名)。验签的核心逻辑是:把请求体原文、时间戳、随机串按固定顺序拼接成一个字符串,再用微信支付平台证书的公钥做SHA256-RSA验签。这里有个非常容易踩的坑——参与验签的必须是请求体的原始字节,如果你的Web框架在到达业务代码之前做了任何字符串转换(比如编码转换、JSON解析后再序列化),拼接出来的内容就与签名时的原文不一致,验签必然失败。
另外要注意区分平台证书和商户API证书这两个概念。商户API证书是你自己申请的,用于请求微信接口时签名;平台证书是微信官方的,用于验证微信发来的通知。平台证书可以通过微信的证书下载接口获取,也可以使用微信提供的公钥模式。生产环境建议定期刷新并缓存证书,避免证书轮换后验签突然失败。
二、Node.js验签与解密完整代码实现
下面给出一段可直接运行的完整代码,基于Express框架和Node.js内置的crypto模块,不依赖第三方SDK。首先是接收原始请求体的配置:
const express = require('express');
const crypto = require('crypto');
const app = express();
// 必须用raw类型接收请求体,保证验签使用的是原始字节
app.use(express.raw({ type: '*/*', limit: '2mb' }));
// 平台证书公钥(PEM格式),生产环境应从证书下载接口动态获取
const PLAToFORM_PUBLIC_KEY = `-----BEGIN CERTIFICATE-----
MIIDxxxxx...(平台证书内容)
-----END CERTIFICATE-----`;
// 商户API证书对应的密钥v3密钥,32字节字符串
const API_V3_KEY = '你的32位APIv3密钥';
// 验证签名
function verifySignature(body, timestamp, nonce, signature) {
// 按官方规则拼接待验签字符串
const message = `${timestamp}\n${nonce}\n${body}\n`;
const signatureBuffer = Buffer.from(signature, 'base64');
return crypto.createVerify('RSA-SHA256')
.update(message, 'utf8')
.verify(PLATFORM_PUBLIC_KEY, signatureBuffer);
}
// AES-256-GCM解密resource
function decryptResource(nonce, associatedData, ciphertext) {
const buf = Buffer.from(ciphertext, 'base64');
const authTag = buf.subarray(buf.length - 16); // 最后16字节是认证标签
const data = buf.subarray(0, buf.length - 16); // 前面部分是密文
const decipher = crypto.createDecipheriv('aes-256-gcm', API_V3_KEY, nonce);
decipher.setAuthTag(authTag);
if (associatedData) decipher.setAAD(Buffer.from(associatedData));
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
}上面的代码有两个关键点需要展开说明。第一是验签字符串的拼接格式,三部分之间用换行符\n分隔,且末尾也有一个换行符,这是官方文档明确规定但经常被忽略的细节。第二是GCM解密时认证标签的提取:密文Base64解码后,最后16个字节是authTag,其余才是真正的密文数据。如果用setAuthTag时传入了错误长度的标签,会直接抛出认证失败的异常。
接下来是路由处理部分,完成验签、解密并返回应答:
app.post('/refund/notify', (req, res) => {
const body = req.body.toString('utf8');
const timestamp = req.get('Wechatpay-Timestamp');
const nonce = req.get('Wechatpay-Nonce');
const signature = req.get('Wechatpay-Signature');
const serial = req.get('Wechatpay-Serial');
// 1. 验证签名,serial可用于匹配多张平台证书
if (!verifySignature(body, timestamp, nonce, signature)) {
console.error('验签失败');
return res.status(401).json({ code: 'FAIL', message: '验签失败' });
}
// 2. 解析报文并解密resource
const notify = JSON.parse(body);
const { ciphertext, nonce: resNonce, associated_data: aad } = notify.resource;
const refundData = JSON.parse(
decryptResource(resNonce, aad, ciphertext)
);
// 3. 业务处理:根据退款状态更新订单
console.log('退款单号:', refundData.out_refund_no);
console.log('退款状态:', refundData.refund_status);
// 4. 返回成功应答,微信才会停止重推
res.json({ code: 'SUCCESS', message: '成功' });
});
app.listen(3000, () => console.log('监听退款通知:3000'));三、常见问题与生产环境注意事项
第一类问题是验签始终返回false。除了前面提到的请求体被修改之外,还要检查是否用错了证书——用商户证书的公钥去验签是常见错误,两者公钥并不相同。此外时间戳拼接时不要自行转成毫秒,微信传的是秒级时间戳,直接使用即可。建议同时校验时间戳与当前时间的差值(一般允许5分钟内),防止重放攻击。
第二类问题是解密抛出异常。排查方向包括:APIv3密钥是否正确(注意是商户平台设置的32位密钥,不是证书密钥文件)、nonce是否取自resource内部而不是请求头的nonce、associated_data是否正确传入。很多开发者把请求头的Wechatpay-Nonce误当成解密用的nonce,这是两个完全不同的值。
第三类是重复通知的处理。微信在未收到成功应答时会按衰减频率重试多次,业务代码必须做好幂等:解密出的退款单号out_refund_no和通知IDnotify.id都可以作为幂等键,处理前先查库判断是否已处理过。另外多实例部署时要注意证书缓存的共享与刷新,可以定时(例如每12小时)调用证书下载接口更新本地证书列表,根据请求头的Wechatpay-Serial匹配使用对应证书,这样即使微信轮换证书也不会导致线上验签故障。完成上述几点,退款通知的完整闭环就稳妥了。
微信支付退款通知Node.js签名验证AES-256-GCM解密修改时间:2026-09-01 17:02:35