在对接微信公众号支付时,支付结果通知验签正常、退款结果通知却一直提示签名验证失败,是不少后端开发者遇到过的麻烦。日志中通常只会留下“sign mismatch”或“签名校验不通过”之类的信息,但反复检查 API 密钥、参数排序和 XML 解析之后,问题依旧存在。很多时候真正的原因并不是密钥填错,而是验签时代码使用了错误的签名算法。

先把问题锁定在 sign_type 和商户平台配置上
如果支付结果通知验证通过,退款结果通知却验证失败,可以先按固定顺序排查:APIv2 密钥是否与商户平台一致、通知报文是否被完整读取、参数排序是否使用 ASCII 字典序、空值参数是否排除、XML 解析是否正确保留 CDATA 内容。这些步骤确认无误后,重点就应该转向签名算法本身。
微信支付退款结果通知与支付结果通知的签名规则基本一致,但退款通知的返回报文中有时不会明确给出 sign_type 字段。很多开发者习惯直接写死 MD5,这在商户平台配置为 MD5 时没有问题。一旦商户平台将 APIv2 密钥的签名方式改为 HMAC-SHA256,而本地代码仍然按 MD5 生成签名,就会稳定出现验签失败。此时排查密钥已经失去意义,因为相同参数下 MD5 和 HMAC-SHA256 得到的结果完全不同,前者是 32 位十六进制字符串,后者是 64 位十六进制字符串。
要确认这一点,可以登录微信支付商户平台,进入「账户中心」-「API安全」-「APIv2密钥」查看当前签名方式。如果这里显示的是 HMAC-SHA256,而代码里仍然使用 MD5,问题就非常明确了。另外,部分历史项目和线上环境在切换签名方式时没有同步更新代码,也会导致支付通知和退款通知表现不一致。
签名串构造与两种算法的核心差异
微信支付 APIv2 的签名串构造过程非常固定:先将参数名按照 ASCII 码从小到大排序,去掉值为空的参数,再将参数拼接为 key1=value1&key2=value2 的形式,最后追加 &key=APIv2密钥。这一串内容分别作为 MD5 或 HMAC-SHA256 的输入,最终结果转换为大写。需要特别注意的是,排序时只对参数名排序,而不是对整个键值对排序;值为空或未设置时不能参与签名,否则签名串会不一致。
MD5 与 HMAC-SHA256 虽然都是摘要算法,但不可互换。MD5 输出 32 位十六进制大写字符串,HMAC-SHA256 输出 64 位十六进制大写字符串。下面给出一个 Java 版本的签名生成函数,开发时可以直接使用,也可以与现有代码进行对比。
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.*;
public class WxRefundNotifyVerifier {
public static String generateSignature(Map<String, String> data, String key, String signType) throws Exception {
List<String> keys = new ArrayList<>(data.keySet());
Collections.sort(keys);
StringBuilder sb = new StringBuilder();
for (String k : keys) {
String value = data.get(k);
if (value == null || value.isEmpty()) {
continue;
}
if (sb.length() > 0) {
sb.append("&");
}
sb.append(k).append("=").append(value);
}
sb.append("&key=").append(key);
String content = sb.toString();
if ("HMAC-SHA256".equals(signType)) {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec spec = new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256");
mac.init(spec);
byte[] raw = mac.doFinal(content.getBytes("UTF-8"));
return bytesToHex(raw).toUpperCase();
}
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] raw = md.digest(content.getBytes("UTF-8"));
return bytesToHex(raw).toUpperCase();
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
退款结果通知的外层报文通常包含 return_code、appid、mch_id、nonce_str、req_info 以及 sign 等字段。其中 req_info 是加密后的退款详情,需要使用 AES-256-ECB 进行解密。签名校验的对象是包含 req_info 在内的外层完整报文,而不是解密后的 XML。因此正确顺序是先验证外层报文签名,再解密 req_info。如果把顺序反过来,或者只对解密后的内容做验签,同样会触发验签失败。
修复方案:用配置驱动算法选择,必要时兼容两种方式
最直接的修复方式是不要在代码中写死 MD5。可以在项目配置文件中增加类似 wx.pay.sign.type=HMAC-SHA256 的配置项,读取后传入验签方法。这样当商户平台切换签名方式时,只需要修改配置,不需要改动核心代码。下面的模拟调用展示了这个思路,同时加入兼容处理,避免通知中缺失 sign_type 时无法判断。
Map<String, String> notifyData = WxPayXmlUtil.xmlToMap(xmlString);
String signFromWx = notifyData.get("sign");
notifyData.remove("sign");
String signType = notifyData.get("sign_type");
if (signType == null || signType.isEmpty()) {
signType = "HMAC-SHA256"; // 优先使用商户平台实际配置的算法
}
String calculated = WxRefundNotifyVerifier.generateSignature(notifyData, apiV2Key, signType);
if (!calculated.equals(signFromWx)) {
// 兼容模式:如果当前配置不是 HMAC-SHA256,或通知实际仍使用 MD5,则再尝试 MD5
String calculatedMd5 = WxRefundNotifyVerifier.generateSignature(notifyData, apiV2Key, "MD5");
if (!calculatedMd5.equals(signFromWx)) {
throw new RuntimeException("退款结果通知签名验证失败");
}
}
如果项目中使用了微信支付官方 SDK,还需要注意 SDK 提供的验签方法是否默认使用了 MD5。很多旧版工具类在无 sign_type 时会直接走 MD5 分支,即使商户平台配置为 HMAC-SHA256 也会失败。此时可以升级 SDK,或者检查工具类源码,确保调用时显式传入 SignType.HMAC_SHA256。对于自定义封装,建议把签名算法作为参数传入,不要隐藏在公共方法内部。
兼容验签虽然能同时覆盖 MD5 和 HMAC-SHA256,但不要为了省事直接跳过验签,也不要因为一次失败就放弃验签。退款通知涉及资金变动,验签是保证报文未篡改的第一道防线。正确做法是:读取商户平台配置的签名方式作为首选算法,验证失败后再尝试另一种算法,两次都不匹配则拒绝处理并记录日志。
退款通知处理的完整顺序与稳定性建议
处理退款结果通知时,建议按照固定顺序执行:接收微信推送的 XML 报文,解析为参数映射,移除 sign 后生成签名并比对,验签通过后对 req_info 做 AES-256-ECB 解密,得到退款明细 XML,再解析其中的 refund_status、refund_fee 等字段进行业务更新。更新完成后需要给微信返回成功回执,否则微信会按照策略进行重复通知。最简单的返回报文如下。
<xml> <return_code><![CDATA[SUCCESS]]></return_code> <return_msg><![CDATA[OK]]></return_msg> </xml>
在具体实现中,还需要注意几个容易忽略的细节。首先,退款通知可能重复到达,业务层必须做幂等处理,例如根据 refund_id 或 out_refund_no 判断是否已经处理过。其次,解密 req_info 使用的密钥与验签使用的 APIv2 密钥是同一个字符串,但解密算法的填充方式为 PKCS7Padding,不同语言实现时要注意补齐逻辑。最后,日志中不要打印完整通知报文和 API 密钥,避免泄露敏感信息,尤其是签名失败时只记录错误原因和数据摘要即可。
测试阶段可以准备一份固定参数的模拟通知,分别用 MD5 和 HMAC-SHA256 计算出预期 sign,再验证本地代码在两种算法下是否都能正确处理。这样不需要频繁发起真实退款,也能快速定位算法分支问题。上线前还应确认配置项、密钥值和签名方式三者在测试环境与生产环境保持一致,避免环境切换后再次出现验签失败。

