微信支付退款成功后,微信服务器会向商户配置的回调地址推送一条加密的退款结果通知。这条通知里的req_info字段是密文,必须使用商户API证书的密钥(即商户平台设置的APIv2密钥)做MD5摘要后作为AES密钥,再通过AES-256-ECB模式解密。很多开发者反馈解密环节频繁报错,或者解密出来的字符串中夹杂乱码、不可见字符,导致json_decode或JSON.parse失败。这篇文章围绕退款结果解密的完整链路,逐一分析解密失败与特殊字符处理的各个细节。

一、退款结果解密的完整流程与常见错误定位
退款通知的解密步骤官方文档写得很明确,但每一步都有隐藏的坑。完整流程是:先对return_code和result_code做判断,确认是SUCCESS后,取req_info字段,对其进行Base64解码得到密文字节流;再对APIv2密钥做MD5哈希,得到长度为32的小写十六进制字符串,这个字符串就是AES解密所用的key;接着用AES-256-ECB模式、PKCS7填充方式解密;最后得到一段XML或JSON格式的明文,里面包含out_refund_no、refund_status、refund_recv_account等关键信息。
常见错误主要集中在三个环节。第一是密钥处理错误,有些开发者直接拿APIv2密钥去解密,忘记了要先做MD5。MD5的结果必须是小写的32位字符串,如果转成了大写或者做了Base64编码,解密必然失败。第二是Base64解码问题,某些语言环境下Base64解码后得到的是字符串而非字节流,遇到二进制数据时会被隐式转换,造成密文损坏。第三是解密后的特殊字符问题,这一点最隐蔽,也是本文的重点。
定位问题时建议先把中间结果打印出来。检查MD5后的key长度是否为32,检查Base64解码后的密文长度是否为16的整数倍(AES分组长度要求),检查解密后的原始字节是否以合法字符开头。如果解密结果开头就是正常的XML声明或JSON花括号,但末尾出现乱码,那基本可以确定是填充去除或编码转换的问题。
二、解密后数据包含特殊字符的原因分析
解密后的明文中出现特殊字符或乱码,通常有以下几类原因。首先是填充(Padding)没有正确去除。AES-256-ECB默认使用PKCS7填充,解密时最后一个分组的末尾会附加填充字节。部分语言或旧版本的加密库默认使用PKCS5或ZeroPadding,如果解密时用了ZeroPadding,而加密方用的是PKCS7,当明文最后刚好是\x00之类的字节时,就会出现填充判断异常,末尾残留乱码字符。
其次是编码转换问题。微信返回的退款明文是UTF-8编码的XML或JSON,其中refund_recv_account、refund_success_time等字段可能包含中文。如果解密后把字节数组转成字符串时使用了GBK编码,中文部分会变成乱码,而这些乱码字节中可能包含双引号、反斜杠等会破坏JSON结构的字节序列,导致后续解析失败。反过来,如果整个链路中有一环节把UTF-8字符截断了半个字符,也会产生非法字节序列。
还有一种情况是字符串函数误用。例如在PHP中用gzinflate、trim等函数处理解密结果时,trim默认会去除的字符集中包含\x00到\x20,如果填充字节没去干净,trim可能只去掉了部分,剩下的藏在中间。又比如在Java中使用new String(bytes)不指定字符集,会依赖平台默认编码,在Windows服务器上可能是GBK,在Linux上是UTF-8,同一份代码在不同环境表现不一致,这类问题排查起来非常耗时。此外,如果回调数据被Web框架自动做过一次urldecode或htmlspecialchars转义,密文中的加号会被替换成空格,Base64解码就会失败,这也是一种广义上的特殊字符问题。
三、PHP、Java、Node.js三种语言的正确解密实现
PHP项目中推荐使用openssl系列函数,避免老旧的mcrypt扩展。下面给出完整的PHP解密实现,重点注意md5函数的第二个参数必须为false(返回十六进制字符串而非原始字节),以及解密后显式指定UTF-8编码。
<?php
function decryptRefundReqInfo($reqInfo, $apiV2Key)
{
// 第一步:Base64解码得到密文字节流
$ciphertext = base64_decode($reqInfo);
// 第二步:对APIv2密钥做MD5,得到32位小写十六进制字符串作为AES密钥
$aesKey = md5($apiV2Key); // 注意:不要传第二个参数true
// 第三步:AES-256-ECB解密,OPENSSL_ZERO_PADDING配合手动去填充更可控
$decrypted = openssl_decrypt(
$ciphertext,
'AES-256-ECB',
$aesKey,
OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING
);
// 第四步:手动去除PKCS7填充
$pad = ord(substr($decrypted, -1));
if ($pad > 0 && $pad <= 16) {
$decrypted = substr($decrypted, 0, -$pad);
}
// 第五步:按UTF-8处理,过滤非法不可见字符(保留中文与常规符号)
$text = mb_convert_encoding($decrypted, 'UTF-8', 'UTF-8');
return $text;
}
// 使用示例
$plain = decryptRefundReqInfo($notifyData['req_info'], '你的32位APIv2密钥');
$result = simplexml_load_string($plain);
$refundStatus = (string)$result->refund_status;
Java的实现要点在于SecureRandom的使用(ECB模式其实不需要IV,这点常被搞混)和字符串构造时显式指定StandardCharsets.UTF_8。部分老代码使用Cipher.getInstance("AES")默认走PKCS5Padding在Java8以下版本可能与PKCS7不兼容,建议显式声明。
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
public class RefundDecryptor {
public static String decrypt(String reqInfo, String apiV2Key) throws Exception {
byte[] ciphertext = Base64.getDecoder().decode(reqInfo);
// MD5得到32位小写十六进制密钥
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(apiV2Key.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
String aesKey = sb.toString();
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKeySpec keySpec = new SecretKeySpec(
aesKey.getBytes(StandardCharsets.UTF_8), "AES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] plain = cipher.doFinal(ciphertext); // doFinal已自动去除填充
// 显式UTF-8,避免依赖平台默认编码
return new String(plain, StandardCharsets.UTF_8);
}
}
Node.js项目用内置的crypto模块即可,注意createDecipheriv的ECB模式iv参数要传null,解密后调用setAutoPadding(true)确保PKCS7填充被正确去除。
const crypto = require('crypto');
function decryptRefundReqInfo(reqInfo, apiV2Key) {
// MD5生成32位小写十六进制AES密钥
const aesKey = crypto.createHash('md5')
.update(apiV2Key, 'utf8')
.digest('hex');
const decipher = crypto.createDecipheriv(
'aes-256-ecb',
Buffer.from(aesKey, 'utf8'),
null // ECB模式无初始向量
);
decipher.setAutoPadding(true); // 启用PKCS7自动去填充
const ciphertext = Buffer.from(reqInfo, 'base64');
const plain = Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]);
// 始终以utf8输出,禁止使用latin1
return plain.toString('utf8');
}
四、解密成功后的数据校验与二次处理建议
解密拿到明文后不要直接信任数据,建议做几层校验。第一层是格式校验,用对应的解析器(PHP的simplexml_load_string或json_decode、Java的DOM解析、Node的JSON.parse)解析,解析失败时把原始明文的十六进制转储打印出来,检查是否存在截断的字节或异常填充。第二层是字段完整性校验,确认refund_status、out_refund_no、settlement_refund_fee等必要字段存在且不为空。
第二层之外还有业务层面的验签。虽然req_info本身是加密传输的,但通知的整体应答仍建议通过查询退款接口主动核实,防止伪造回调。处理完成后按微信要求返回success的XML或JSON应答,注意返回内容同样是UTF-8编码,且不要额外输出BOM头。BOM头是另一种容易被忽视的特殊字符问题:如果PHP文件保存为UTF-8 with BOM格式,返回给微信的应答开头会带上\xEF\xBB\xBF三个字节,微信可能判定应答异常而反复重推通知,导致商户侧重复处理退款数据。
最后总结一下排查要点:密钥先MD5且为32位小写、密文先Base64解码为字节流、解密模式固定为AES-256-ECB加PKCS7填充、字节转字符串显式声明UTF-8编码、解析前过滤残留的不可见字符、应答输出杜绝BOM头。把这几条逐一核对,绝大多数退款解密失败和特殊字符问题都能迎刃而解。如果项目使用的是APIv3接口,则退款通知改用AEAD_AES_256_GCM解密,密钥直接使用APIv3密钥,无需MD5,处理逻辑有所不同,开发时不要把两套体系混用。
微信支付退款解密解密失败AES-256-ECB修改时间:2026-09-02 20:03:25