做微信公众号支付退款功能的开发者基本都会遇到同一个问题:退款结果通知推送过来的数据里,refund_status、refund_request_source这些关键信息全被塞在一个叫req_info的加密字段里,直接打印出来是一串Base64乱码,根本没法用。这是因为微信出于安全考虑,对退款结果通知做了单独加密处理,加密方式是AES-256-GCM,解密密钥也不是直接用商户API密钥,而是要先做一次MD5。不少人在这一步栽了跟头,要么解密时抛出BadTagException,要么解出来是乱码。本文把整个解密流程和完整Java代码讲透,照着写就能跑通。

退款结果解密的整体流程与原理
先明确一点:微信支付有两类通知需要区分对待。普通支付结果通知(event类型为TRANSACTION)从v3版本开始用的是RSA加密的平台证书解密,而退款结果通知走的却是另一套完全不同的机制——即使你用的是微信支付APIv3,退款成功通知中的resource字段同样采用AEAD_AES_256_GCM算法,但密钥来源是APIv3密钥;如果是v2的退款结果通知,则是对req_info字段用商户API密钥的MD5值作为AES-256-ECB... 不对,这里要特别纠正一个常见误区:v2退款通知用的确实是AES-256-ECB模式,但微信官方文档里退款通知(无论v2接口还是v3的退款结果通知的resource解密)推荐和主流实现都是AES/GCM。以v2退款结果通知为例,官方规定的解密步骤是三步。
第一步,对商户API密钥(就是你在微信商户平台设置的32位API密钥,即md5加密方式的key)做MD5运算,得到一个32字符的小写字符串,这个字符串才是真正用于AES解密的密钥。注意MD5结果必须转成小写十六进制字符串后再使用,直接拿字节数组去初始化SecretKey会报InvalidKeyException,提示密钥长度不合法。这一步是坑最多的地方,因为很多人误以为直接拿API密钥去解密。
第二步,对通知报文中的req_info字段做Base64解码,得到AES加密的原始密文字节数组。第三步,使用AES-256-ECB模式(PKCS5Padding填充)配合第一步得到的MD5字符串作为密钥解密。这里再强调一次版本差异:v2退款通知用ECB模式,v3的退款通知resource解密用GCM模式且需要处理nonce和associated_data。下文代码两种都给出,以v2的ECB场景为主线讲解,最后补充v3的GCM写法。
Java完整解密代码实现(v2退款通知)
下面给出完整可运行的工具类,依赖方面只需要JDK自带的javax.crypto,不需要额外引包。代码里对每一步都做了封装,方便直接嵌入到Spring Boot的回调Controller里。
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Base64;
public class WxRefundDecryptUtil {
/**
* 解密微信退款结果通知的req_info字段
* @param reqInfo 通知中的加密字段(Base64字符串)
* @param apiKey 商户平台设置的API密钥(32位)
* @return 解密后的XML明文
*/
public static String decryptRefundInfo(String reqInfo, String apiKey) throws Exception {
// 第一步:对API密钥做MD5,得到小写十六进制字符串作为解密密钥
MessageDigest md5 = MessageDigest.getInstance("MD5");
byte[] digest = md5.digest(apiKey.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);
}
String md5Key = sb.toString(); // 32位小写密钥
// 第二步:Base64解码密文
byte[] cipherBytes = Base64.getDecoder().decode(reqInfo);
// 第三步:AES-256-ECB解密,PKCS5Padding填充
SecretKeySpec keySpec = new SecretKeySpec(md5Key.getBytes("UTF-8"), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] plainBytes = cipher.doFinal(cipherBytes);
return new String(plainBytes, "UTF-8");
}
public static void main(String[] args) throws Exception {
String apiKey = "你的32位商户API密钥";
String reqInfo = "通知报文里拿到的req_info字段值";
String xml = decryptRefundInfo(reqInfo, apiKey);
System.out.println(xml);
}
}解密成功后返回的是一段XML字符串,结构大致包含out_refund_no(商户退款单号)、refund_status(退款状态,SUCCESS表示成功)、refund_recv_account(退款入账账户)、settlement_refund_fee(退款金额)等字段。解析XML可以用dom4j,也可以简单点用正则或者Hutool的XmlUtil,看个人项目习惯。
这里提两个容易出错的处理细节。其一,取req_info之前一定要先把整个回调XML解析出来,注意微信回调报文是XML不是JSON,用JSON库去解析会直接失败。其二,如果部署环境是JDK 8早期版本,默认的JCE策略文件限制了AES密钥最长128位,而MD5后的32字节密钥对应256位,会抛出InvalidKeyException: Illegal key size。解决办法是升级到JDK 8u161以上版本,或者手动替换local_policy.jar和US_export_policy.jar两个策略文件。
v3退款通知的GCM解密写法
如果你的项目用的是APIv3,退款成功通知里的resource字段解密逻辑不太一样。GCM模式需要额外的nonce(随机串)和认证标签,Java里通过GCMParameterSpec指定认证标签位数,通常为128位。密钥直接使用APIv3密钥(32字节字符串),不需要再做MD5。
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class WxV3RefundDecryptUtil {
public static String decryptResource(String ciphertext, String nonce, String apiV3Key) throws Exception {
byte[] keyBytes = apiV3Key.getBytes(StandardCharsets.UTF_8);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
// GCM模式,认证标签长度128位
GCMParameterSpec spec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8));
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, keySpec, spec);
byte[] result = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
return new String(result, StandardCharsets.UTF_8); // 返回JSON明文
}
}v3解出来的明文是JSON格式,退款状态字段是refund_status,退款单号是out_refund_no,金额字段包含payer_refund、amount等嵌套结构,用Jackson或Gson反序列化即可。GCM解密如果抛出BadTagException,几乎可以断定是密钥不对、nonce取错了字段,或者密文在传输中被截断(比如URL编码处理时出了问题),优先检查这三项。
回调接口的完整衔接与常见异常排查
解密只是回调处理的一环,一个健壮的回调接口顺序应该是:先验签,再解密,然后业务处理,最后按微信要求的格式返回应答。v2回调验签用API密钥对通知内容做MD5签名比对,v3回调验签用微信支付平台证书验证Wechatpay-Signature请求头。验签通过后才进行解密,这个顺序不能反,否则你的接口等于对外暴露了一个免费的解密服务。
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/wx/notify")
public class WxRefundNotifyController {
@PostMapping(value = "/refund", produces = "text/xml;charset=UTF-8")
public String refundNotify(@RequestBody String xmlBody) {
try {
// 1. 解析XML拿到req_info(此处省略解析,假设已拿到)
String reqInfo = extractReqInfo(xmlBody);
// 2. 验签(省略,务必实现)
// 3. 解密
String plainXml = WxRefundDecryptUtil.decryptRefundInfo(reqInfo, "你的API密钥");
// 4. 业务处理:更新退款单状态等
System.out.println("退款结果明文:" + plainXml);
// 5. 返回成功应答,微信收到后停止重推
return "<xml><return_code><![CDATA[SUCCESS]]></return_code>"
+ "<return_msg><![CDATA[OK]]></return_msg></xml>";
} catch (Exception e) {
// 返回失败,微信会按衰减策略重试推送
return "<xml><return_code><![CDATA[FAIL]]></return_code>"
+ "<return_msg><![CDATA[解密失败]]></return_msg></xml>";
}
}
}关于异常排查,再补充几条实战经验。第一,解密结果开头不是<xml>而是一堆乱码,说明密钥对了但填充模式不对,检查是否误用了NoPadding。第二,报Input length must be multiple of 16,通常是Base64解码环节出了问题,确认req_info没有被二次编码或截断。第三,本地测试通过、线上失败,重点排查线上环境JDK的密钥长度限制以及API密钥是否与商户号匹配——一个商户号对应一套密钥,用错商户的密钥永远解不出来。第四,微信的退款通知会重复推送,业务代码里要做幂等处理,建议以out_refund_no加唯一索引兜底,避免重复更新退款状态。
最后提醒一点安全规范:API密钥和APIv3密钥不要硬编码在代码里,应该放到配置中心或环境变量中,日志里也绝对不要打印解密后的完整明文,退款入账账户等信息属于敏感数据。把解密工具类、验签逻辑、业务处理分层封装,后续微信切换通知格式时改动成本也最小。按本文的代码和排查思路走下来,退款结果解密这个环节基本不会留下隐患。
微信支付退款解密Java解密AES-256-GCM退款结果通知修改时间:2026-09-14 05:00:50