退款结果通知是微信公众号支付异步回调中最关键的一环。商户后台收到POST请求后,如果只解析refund_status字段并更新订单状态,而不验证请求是否真的来自微信服务器,就会给伪造退款通知留下可乘之机。攻击者只要猜中回调地址,就可以模拟一个退款成功报文,诱导系统发放补偿、自动发货或关闭风控流程。因此,在分析退款业务逻辑之前,必须先完成签名验证和密文解密,确认通知来源合法且内容未被篡改。

一、为什么退款结果通知必须验签
微信支付APIv3的退款结果通知是一个异步回调,微信服务器会向商户配置的退款通知地址发送一个JSON格式的POST请求。这个请求的HTTP头中带有几个关键字段:Wechatpay-Timestamp表示请求时间戳,Wechatpay-Nonce是一次性随机字符串,Wechatpay-Signature是对整个请求体计算的RSA-SHA256签名,Wechatpay-Serial表示签名所使用的微信支付平台证书序列号。请求体本身包含resource字段,其中嵌套了加密后的退款详情密文。
如果不验证这些头部信息,只读取请求体中的退款状态,整个通知链路就处于无保护状态。攻击者可以构造任意JSON报文,声称某笔订单已经退款成功。虽然最终业务可以再调用微信支付查询退款接口做二次确认,但不少系统为了减轻压力或简化逻辑会跳过查询,这就会放大伪造通知的风险。旧版微信支付APIv2的退款通知没有单独提供这样的签名头,商户只能依靠req_info字段解密成功以及主动查询退款接口来间接确认,而APIv3则通过签名头让验签成为第一道可靠防线。
验签的前提是商户后台已经获取到微信支付平台证书或微信支付公钥。微信支付APIv3早期使用平台证书,现在也支持微信支付公钥模式。无论使用哪种方式,商户都需要维护证书序列号与公钥的映射关系,在收到通知时根据Wechatpay-Serial找到对应的公钥进行验证。
二、验证签名的完整步骤
签名的核心逻辑是使用微信支付平台公钥对一串特定格式的文本进行RSA-SHA256验签。这串文本由四个部分组成:HTTP头中的时间戳、随机数、请求体原文,以及每一部分之后的换行符。用代码表示就是timestamp + "\n" + nonce + "\n" + body + "\n"。这里的时间戳和随机数直接取HTTP头中的值,body必须是请求的原始JSON字符串,不能经过任何格式化或字段重排。
验签操作可以拆成几个明确步骤。第一步,读取HTTP头中的timestamp、nonce、signature和serial四个值,同时获取请求体原文。第二步,按照上面提到的顺序拼接验签串。第三步,根据serial找到对应的公钥。第四步,使用Java的Signature类执行SHA256withRSA算法验证签名的Base64解码值。第五步,检查timestamp与服务器当前时间的差值是否在合理范围内,通常建议不超过5分钟,防止旧请求被重放。
下面是一段Java验签的核心实现,其中publicKey需要提前从平台证书或微信支付公钥中加载。
import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Base64;
public class WechatPayNotifyVerifier {
public static boolean verifySignature(String timestamp, String nonce, String body,
String signature, PublicKey publicKey) throws Exception {
// 构造验签串:timestamp\nnonce\nbody\n
String message = timestamp + "\n" + nonce + "\n" + body + "\n";
Signature sign = Signature.getInstance("SHA256withRSA");
sign.initVerify(publicKey);
sign.update(message.getBytes(StandardCharsets.UTF_8));
return sign.verify(Base64.getDecoder().decode(signature));
}
}
验签过程中最容易出错的地方有两个:一是忘记在body后面追加最后一个换行符,二是对请求体做了不必要的格式化。微信支付要求body必须是原始报文,如果后台框架自动转换了JSON字段顺序或过滤了空字段,签名就会校验失败。因此建议在获取请求体时直接读取原始字节流,而不是先解析成对象再序列化回去。
三、解密退款通知中的resource字段
签名验证通过只能说明请求确实来自微信服务器,并且请求体没有被中间人篡改。但退款详情本身仍然处于加密状态,需要进一步解密resource字段。resource中通常包含algorithm、ciphertext、nonce和associated_data这几个子字段。algorithm的值为AEAD_AES_256_GCM,表示使用AES-256-GCM对称加密算法,密钥是商户在微信支付平台设置的APIv3密钥。
解密时要注意,ciphertext需要先进行Base64解码,nonce和associated_data则直接按原值使用。AES-GCM是一种带认证的加密算法,如果密文被篡改或者密钥不正确,解密过程会抛出异常,这也可以作为额外的一种完整性校验手段。解密后的内容是一段JSON字符串,里面包含out_refund_no、refund_id、refund_status等退款业务字段。
下面是一段使用Java解密resource字段的代码示例。
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 WechatPayResourceDecryptor {
public static String decryptResource(String ciphertext, String nonce,
String associatedData, byte[] apiV3Key) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(apiV3Key, "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8));
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
if (associatedData != null && !associatedData.isEmpty()) {
cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8));
}
byte[] plaintext = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
return new String(plaintext, StandardCharsets.UTF_8);
}
}
整个处理顺序应该是先验证签名,再解密resource。如果顺序颠倒,一旦请求来自伪造者,系统就可能解密出错误数据甚至触发异常,而验签的拦截作用就失去了意义。解密完成后,再把明文解析成退款结果对象,进入业务状态更新流程。
四、容易忽略的安全细节
时间戳校验是防止重放攻击的第一步,但仅有时间戳还不够。同一个时间窗口内,攻击者仍然可以截获合法通知并重复发送。因此nonce字段需要启用防重放机制。商户可以把最近处理过的nonce存入Redis或本地缓存,并设置一个短期过期时间,比如10分钟。如果发现同一个nonce已经在缓存中出现过,就直接丢弃该通知,避免重复处理导致资金或状态异常。
证书轮换也是一个实际运维中经常被忽略的问题。微信支付平台证书会定期更新,如果商户只保存了旧的公钥,新的通知签名就无法验证通过。建议将平台证书或微信支付公钥放入可动态更新的配置中心,并在验签失败时触发证书刷新逻辑。验签失败时,给微信返回非200状态码,微信会按照重试机制再次推送通知,但不要无条件刷新证书,最好先确认是否为证书过期导致。
日志脱敏同样重要。退款通知中包含商户订单号、微信退款单号以及加密密文,这些信息如果被完整打印到日志中,一旦日志系统被攻破就可能泄露交易数据。APIv3密钥更应该妥善保管,不要在代码仓库、前端页面或错误日志中出现。最终的业务状态建议同时结合微信支付查询退款接口的结果进行确认,通知可以作为触发条件,但不要把它当成唯一的资金依据。