微信支付退款结果通知的验签逻辑本身并不复杂,真正容易出错的是密钥来源。你拿着计算出来的MD5去和微信下发的sign对比,只要比对不上,系统就会判定签名验证失败,但这不一定代表排序、拼接或哈希函数有误。退款通知属于微信支付v2接口的异步回调,外层验签和内层req_info解密都依赖同一个32位API密钥。如果这个密钥被误填成AppSecret、APIv3密钥或其他凭据,即使代码和微信官方示例完全一致,也会稳定验签失败。

一、退款结果通知里的两层数据
微信支付v2的退款结果通知以XML形式推送到商户服务器,典型字段包括return_code、appid、mch_id、nonce_str、req_info和sign。其中req_info是加密后的退款详情,不能直接读取;sign是微信对整个通知参数做的签名,商户需要先验签确认报文确实来自微信,再解密req_info处理退款状态。
这里要强调一个容易忽视的点:外层签名把req_info作为普通参数参与拼接,并不会先解密再签。验签时任何对req_info的改写、解密、URL解码,都会改变参与签名的原值,最终导致计算出的摘要与微信下发的sign不一致。因此处理回调的第一条原则是,必须基于微信POST过来的原始参数集合验签。
签名字符串的生成规则如下:剔除sign字段,过滤参数值为空的项,按参数名ASCII码从小到大排序,再拼成key=value&key2=value2这种形式,最后追加&key=商户API密钥。如果商户平台配置的是MD5,就对整串做MD5并转大写;如果配置的是HMAC-SHA256,则使用同样拼接后的字符串做HMAC-SHA256并转大写。
<xml> <return_code><![CDATA[SUCCESS]]></return_code> <appid><![CDATA[wx1234567890abcdef]]></appid> <mch_id><![CDATA[1900000109]]></mch_id> <nonce_str><![CDATA[5K8264ILTKCH16CQ2502SI8ZNMTM67VS]]></nonce_str> <req_info><![CDATA[此处是一长串加密后的Base64文本]]></req_info> <sign><![CDATA[9F4E...]]></sign> </xml>
这段XML只是示意结构,重点在于sign是外层字段,req_info是密文。验签通过之前,不要把req_info拿去解密,否则会浪费排查时间。
二、用错API密钥是最典型的失败原因
微信公众号支付体系内的密钥不止一套。公众号有AppSecret,微信支付v2有API密钥,微信支付v3有APIv3密钥,商户还有API证书私钥和平台证书公钥。它们长度可能看起来相似,但用途完全不同。退款结果通知属于v2报文,只能使用商户平台里配置的32位API密钥。这个密钥在商户平台的位置通常叫API密钥,而不是APIv3密钥,也不是公众号开发者密码。
| 凭据名称 | 常见用途 | 是否可用于退款通知验签 |
|---|---|---|
| API密钥 | v2支付、退款、回调验签、req_info解密 | 是 |
| APIv3密钥 | v3回调报文AES-256-GCM解密 | 否 |
| AppSecret | 公众号网页授权、用户信息 | 否 |
| 商户API证书私钥 | v3请求签名 | 否 |
| 微信支付平台证书公钥 | 验证v3应答签名 | 否 |
如果误把APIv3密钥传入v2验签函数,拼出来的字符串没有任何异常,只是最后MD5结果与微信通知对不上。尤其当APIv3密钥和API密钥都是32位字符串时,肉眼很难第一时间发现问题。另一个常见情况是多商户号并存,支付使用A商户号,退款通知里的mch_id是B商户号,但系统只配置了A商户号的API密钥,也会稳定验签失败。
还有一种混淆发生在退款解密环节。微信支付v2退款通知的req_info使用AES-256-ECB解密,密钥同样是API密钥。如果验签通过了,但解密出来是乱码或openssl_decrypt直接返回false,就要检查是不是把APIv3密钥或AppSecret传进了AES解密函数。验签和解密共用同一个API密钥,这是v2退款通知和v3回调最大的区别之一。
三、PHP验签与解密代码
下面这段PHP代码演示了v2通知的验签过程。它首先取出sign并从参数集合中移除,再对剩余参数排序、过滤空值、拼接字符串,最后计算MD5或HMAC-SHA256。使用时传入的参数必须是微信POST的原始键值对,不要先做XML转对象后再拼字符串,因为有些解析库会改变参数顺序,虽然排序后顺序通常一致,但空值和CDATA处理可能引入差异。
function verifyWxPayNotify(array $data, string $apiKey, string $signType = 'MD5'): bool
{
$receivedSign = $data['sign'] ?? '';
unset($data['sign']);
ksort($data);
$parts = [];
foreach ($data as $k => $v) {
if ($v === '' || $v === null) {
continue;
}
$parts[] = $k . '=' . $v;
}
$str = implode('&', $parts);
$str .= '&key=' . $apiKey;
if (strtoupper($signType) === 'HMAC-SHA256') {
$expect = strtoupper(hash_hmac('sha256', $str, $apiKey));
} else {
$expect = strtoupper(md5($str));
}
return hash_equals(strtoupper($receivedSign), $expect);
}
验签通过后,可以继续解密req_info。解密算法是AES-256-ECB,密钥为同一个API密钥,密文先做Base64解码,再交给openssl_decrypt处理。注意第四个参数必须传OPENSSL_RAW_DATA,否则PHP会让输出继续按Base64处理,解析出来的内容会不对。
function decryptWxRefundInfo(string $reqInfo, string $apiKey): ?array
{
$encrypted = base64_decode($reqInfo, true);
if ($encrypted === false) {
return null;
}
$xml = openssl_decrypt(
$encrypted,
'AES-256-ECB',
$apiKey,
OPENSSL_RAW_DATA
);
if ($xml === false) {
return null;
}
libxml_use_internal_errors(true);
$data = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($data === false) {
return null;
}
return json_decode(json_encode($data), true);
}
解密成功后得到的数组里会有refund_id、out_refund_no、refund_status等字段。线上代码建议把这两个函数分开,因为验签是安全边界,解密是业务解析。如果验签失败,应直接记录日志并终止处理,不要继续解密,更不能把业务状态改为已退款。
四、快速定位并修复验签失败
当退款通知持续签名失败时,可以按以下顺序排查。第一步,打印微信POST过来的原始XML或表单参数,确认mch_id、sign、sign_type是否存在。第二步,根据mch_id找到对应的商户号配置,确认系统里加载的API密钥和该商户号在商户平台配置的API密钥完全一致。第三步,检查代码是否在验签前对参数做了多余处理,例如过滤了req_info、转义了XML、或把sign也拼进了签名串。
- 确认回调中的商户号与API密钥是否匹配,不要使用默认商户号的密钥处理所有回调。
- 在商户平台重新设置一个包含明确前缀的API密钥,避免与AppSecret、APIv3密钥混淆。
- 如果通知中带有
sign_type,按对应算法验签;如果不带,默认使用MD5。 - 写一个临时脚本,用候选密钥分别计算签名,找到哪一个能匹配微信下发的
sign。 - 验签通过后返回微信要求的成功XML,例如
<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>,否则微信会重复通知。
这里需要特别注意,返回给微信的成功XML不需要再使用API密钥签名,直接用文本输出即可。很多开发者因为担心不签名会被攻击,反而在响应里多加了签名字段,结果微信没有解析到正确状态导致重复回调。退款通知的验签只针对微信发给商户的请求,商户响应不参与签名。
最终如果确认所有逻辑都没问题,仍然验签失败,再登录商户平台查看API密钥是否被修改过。API密钥一旦重置,历史回调里的旧签名都无法用新密钥验证,但这通常只影响很短时间。线上系统修改密钥后,应同步更新配置并重启回调服务,避免不同实例读到的密钥不一致。