微信支付退款结果通知进入商户服务器后,通常先做签名校验,再对 resource 字段进行解密。验签通过只能说明通知确实来自微信支付、报文没有被中间人篡改,不能说明商户本地配置的 APIv3 密钥一定正确。很多退款解密失败最终都表现为同一个现象:AES-256-GCM 解密时抛出 Tag mismatch,Java 里常见的是 javax.crypto.AEADBadTagException,PHP 的 sodium 函数则直接返回 false。这个问题看上去像算法参数错误,但实际场景里八成以上是加密密钥与解密密钥没有对齐,或者 nonce、associated_data 没有按微信支付通知原文传入。

一、先看通知报文结构,确认解密到底需要哪些参数
微信支付 API v3 的退款结果通知会把真正的业务数据放在 resource 字段里,外层是事件类型、通知 ID、创建时间等信息。resource 本身包含 algorithm、ciphertext、associated_data 和 nonce 四个关键字段。algorithm 固定为 AEAD_AES_256_GCM,说明加密算法是 AES-256-GCM;ciphertext 是 Base64 编码后的密文;associated_data 是附加验证数据;nonce 是 GCM 模式使用的随机数。
排查解密失败时,第一件事不是怀疑微信支付返回的数据有问题,而是把自己解析出来的四个字段原样打印出来。注意 ciphertext 必须做 Base64 解码后再参与解密,而 nonce 和 associated_data 通常直接使用字符串的 UTF-8 字节即可。只要这三个参数中的任意一个和微信支付加密时使用的不一致,GCM 的认证标签就无法通过校验,最终也会表现为 Tag mismatch。也就是说,参数不匹配和密钥不匹配在异常表现上非常接近,必须一起排查。
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"create_time": "2025-07-01T10:30:00+08:00",
"resource_type": "encrypt-resource",
"event_type": "REFUND.SUCCESS",
"summary": "退款成功",
"resource": {
"original_type": "refund",
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"associated_data": "refund",
"nonce": "nonce_string"
}
}
上面这段报文里,真正参与 AES-256-GCM 解密的是 ciphertext、nonce、associated_data 和 APIv3 密钥。外层 event_type 可以用来区分通知类型,但退款结果通知的解密流程不会因为事件类型不同而变化。如果 outer 层验签通过,却一直卡在 resource 解密失败,接下来的重点就是核对密钥和这三个参数的取值。
二、APIv3 密钥是最容易用错的一环
微信支付 API v3 的 APIv3 密钥是一个 32 字节的对称密钥,退款结果通知的 resource 加密和解密都使用这同一个密钥。它不是商户证书私钥,不是 AppSecret,更不是平台证书序列号。实际开发中经常有人把这几个概念搞混:商户 API 证书私钥用于对商户主动调微信支付接口时的请求签名,平台证书公钥用于验证微信支付回调的签名,AppSecret 属于公众号网页授权体系,只有 APIv3 密钥负责回调 resource 的对称加解密。
如果代码里把 AppSecret 或商户私钥当作 APIv3 密钥传入解密函数,GCM 解密几乎必然失败。另一个高频问题是密钥长度不对。APIv3 密钥必须在商户平台完整生成并保存,复制到配置文件时不能多一个换行、不能少一个字符。部分开发者在 shell 环境变量中配置了带引号的值,例如 API_V3_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",程序读取后没有去掉多余的引号,导致实际参与解密的密钥变成 34 个字符,最终 AES 密钥构造或标签校验直接出错。
建议在解密入口增加一个轻量级校验,只检查长度和前后空白,不要把完整密钥打到日志里。下面这段 Java 代码可以快速发现配置读取阶段的问题:
String apiV3Key = System.getenv("WECHAT_API_V3_KEY");
if (apiV3Key != null) {
apiV3Key = apiV3Key.trim();
}
if (apiV3Key == null || apiV3Key.getBytes(StandardCharsets.UTF_8).length != 32) {
throw new IllegalArgumentException("APIv3 key length must be 32 bytes");
}
这个校验不能替代真实解密,但可以在一开始就暴露配置问题。对于 .properties、.yaml 或数据库配置,同样需要在读取后执行 trim。如果多个商户号共用一套配置,还要确认当前退款通知对应的 mchid 是否使用了正确的密钥,而不是直接读取默认值。
三、Java 与 PHP 的退款结果解密实现
Java 里解密微信支付退款通知的 resource,标准做法是使用 AES/GCM/NoPadding 算法,并传入 128 位的 GCMParameterSpec。nonce 和 associated_data 都要使用 UTF-8 编码后的字节,ciphertext 则需要 Base64 解码。完整方法可以这样写:
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 WechatRefundDecryptor {
public static String decryptResource(String ciphertext, String nonce,
String associatedData, String apiV3Key) throws Exception {
byte[] keyBytes = apiV3Key.getBytes(StandardCharsets.UTF_8);
if (keyBytes.length != 32) {
throw new IllegalArgumentException("APIv3 key length must be 32 bytes");
}
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
GCMParameterSpec spec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8));
cipher.init(Cipher.DECRYPT_MODE, keySpec, spec);
cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8));
byte[] plainBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
return new String(plainBytes, StandardCharsets.UTF_8);
}
}
如果 APIv3 密钥错误,cipher.doFinal 会抛出 AEADBadTagException,异常信息通常包含 Tag mismatch。如果 associated_data 或 nonce 传错,也会进入同样的异常分支。这就是为什么不能只盯着密钥,还要把 resource 字段完整传入。尤其是 associated_data 容易被忽略,很多示例代码只传了 nonce,结果在真实微信支付通知上一直解密失败。
PHP 侧使用 libsodium 扩展会更简单,但前提是服务器已经启用 sodium 扩展,并且 PHP 版本支持 AEAD_AES_256_GCM。解密函数如下:
<?php
function decryptWechatRefundResource(array $resource, string $apiV3Key): ?array
{
$ciphertext = base64_decode($resource['ciphertext'], true);
if ($ciphertext === false) {
return null;
}
$nonce = $resource['nonce'];
$associatedData = $resource['associated_data'];
if (strlen($apiV3Key) !== 32) {
throw new InvalidArgumentException('APIv3 key length must be 32 bytes');
}
$plain = sodium_crypto_aead_aes256gcm_decrypt(
$ciphertext,
$associatedData,
$nonce,
$apiV3Key
);
if ($plain === false) {
return null;
}
return json_decode($plain, true);
}
PHP 中返回 false 时,通常是密钥不正确或密文损坏。由于 sodium 函数不会提供更详细的错误码,排查时可以先打印密钥长度、ciphertext 是否解码成功、nonce 和 associated_data 是否与通知原文一致。只要这四个输入项完全正确,解密基本可以一次通过。需要注意的是,json_decode 之后的数组才是退款单号、退款金额、退款状态等业务字段。
四、多商户、缓存与密钥轮换造成的不匹配
多商户系统里最常见的解密失败不是算法问题,而是 key 映射错误。每个微信支付商户号都对应独立的 APIv3 密钥,如果服务收到退款通知后没有根据 mchid 选择密钥,而是读取了默认商户的配置,解密就会因为密钥不一致而失败。通知外层通常带有 mchid 字段,可以在解密前把它取出来,再通过 mchid 查询对应的 APIv3 密钥。
还有一个容易忽视的场景是 APIv3 密钥轮换。商户平台重置 APIv3 密钥后,旧密钥会立即失效,而线上服务如果仍然从内存缓存、配置中心或本地文件读取旧值,就会出现新通知完全无法解密的情况。微信支付不会在通知里标注使用了哪个版本的密钥,因此排障时只能去商户平台核对当前密钥,再对比服务端实际读取到的值。建议密钥重置后同步更新配置中心,并重启或热加载相关服务,避免本地缓存继续命中旧密钥。
证书和密钥的混用也会导致解密失败。平台证书用于验证微信支付回调签名,商户 API 证书私钥用于请求签名,这两者都不是 APIv3 密钥。即便证书文件路径配置正确,只要把证书里的私钥或平台证书内容传给 AES/GCM 解密,GCM 标签校验也会失败。遇到解密失败时,建议先确认传入的密钥来源,再确认它是否和商户平台 APIv3 密钥完全一致。
五、快速排查清单与修复建议
先把排查步骤固定下来,能减少很多无效猜测。收到退款通知后,按下面顺序检查:验签是否通过、resource 字段是否完整、ciphertext 是否做了 Base64 解码、nonce 和 associated_data 是否原样传入、APIv3 密钥长度是否为 32 字节、当前商户号是否匹配该密钥。每一步都能直接排除一类问题。
- 验签通过但解密失败,优先怀疑 APIv3 密钥不一致或 resource 参数传错。
- 密钥长度不是 32 字节,检查配置文件是否混入引号、空格、换行。
- 多商户场景中,确认 mchid 与 APIv3 密钥的映射关系没有被默认值覆盖。
- 重置过 APIv3 密钥后,清理内存缓存、更新配置中心并重启服务。
- 不要把商户私钥、AppSecret 或平台证书内容当作 APIv3 密钥使用。
- 解密前打印密钥长度和哈希前几位,不要打印完整密钥。
如果以上步骤仍无法定位,可以再检查服务器字符编码。例如从数据库读出的密钥使用了非 UTF-8 编码,或在 Java 中使用了平台默认字符集,都可能导致密钥字节发生变化。统一使用 UTF-8 后,再用一个已知正确的通知报文做一次本地解密测试,通常就能确认问题是否仍然存在。
退款结果解密失败最怕的是把异常当成算法缺陷去改代码,结果绕了一圈还是配置问题。只要把密钥、nonce、associated_data 和 ciphertext 四项对齐,微信支付退款结果通知的解密流程会非常稳定。后续如果继续遇到 Tag mismatch,就回到这四项逐一比对,定位速度会快很多。