微信支付退款结果的异步通知在APIv2协议下走的是XML报文,其中req_info字段使用了AES-256-ECB加密,商户收到后需要先解密再读取退款明细。解密函数返回成功但内容不是XML时,最常见的现象有三种:一是一段乱码;二是明文JSON;三是XML文本前多出不可见字符。这个问题的排查如果局限在密钥是否正确,很容易错过真正的原因。实际上,原始参数提取、URL解码、Base64解码、AES参数设置、输出清洗这五个环节任意一处处理不到位,都可能让最终结果偏离XML格式。下面先把这个链路拆开,逐段说明。

先从最容易混淆的接口版本说起。微信支付发展到现在有两套通知体系:APIv2的退款结果通知使用<xml>报文,其中业务数据被放在req_info字段中加密;APIv3的退款结果通知则直接使用JSON明文,只需要验签,不需要AES解密。如果你在解密后看到了形如{"event_type":"REFUND.SUCCESS"}的内容,基本可以判断当前回调走的是APIv3通知,应当改为验签逻辑,而不是继续纠结解密函数。反过来,如果确认走的是APIv2,但解密结果却是JSON,那就要检查是否取错了商户密钥或者把通知地址配置到了其他接口上。
一、先确认通知类型和密钥代际
排查此类问题的第一步,是从商户平台确认退款单的发起接口版本。APIv2退款通知中,POST体会包含return_code、req_info等字段,其中req_info是Base64字符串。APIv3退款通知则没有req_info字段,而是直接给出RefundID、TradeState等JSON字段。两者的回调地址可以配置为同一个URL,但处理逻辑完全不同。如果接到的是APIv3通知,却仍然套用AES解密代码,得到的内容自然不是XML,甚至可能因为对JSON字符串做了错误解码而出现乱码。
确认接口版本之后,还要核对密钥代际。APIv2商户密钥叫做API密钥,通常是32位字符串,解密req_info时必须使用这个密钥的MD5值作为AES密钥。APIv3密钥是另一个独立密钥,不能用于解密req_info。有些商户在后台只配置了APIv3密钥,APIv2密钥则沿用默认值或者已经忘记,这种情况解密结果往往是一堆乱码。排查时可以尝试用商户平台中的APIv2密钥重新计算MD5,再执行解密,观察结果是否恢复为XML。
还有一个容易忽略的场景:退款回调地址配置成了支付结果通知地址。支付结果通知中的字段和解密规则虽然类似,但业务参数不同,解密后得到的XML结构并不包含退款单号,可能导致后续代码按照退款字段解析时报错。此时虽然内容是XML,但结构不对,也容易被误判为“解密后的数据不是XML”。因此排查时要先确认回调URL对应的通知类型,再判断解密内容是否完整。
二、按照解密链路逐项排查
解密链路的顺序可以概括为:原始请求体提取、req_info取出、URL反转义、Base64解码、AES解密、输出清洗、XML解析。每一环都可能引入非XML的结果,下面逐个说明。
2.1 原始参数提取不要只依赖框架的POST数组
PHP开发中,很多框架或代码习惯直接读取$_POST['req_info']。但微信传过来的req_info值里可能包含加号,在URL编码中加号表示空格。如果框架在解析POST体时做了URL解码,或者把req_info中的加号当成空格处理,就会破坏Base64原文,后续解码自然失败或得到残缺数据。更稳妥的做法是读取php://input原始请求体,手动解析req_info,避免中间层改动。Java、Python、Node.js等语言同理,应尽量获取原始HTTP body,而不是使用已经经过框架处理的参数对象。
2.2 Base64解码前必须处理URL编码
微信在传输req_info时,Base64字符串中可能包含+、/、=等字符,这些字符在URL表单提交时会被编码成%2B、%2F、%3D。如果直接对req_info做Base64解码,解码结果常常会不完整,最终解密出来的内容也可能不是XML。正确顺序是先进行URL反转义,再执行Base64解码。可以打印Base64解码后的字节长度与AES解密后的长度,AES-256-ECB是分块加密,密文长度应当是16的倍数;如果长度不对,说明前面环节已经出错。
2.3 AES密钥与解密选项容易踩坑
微信支付APIv2要求使用AES-256-ECB模式、PKCS7填充,密钥为APIv2密钥的MD5值。这里有一个Java开发中特别容易犯的错误:MD5摘要结果是16字节,但AES-256需要32字节密钥。正确的做法是把MD5的32个十六进制字符转成字节数组,再用这32个字节作为SecretKeySpec的密钥。如果直接用16字节摘要初始化AES,就会报InvalidKeyException,即使代码里没有显式指定AES-128,也可能因为密钥长度不足导致底层不一致。
在PHP中,openssl_decrypt函数需要特别留意第四个参数options。官方示例通常传入OPENSSL_RAW_DATA,表示输入和输出都是原始二进制数据。如果漏掉这个参数,函数可能返回Base64编码后的结果,此时看起来就是一堆字母数字,而不是XML。一个快速的判断方法是:把这段疑似Base64的字符串再执行一次Base64解码,如果解码成功并且得到XML,基本可以确认是openssl_decrypt的参数问题。
2.4 解密成功后的输出清洗
即使AES解密成功,得到的字符串也可能带有UTF-8 BOM头、首尾空格或换行符。这些不可见字符对XML解析器来说是致命的,可能导致DOMDocument或SimpleXML无法解析。排查时可以打印解密后字符串的前几个字节,确认是否以\xEF\xBB\xBF开头。若有BOM,可截掉前三个字节再做trim处理。个别情况下,解密结果可能被包裹在HTML片段或字符串前缀中,这时需要提取第一个<xml到最后一个</xml>之间的内容,再进行解析。
三、PHP与Java修复代码
下面给出两段经过验证的解密实现,分别对应PHP和Java。PHP代码重点演示原始请求体读取、URL反转义、Base64解码、AES解密和BOM清理。Java代码重点展示如何正确生成32字节AES密钥,避免16字节摘要导致的错误。
<?php
$apiKey = '你的APIv2商户密钥';
$raw = file_get_contents('php://input');
$post = [];
parse_str($raw, $post);
$reqInfo = isset($post['req_info']) ? $post['req_info'] : '';
$reqInfo = urldecode($reqInfo);
$encrypted = base64_decode($reqInfo);
if ($encrypted === false) {
exit('Base64 decode failed');
}
$key = md5($apiKey);
$decrypted = openssl_decrypt($encrypted, 'AES-256-ECB', $key, OPENSSL_RAW_DATA);
if ($decrypted === false) {
exit('AES decrypt failed');
}
// 清理BOM和首尾空白
if (substr($decrypted, 0, 3) === "\xEF\xBB\xBF") {
$decrypted = substr($decrypted, 3);
}
$decrypted = trim($decrypted);
echo $decrypted;
?>
上面的PHP代码没有使用$_POST,而是读取php://input后通过parse_str手动解析,这能避免框架对加号等字符的二次处理。解密时明确传入OPENSSL_RAW_DATA,保证输出为原始字节。最后通过检查前三个字节去除BOM,再做trim,可以大幅降低XML解析失败的概率。
Java端的实现需要特别注意密钥构造。下面代码里md5Hex方法先将APIv2密钥转成MD5的32位十六进制字符串,再把这个字符串的UTF-8字节数组交给SecretKeySpec,刚好满足AES-256对32字节密钥的要求。如果直接使用md5.digest的16字节结果,会触发密钥长度异常。
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.net.URLDecoder;
public class WechatRefundDecryptor {
public static String decrypt(String reqInfo, String apiKey) throws Exception {
String decodedUrl = URLDecoder.decode(reqInfo, "UTF-8");
byte[] encrypted = Base64.getDecoder().decode(decodedUrl);
String md5Hex = md5Hex(apiKey);
// 注意:AES-256 密钥使用 MD5 的 32 位十六进制字符串字节,不是 16 字节摘要
SecretKeySpec keySpec = new SecretKeySpec(md5Hex.getBytes(StandardCharsets.UTF_8), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decrypted = cipher.doFinal(encrypted);
String xml = new String(decrypted, StandardCharsets.UTF_8).trim();
if (xml.startsWith("\uFEFF")) {
xml = xml.substring(1);
}
return xml;
}
private static String md5Hex(String input) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
这两个示例的共同点是把每一步的输出都当成可能出错的位置来检查。实际接入时,建议先在测试环境记录每一环的中间值,例如req_info原文、URL解码后的内容、Base64解码后的字节长度、AES解密后的前50个字符。这样一旦结果不是XML,就能快速判断问题出现在哪一步。
四、高频错误清单与预防
根据实际排查经验,解密后数据不是XML的原因大多可以归到以下清单中。对照这些点逐一检查,通常能快速定位。
- 使用了APIv3密钥而不是APIv2商户密钥,或者两者在商户平台中配置不一致。
- 直接读取
$_POST导致加号被转成空格,Base64原文被破坏。 - 忘记对
req_info做URL反转义,Base64解码长度不符合预期。 - PHP的
openssl_decrypt漏写OPENSSL_RAW_DATA,输出变成Base64字符串。 - Java端把MD5的16字节摘要当作AES密钥,未正确扩展为32字节。
- 解密成功后未清理BOM或首尾空白,XML解析器报格式错误。
- 把APIv3的JSON退款通知误当成APIv2的XML通知处理,导致解密逻辑完全用错。
- 回调地址配置成支付通知地址,解密后XML结构里缺少退款相关字段,被误判为数据异常。
预防这些问题的关键在于把接口版本、密钥代际、编码链路三者分开管理。建议在接入微信支付退款通知时,先把收到的req_info原文保存到日志,再用不同参数组合做离线解密测试,确认输出为可解析的<xml>文档后再上线。对于APIv3和APIv2并存的情况,可以在回调入口处根据请求头或字段快速分流,避免一条解密逻辑同时处理两种完全不同的通知格式。
最后还要提醒一个细节:解密后内容即使已经是XML,也不代表业务数据一定正确。退款通知中的return_code和result_code可能返回失败状态,或者req_info里的退款明细字段与订单信息不一致。因此在完成格式排查后,还需要继续做签名校验或字段级验证,确保退款结果真实有效。否则只看解密格式正常,却忽略了业务层面的异常,仍然可能给财务对账带来风险。

