微信支付退款结果通知到达商户服务器后,验签失败是一个让很多开发者头疼的问题,但真正定位下来,高频原因并不是API密钥配置错误,而是构造签名原串时参数顺序或取值范围出了偏差。退款通知里除了常规的return_code、result_code,还会带一个很长的req_info加密串,验签对象始终是通知本身的字段,而不是解密后的商品订单或退款单数据。只要把解密后的内容混进签名原串,或者漏掉return_msg这类字段,哪怕密钥完全正确,算出的签名和微信下发的sign也一定对不上。

先回到微信支付API v2的签名约定:对回调参数中除sign以外的所有非空参数,按照参数名ASCII码从小到大排序,然后以key=value的形式用&符号连接,最后再拼接key=商户API密钥。这里有两个非常容易踩坑的地方:一是排序必须基于参数名,而不是参数出现的先后顺序;二是参与签名的参数值必须是原始通知值,不能做URL编码、HTML实体转换或Java字符串trim等处理。req_info这个字段尤其特殊,它的值长度可能超过一千字符,并且包含Base64字符,看起来像乱码,但必须原样参与拼接。
一、退款通知验签的原串到底长什么样
微信支付退款通知本质上是一个XML文档,字段名固定。商户服务端收到的是类似下面这样的XML结构,其中sign是微信计算好的签名,req_info是加密后的退款详情。验签的第一步是把sign字段排除掉,其余字段放入一个有序容器中。
实际的XML示例可以简化表示,但重点在于字段集合。我们需要把return_code、return_msg、result_code、appid、mch_id、nonce_str、req_info这几个字段取出来,按照参数名排序后拼接。排序规则是ASCII码逐字符比较,不是字典序里的中文习惯,所以return_code一定排在return_msg前面,result_code也排在return_code前面。如果字段值为空,比如return_msg有时会返回空字符串,这个字段就不参与签名。很多失败场景就是把空值也拼成了return_msg=&,比正确签名多了一个&,导致结果完全不同。
下面这段Java代码用来构造签名原串,可以直观看到排序和拼接过程。使用TreeMap来保证参数名有序,避免HashMap遍历顺序不确定造成签名偶发失败。
import java.util.Map;
import java.util.TreeMap;
public class WxPaySignUtil {
public static String buildSignSource(Map<String, String> params) {
Map<String, String> sorted = new TreeMap<>(params);
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sorted.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (value == null || value.isEmpty() || key.equals("sign")) {
continue;
}
sb.append(key).append("=").append(value).append("&");
}
sb.append("key=").append("你的APIv2密钥");
return sb.toString();
}
}
注意代码中key值为真实APIv2密钥时需要替换,不要硬编码在业务代码里。拼接完成后,再用MD5或按sign_type指定的算法对整串做摘要。如果通知没有传sign_type,微信支付API v2默认使用MD5。很多开发者在请求退款时用了HMAC-SHA256,回调通知里仍可能没有sign_type,此时验签要按MD5处理,这点容易混淆。
二、最容易写错的三个拼接细节
第一个错误是把req_info解密后的内容拿去做签名。req_info是BASE64编码后的AES密文,内部解密才包含退款单号、退款金额等明文。微信下发sign的计算范围仅包含req_info的原始密文值。解密操作属于验签后的步骤,如果本末倒置,先解密再拿解密结果替换req_info去拼串,必然失败。
第二个错误是字符串比较时使用了大小写不敏感的方法。sign本身是十六进制字符串,微信返回的sign可能是大写,也可能小写,本地计算出的MD5通常是全小写或全大写。比较时必须统一大小写,不能直接使用equals,而应使用equalsIgnoreCase,或者在比较前两者都转小写。曾有一个线上问题是本地用toLowerCase后的sign与微信sign比较,日志里看起来一样,但实际代码分支多执行了一次trim,导致首尾不可见字符影响了结果。
第三个错误是框架对XML解析时自动做了实体解码或空白裁剪。微信下发的req_info如果包含+号、等号、斜杠等Base64字符,XML解析器不会改动它们,但有些自研的xml转map工具会调用URLDecoder,把+转成空格,这会让签名原串的req_info值变样。正确做法是读取原始XML节点文本后不要做URL解码、HTML解码或正则清理。
下面给一个PHP版本的验签辅助方法,用来比对签名原串和sign。代码中特意先排除sign再排序,并对空值直接跳过。PHP数组排序通常用ksort,这里需要注意ksort默认按字符串键排序,符合ASCII规则。
function buildWxSignSource(array $data, string $apiKey): string
{
unset($data['sign']);
ksort($data);
$parts = [];
foreach ($data as $key => $value) {
if ($value === '' || $value === null) {
continue;
}
$parts[] = $key . '=' . $value;
}
$parts[] = 'key=' . $apiKey;
return implode('&', $parts);
}
$source = buildWxSignSource($noticeData, $apiKey);
$localSign = strtoupper(md5($source));
$remoteSign = strtoupper($noticeData['sign']);
if (hash_equals($localSign, $remoteSign)) {
// 验签通过
}
PHP中hash_equals可以避免时序攻击,比直接使用双等号更严谨。这里同样要注意,构造签名原串时不要手动给值加引号,也不要对值做任何编码转换。
三、把验签和req_info解密拆开排查
接到退款通知后,正确的处理顺序是先验签、后解密。验签只依赖通知原始字段,解密只依赖req_info和API密钥的MD5值。如果将两者混在一个方法里,出错时很难判断是签名错了还是AES密钥错了。建议在回调入口记录三类日志:原始XML、排序后的参数名列表、构造出的签名原串,其中原串里的key部分用星号替代。这样收到一次失败通知就能快速比对。
req_info的解密算法是AES-256-ECB,密钥不是API密钥本身,而是API密钥的MD5字符串的小写形式。使用Java解密时,Cipher实例算法写成AES/ECB/PKCS5Padding,密钥长度必须是32字节,刚好是MD5结果的32个十六进制字符。解密出的是XML字符串,里面才是退款状态和金额信息。这里再强调一次,解密失败不会导致验签失败,如果验签已经失败,就不需要继续解密;如果解密失败但验签通过,问题才出在req_info的数据完整性与解密参数上。
下面是一段Java解密req_info的方法,方便与验签函数对比。注意Base64解码时不要使用URL Safe模式,因为微信返回的是标准Base64。
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
public class WxRefundDecryptUtil {
public static String decryptReqInfo(String reqInfo, String apiKey) throws Exception {
MessageDigest md5 = MessageDigest.getInstance("MD5");
byte[] keyBytes = md5.digest(apiKey.getBytes(StandardCharsets.UTF_8));
StringBuilder keyHex = new StringBuilder();
for (byte b : keyBytes) {
keyHex.append(String.format("%02x", b));
}
SecretKeySpec keySpec = new SecretKeySpec(keyHex.toString().getBytes(StandardCharsets.UTF_8), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] encryptedData = Base64.getDecoder().decode(reqInfo);
byte[] decryptedData = cipher.doFinal(encryptedData);
return new String(decryptedData, StandardCharsets.UTF_8);
}
}
这段代码中keyHex必须使用小写,否则会生成另一个AES密钥,解密时抛异常。线上出现过的兼容问题是JDK版本较旧时AES/ECB/PKCS5Padding可能不支持,可以改成AES/ECB/PKCS7Padding,但JDK默认provider通常支持PKCS5Padding,因为AES块大小固定,两者行为等价。
四、用最小化例子验证自己的拼接逻辑
如果怀疑自己代码的排序或拼接有问题,不必反复触发真实退款,可以用一组固定参数和固定密钥算一遍sign,然后与微信官方文档中的示例或在线工具结果对比。微信支付API v2的签名算法是公开的,很多SDK里也有测试用例。关键是把拼出来的原串打印出来,肉眼确认每个字段的顺序是否正确。
例如收到一条通知,字段值分别为return_code=SUCCESS、result_code=SUCCESS、appid=wx123、mch_id=19000001、nonce_str=abc、req_info=encrypted_value,return_msg为空。正确的原串应该是:appid=wx123&mch_id=19000001&nonce_str=abc&req_info=encrypted_value&result_code=SUCCESS&return_code=SUCCESS&key=your_api_key。注意这里没有return_msg,因为它为空。如果某段代码把return_msg也拼进去,顺序会变成appid=wx123&mch_id=19000001&nonce_str=abc&req_info=encrypted_value&result_code=SUCCESS&return_code=SUCCESS&return_msg=&key=your_api_key,后面的key前面会多一个&,MD5完全不同。
排查时还要检查是否漏掉了appid或mch_id这两个字段。有些同学以为签名只需要req_info和return_code,这是错误理解。微信支付退款通知的签名是针对整包参数做的,appid、mch_id、nonce_str缺一不可。少拼一个字段就像改动了整个签名原串,失败是必然的。
五、上线后的监控与防御性编码
退款通知属于资金相关回调,验签通过后还需要校验商户号、AppID是否与自己的配置一致,防止拿不同商户的通知伪造回执。解密后的req_info里也有mch_id和appid,可以再跟通知外层做一次交叉校验。这样即使签名原串拼接正确,业务层也多一层保护。
在业务代码里尽量把验签和解析封装成单独模块,所有收到通知的入口只调用一个方法。不要在每个Controller里复制验签代码,否则某个版本改了排序逻辑,其他入口还停留在旧实现,容易出现偶发失败。签名串拼接顺序最好使用有序Map,不要依赖JSON对象或普通HashMap的遍历稳定性。
最后还要注意密钥的安全管理。APIv2密钥一旦泄露,签名保护就形同虚设。日常打印日志时,签名原串中的key必须脱敏。建议使用配置中心读取密钥,不要在源码仓库中提交。验签失败和验签通过的日志要分开记录,失败日志保留原始XML和脱敏后的签名原串,便于后续定位。

