微信支付在退款接口中有一个容易踩坑的设计:无论是退款结果通知还是退款查询接口返回的refund_info,敏感字段都是以密文形式下发的,官方要求商户用自己的API密钥进行AES-256-ECB解密。不少开发者拿到密文后直接调用解密函数,结果抛出异常或者解出来一堆乱码。这篇文章围绕密钥长度和填充模式这两个核心点,把解密失败的常见原因逐一拆解,并给出可以直接运行的代码示例。

一、微信退款解密的整体流程和前置条件
先明确微信官方的规定:退款结果通知中,req_info字段是加密数据,加密方式为AES-256-ECB,密钥是商户号的API密钥(即v3 API密钥,32位字符串)先做MD5,得到的结果作为AES解密的key。整个流程是:对req_info先做Base64解码,得到密文字节;再对32位商户密钥做MD5,取小写的32位十六进制字符串,转换为字节数组后作为AES密钥;解密后得到的是带PKCS7填充的明文,需要去掉填充再按UTF-8解码成XML字符串。
很多团队在这里出错的第一原因是密钥用错了。微信支付有两套密钥:v2的API密钥和v3的APIv3密钥,退款回调解密用的是v3密钥(32位)。如果配置文件里写的是老商户平台的32位v2密钥,或者干脆把商户号、AppSecret当成解密密钥,MD5之后的字节数组就不对,解出来必然是乱码。第二个原因是MD5结果的处理:MD5得到的是16字节的原始摘要,但微信要求的是把摘要转成32位小写十六进制字符串后再作为key的字节,也就是实际AES key是32字节,对应AES-256。
正确的Java示例代码如下:
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Base64;
public class WxRefundDecoder {
public static String decryptRefundInfo(String reqInfo, String apiV3Key) throws Exception {
// 第一步:Base64解码密文
byte[] cipherBytes = Base64.getDecoder().decode(reqInfo);
// 第二步:对32位商户密钥做MD5,转小写十六进制字符串
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(apiV3Key.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);
}
// 32字节的小写hex字符串作为AES key
byte[] keyBytes = sb.toString().getBytes("UTF-8");
// 第三步:AES-256-ECB解密,由JDK自动处理PKCS5/7填充
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"));
byte[] plainBytes = cipher.doFinal(cipherBytes);
// 第四步:UTF-8解码得到XML明文
return new String(plainBytes, "UTF-8");
}
}
注意代码里用的是AES/ECB/PKCS5Padding。在Java的标准实现中,PKCS5Padding只处理8字节块,而AES块是16字节,但JDK内部实际按PKCS7语义处理,所以AES场景下写PKCS5Padding是正确的,这也是很多人疑惑的地方。如果写成NoPadding,解密不会报错,但明文末尾会残留填充字节,表现为XML结尾多出乱码字符。
二、密钥长度问题:为什么报InvalidKeyException或者KeyLength异常
密钥长度不匹配是解密失败中最隐蔽的一类问题。AES-256要求key必须是32字节,AES-128要求16字节。如果传入的key字节数不是这三个合法值之一,Java会抛出InvalidKeyException: Invalid AES key length。出现这个异常时,优先检查三点:密钥本身是不是32位;MD5的结果是不是转成了十六进制字符串后再取字节;字符串转字节时用的字符集是否为UTF-8。
字符集问题在中文环境下特别常见。如果服务器默认编码是GBK,而密钥中包含ASCII字符,getBytes()不带参数时结果通常一致,不会出问题;但一旦代码里对明文或者密钥做了其他处理,隐式依赖默认编码就是不稳定的。规范做法是所有getBytes()都显式指定StandardCharsets.UTF_8。另外,如果部署环境是JDK 8早期的某些小版本,AES-256受出口限制默认未启用,需要安装JCE无限制强度策略文件,否则会报Invalid key size异常,JDK 8u161之后已经默认放开。
PHP开发者则要注意另一个坑:openssl_decrypt的第五个参数是options,传OPENSSL_RAW_DATA表示输入是原始二进制,解密结果也是原始字节(保留PKCS7填充会被自动去除,前提是使用默认的PKCS7 padding)。PHP示例:
function decryptRefundInfo($reqInfo, $apiV3Key)
{
// Base64解码
$cipherBytes = base64_decode($reqInfo);
// MD5后取小写hex作为key
$key = strtolower(md5($apiV3Key));
// AES-256-ECB解密,OPENSSL_RAW_DATA表示原始数据模式
$plain = openssl_decrypt(
$cipherBytes,
'AES-256-ECB',
$key,
OPENSSL_RAW_DATA
);
if ($plain === false) {
throw new Exception('解密失败: ' . openssl_error_string());
}
return $plain; // UTF-8编码的XML明文
}
这里md5()默认返回的就是32位小写十六进制字符串,PHP会把它当作32字节ASCII传入,正好满足AES-256的key要求,所以PHP版本反而比Java少一步转换。如果有人画蛇添足又对hex字符串做了一次hex2bin,key就变成16字节,触发AES-128却用AES-256-ECB的模式声明,解密直接失败。
三、填充模式处理不当的典型表现与排查
填充模式的问题不像密钥长度那样直接抛异常,它更多表现为解密成功但内容不对。第一种表现是明文末尾乱码:使用了NoPadding模式,PKCS7填充字节没有被剥离,解出来的XML最后会有几个不可见字符,DOM解析时报格式错误。第二种表现是抛出BadPaddingException:密文本身不完整(Base64解码后的字节数不是16的整数倍),或者密文在传输中被URL编码处理过没有还原,导致最后一块的填充校验失败。
排查这类问题建议按顺序做四件事。第一,打印Base64解码后的密文字节数,确认能被16整除,不能整除说明密文被截断或者被额外编码过。第二,核对密钥来源:登录商户平台确认APIv3密钥,注意密钥只显示一次,配置文件里存的可能不是最新值,商户如果重置过密钥,旧密钥立刻失效。第三,把解密出的明文前几个字节转成十六进制打印出来,正常情况下应该以<xml>开头,如果开头就是乱码,基本可以断定密钥错了而不是填充问题。第四,用微信官方的验签机制确认通知本身没被篡改。
还有一种特殊情况是退款查询接口的返回结构。查询接口返回的refund_info里的encrypt_refund_info字段同样需要解密,字段含义和通知里的req_info一致,解密方式完全相同,可以直接复用同一套解密函数。但要注意不要把已经解密过的明文再次送进解密流程,双重解密会抛出Base64解码异常,这种低级错误在通知重试与查询回调共用的代码里时有发生,建议在解密函数入口加一个简单判断:明文以<开头就直接返回原文。
总结一下,微信退款解密失败的核心检查点就三个:密钥必须是32位APIv3密钥经过MD5转小写hex得到的32字节;模式必须是AES-256-ECB;填充必须让解密库自动处理PKCS7,不要手动用NoPadding。按这个顺序排查,绝大多数解密报错都能在十分钟内定位。写代码时建议把解密逻辑封装成独立工具类并附加详细日志,把密文字节长度、key长度、解密结果前缀都记录下来,线上出问题时日志能直接说明卡在哪一步。
微信支付退款解密AES-256-ECB解密商户密钥修改时间:2026-09-11 15:40:48