微信支付的回调通知体系里,普通支付结果通知和退款结果通知走的是两套完全不同的安全机制。不少开发者已经顺利跑通了支付通知的验签,到了退款通知这里却反复踩坑:明明商户证书、API密钥都没问题,验签就是失败。其中一个高频原因是签名类型用错了,退款通知的req_info字段使用的是MD5签名密钥派生的解密密钥,而不是和普通通知一样直接用APIv2密钥做HMAC-SHA256或MD5验签。下面我们把整个机制拆开讲清楚。

一、退款通知和支付通知的安全机制差异
先看普通支付结果通知的验签方式。微信会在返回的XML里附带sign字段,验签时需要把所有非sign字段按key的ASCII码排序,拼接成key1=value1&key2=value2的字符串,再在末尾拼上&key=APIv2密钥,根据sign_type选择MD5或HMAC-SHA256计算摘要,与sign字段比对。这是开发者最熟悉的一套流程。
退款结果通知的结构却不一样。它的XML里只有四个字段:return_code、appid、mch_id,以及最关键的req_info。注意这里没有sign字段,也就是说退款通知本身不参与普通的排序验签流程。真正需要验证的是req_info这个加密字段,它的加密流程为:先对商户密钥做MD5得到32位小写字符串,再将该字符串转换为大写,以此作为AES-256-ECB的密钥,对退款结果报文做PKCS7填充后加密,最后Base64编码。
所以验签失败的第一个典型误区就是:拿到退款通知后,仍然按支付通知的老办法对所有字段排序拼接后做MD5比对。由于通知里压根没有sign字段,自然无论如何都验不过。正确的理解是,解密成功本身就等价于验证了数据完整性——因为解密密钥只有持有商户密钥的双方才知道。
二、正确的解密与验证流程详解
解密的完整步骤分为四步。第一步,对商户平台的APIv2密钥执行MD5,得到32位小写摘要;第二步,把这个摘要全部转为大写;第三步,用这32个大写字符作为密钥,对Base64解码后的req_info数据做AES-256-ECB解密;第四步,去掉PKCS7填充,得到UTF-8编码的XML明文,里面包含out_refund_no、refund_status、refund_fee等退款明细字段。
很多语言默认的AES库不会自动去除PKCS7填充,解密出来会在明文末尾出现一串填充字节,导致XML解析失败。这也是被误判为验签失败的另一个常见来源。下面以Java为例给出完整实现:
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Base64;
public class RefundNotifyDecoder {
/**
* 解密退款通知中的 req_info 字段
* @param reqInfo 微信通知里的加密字符串
* @param apiV2Key 商户平台设置的APIv2密钥
*/
public static String decrypt(String reqInfo, String apiV2Key) throws Exception {
// 第一步:对商户密钥做MD5,得到32位小写字符串
MessageDigest md5 = MessageDigest.getInstance("MD5");
byte[] digest = md5.digest(apiV2Key.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
String hex = Integer.toHexString(b & 0xFF);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
// 第二步:转为大写,作为AES-256-ECB的密钥
String aesKey = sb.toString().toUpperCase();
// 第三步:Base64解码后用AES/ECB/PKCS5Padding解密
// PKCS5Padding在实现上兼容PKCS7,JDK会自动去除填充
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKeySpec keySpec = new SecretKeySpec(aesKey.getBytes("UTF-8"), "AES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(reqInfo));
// 第四步:得到UTF-8编码的退款明文XML
return new String(decrypted, "UTF-8");
}
}
PHP开发者如果使用openssl系列函数,需要注意openssl_decrypt要显式传入OPENSSL_RAW_DATA标志,否则函数会按Base64处理输入,而我们已经手动解码过了,结果就会出错。密钥同样是通过strtoupper(md5($apiV2Key))生成,这一点三种语言下完全一致。
三、APIv3退款通知为什么又不一样了
如果你的商户号已经切到APIv3,退款通知的验签规则又变了:微信会使用平台证书对通知报文做数字签名,签名算法是RSA-SHA256,签名串的构造是按固定顺序拼接时间戳、随机串和报文体,放在HTTP头Wechatpay-Signature中。验签时要用微信支付平台证书的公钥(不是商户私钥,也不是APIv3密钥),而报文体本身是用APIv3密钥做AES-256-GCM解密的,其中对称解密的key直接就是APIv3密钥,nonce在报文JSON里,additional_data为空字符串。
这三种机制混合使用是最容易出错的场景。排查时可以按下面的清单逐项确认:
- 确认商户号当前用的是APIv2还是APIv3,两套通知的报文格式完全不同,v2是XML,v3是JSON;
- v2退款通知不要做排序验签,直接走MD5派生密钥加AES-256-ECB解密流程;
- 密钥的大写转换不能遗漏,小写MD5直接当AES密钥会抛出InvalidKeyException;
- 解密后若无法解析XML,检查是否漏掉了PKCS7去填充;
- v3退款通知验签失败时,优先检查是否误用了商户私钥而不是平台证书公钥。
还有一个容易忽视的点:商户平台修改APIv2密钥后,已发出的退款通知重试仍然用旧密钥加密,如果回调处理程序正好在改密窗口期收到通知,解密会失败。这种情况下应返回失败让微信择机重推,而不是直接丢弃。处理好这些细节,退款通知的验签问题基本都能彻底解决。