微信支付退款接口与支付通知最大的区别在于:退款结果不会在同步响应中明文返回,而是以req_info字段的形式存放一段AES-256-ECB加密后的Base64字符串。开发者需要先用MD5后的商户密钥派生出AES密钥,解密后才能拿到退款详情XML。不少人在这一步拿到的是一堆问号、方框或者直接抛出BadPaddingException,排查半天发现加密逻辑与官方文档完全一致,最后问题往往出在UTF-8和GBK的编码转换上。本文把这个问题的来龙去脉和解决方案完整梳理一遍。

一、退款解密流程中编码出错的三个位置
微信退款通知的解密流程本身不复杂:取商户API密钥做MD5得到32字节的小写字符串,作为AES密钥;对通知里的req_info先做Base64解码得到密文字节数组;再执行AES-256-ECB解密得到字节数组;最后把字节数组按UTF-8转成字符串。整个链条里有三个地方涉及字符编码,任何一步用错字符集,最终结果都会乱。
第一个位置是MD5摘要的计算。md5(key.getBytes())这行代码里,如果密钥本身是纯ASCII字符,UTF-8和GBK的结果一致,不会出问题。但一旦密钥中混入了非ASCII字符,或者调用了封装不当的工具类,两种编码算出的MD5值就不同,后续解密必然失败,典型表现是抛出Given final block not properly padded异常。
第二个位置是解密结果的字符串化。微信退款回调的原始内容是UTF-8编码的XML,解密得到的字节数组必须用UTF-8还原。很多老项目在Windows上开发,JVM默认字符集被识别为GBK,如果写成new String(decrypted)而不指定编码,中文就会全部变成乱码。第三个位置是HTTP响应的读取,用HttpURLConnection或老旧的HttpClient读取响应流时,若没有强制按UTF-8读取,拿到的req_info字符串本身就已经被污染,Base64解码自然会出错。
二、Java中正确的编码转换写法
解决这个问题的核心原则只有一条:字节与字符串之间的每一次转换都必须显式指定字符集,绝不依赖平台默认编码。从JDK 1.7开始推荐使用StandardCharsets常量,它线程安全且不会抛出受检异常,比直接传字符串"UTF-8"更稳妥。
下面是一段修正后的完整解密代码,编码相关位置都加了注释:
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 decryptReqInfo(String reqInfo, String apiKey) throws Exception {
// 第一步:对商户API密钥做MD5,getBytes必须显式指定UTF-8
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(apiKey.getBytes(StandardCharsets.UTF_8));
// 将MD5结果转为32位小写十六进制字符串,再用UTF-8取回字节作为AES密钥
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
byte[] aesKey = sb.toString().getBytes(StandardCharsets.UTF_8);
// 第二步:Base64解码密文,reqInfo字符串本身必须来源于UTF-8读取的HTTP响应
byte[] cipherBytes = Base64.getDecoder().decode(reqInfo);
// 第三步:AES-256-ECB解密
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decrypted = cipher.doFinal(cipherBytes);
// 第四步:微信原始内容是UTF-8,这里必须显式指定,不能用平台默认的GBK
return new String(decrypted, StandardCharsets.UTF_8);
}
}这段代码的关键点集中在两处:apiKey.getBytes(StandardCharsets.UTF_8)保证MD5输入的一致性,最后的new String(decrypted, StandardCharsets.UTF_8)保证解密输出不会受Windows默认GBK字符集的影响。如果项目部署在Linux服务器上原本正常,迁移到Windows测试机后突然乱码,几乎可以肯定就是缺了最后这个显式编码参数。
三、HTTP层的编码统一与常见报错对照
除了解密代码本身,HTTP请求与响应的编码也要全链路统一。读取微信回调时,请求体要按UTF-8解析;主动查询退款接口时,发送的请求参数和接收的响应同样要指定UTF-8。以常用的HttpClient为例,构造实体时要写new StringEntity(xml, StandardCharsets.UTF_8),取回响应时用EntityUtils.toString(entity, StandardCharsets.UTF_8),两边都不能省略编码参数。
排查时可以按报错现象快速定位问题层级。如果抛出BadPaddingException或IllegalBlockSizeException,说明密钥派生或Base64数据有问题,重点检查MD5那一步的getBytes编码,以及HTTP响应是否被错误编码污染;如果解密成功但中文显示为乱码,问题必然出在最后new String这一步没有指定UTF-8;如果英文内容正常而个别中文异常,则可能是中途经过了ISO-8859-1中转,被XML工具类二次转换导致。
还有一种隐蔽的情况:从日志或数据库中取出之前保存的req_info进行离线解密时乱码。这往往是因为保存时数据库连接使用了GBK字符集,Base64字符串虽然只含ASCII字符本不受影响,但若保存的是解密后的结果,编码在入库时就已经损坏,无法事后修复。因此建议始终保存原始的req_info密文,解密动作在读取时实时进行,从源头规避编码转换链路过长带来的风险。统一好这些细节后,退款解密的编码问题基本不会再出现。