对接过微信公众号支付的同学对退款结果通知不会陌生。商户系统收到回调后,需要先使用微信支付平台证书验证请求头里的签名,再处理退款状态。实际项目中经常出现一种情况:证书序列号、平台公钥都能对上,时间戳和随机字符串也有值,但验签就是不通过。这类问题很多时候与密钥配置无关,而是请求体在解码和再编码时不再等于微信签名时的原始字节,尤其在退款通知带有中文备注、中文退款原因时更容易出现。

微信支付v3接口的签名原文中,通知回调通常只包含三行:时间戳、随机字符串和请求体,也就是 timestamp + "\n" + nonce + "\n" + body + "\n"。这段签名原文必须按 UTF-8 编码后计算 SHA256withRSA。注意这里的 body 不是业务系统重新解析后的 JSON 对象,也不是美化或排序过的 JSON 字符串,而是微信服务器真正发送过来的原始请求体字节。即使两个 JSON 字符串看着一样,只要字符串内部的中文字节序列、空格、转义符或字段顺序有一处不同,验证签名就会失败。
一、签名机制与编码敏感的根源
微信支付v3的回调验签使用的是平台证书公钥,而不是商户API私钥。商户系统收到退款结果通知后,需要从请求头中取出 Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature 和 Wechatpay-Serial。其中 Wechatpay-Serial 用于匹配本地缓存或微信支付平台证书,Wechatpay-Signature 是微信使用平台私钥对签名原文做 Base64 编码后的结果。
签名原文的拼接规则并不复杂,但有一个非常容易忽略的点:body 必须是 HTTP 请求体原始字节按 UTF-8 解码得到的字符串。这里存在两层风险。第一层是框架读取请求体时没有使用 UTF-8,而是使用了 ISO-8859-1 或容器默认字符集;第二层是开发者拿到字符串后,又为了打印日志、包装实体或字段过滤对 JSON 做了反序列化和重新序列化。无论哪一层出现问题,都会改变中文字符的字节表示,最终导致 SHA256withRSA 验签失败。
可以用“退款”两个字简单验证这一点。原始 UTF-8 编码为 E9 80 80 E6 AC BE,如果先按 ISO-8859-1 读入,再按 UTF-8 输出,字节会变成 C3 A9 C2 80 C2 80 C3 A6 C2 AC C2 BE。签名原文本身没有变化,但 body 的字节变了,算出来的签名自然对不上。
二、导致编码不一致的常见写法
Java 项目里最常见的错误是用 request.getReader() 或 request.getParameter() 读取请求体。Servlet 容器在创建 Reader 时会根据请求头中的 Content-Type 决定字符集,但如果应用或中间件没有正确传递该字符集,容器可能回退到 ISO-8859-1。这样读出来的字符串虽然可以显示某些字符,但它的实际 Unicode 码点已经不是原始 UTF-8 字节对应的汉字。
// 错误示例:用 Reader 读取,容器可能按 ISO-8859-1 解码
StringBuilder sb = new StringBuilder();
try (BufferedReader reader = request.getReader()) {
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
}
String body = sb.toString();
String signSource = timestamp + "\n" + nonce + "\n" + body + "\n";
还有一种情况是在拿到 body 后调用 body.getBytes(StandardCharsets.UTF_8),以为这样可以恢复原始字节。实际上如果 body 已经是被错误解码后的字符串,再按 UTF-8 编码只会产生二次转换,无法得到微信发送的原始字节。类似地,JSON 解析也需要小心。用 fastjson、Jackson 或 Gson 把 body 转成对象后,再调用 toJSONString、writeValueAsString 重新生成字符串,字段顺序、空格、数字格式、转义规则都可能与微信原始报文不同。
PHP 项目里的问题多出现在 json_decode 和 json_encode 的组合上。例如先用 json_decode($input, true) 转成数组,再 json_encode($arr, JSON_UNESCAPED_UNICODE) 生成字符串。即使加了 JSON_UNESCAPED_UNICODE,数组的排序、浮点精度、斜杠转义等仍然可能和原始 JSON 不一致。微信退款通知中的金额字段可能带小数,不同 PHP 版本对浮点数的表示也会有差异。
<?php
// 错误示例:反序列化后重新序列化,body 已不再等于原始报文
$input = file_get_contents('php://input');
$arr = json_decode($input, true);
$body = json_encode($arr, JSON_UNESCAPED_UNICODE);
$signSource = $timestamp . "\n" . $nonce . "\n" . $body . "\n";
Python Flask 或 Django 中,request.get_json() 会根据请求头选择解析方式,并且内部可能已经做了字符集处理。解析后再 json.dumps,默认会使用空格分隔符,还可能会转义非 ASCII 字符。正确做法是直接读取原始字节,不要经过业务对象转换。
三、保留原始字节流的稳定验签写法
验签的核心原则只有一个:微信发来什么字节,就用什么字节参与签名计算。不要试图把请求体先读成字符串再编码回去,更不要对 JSON 做任何格式化处理。Java 中可以从 HttpServletRequest 的 InputStream 直接读取字节数组,然后用 UTF-8 构造字符串,这样字符串和原始字节之间的对应关系是确定的。之后再用该字符串拼接签名原文并计算签名。
public boolean verifyNotify(HttpServletRequest request, String platformPublicKey) throws Exception {
byte[] bodyBytes = readBody(request.getInputStream());
String body = new String(bodyBytes, StandardCharsets.UTF_8);
String timestamp = request.getHeader("Wechatpay-Timestamp");
String nonce = request.getHeader("Wechatpay-Nonce");
String signature = request.getHeader("Wechatpay-Signature");
String serial = request.getHeader("Wechatpay-Serial");
String signSource = timestamp + "\n" + nonce + "\n" + body + "\n";
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initVerify(getCertificate(serial).getPublicKey());
signer.update(signSource.getBytes(StandardCharsets.UTF_8));
return signer.verify(Base64.getDecoder().decode(signature));
}
private byte[] readBody(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int len;
while ((len = in.read(buf)) != -1) {
out.write(buf, 0, len);
}
return out.toByteArray();
}
PHP 中处理起来更直接,用 file_get_contents('php://input') 拿到的就是原始 POST 请求体字符串,之后不要做 json_decode、json_encode、iconv 或 mb_convert_encoding 等操作。直接拼签名原文并用 openssl_verify 验证。
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_WECHATPAY_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_WECHATPAY_NONCE'] ?? '';
$signature = $_SERVER['HTTP_WECHATPAY_SIGNATURE'] ?? '';
$signSource = $timestamp . "\n" . $nonce . "\n" . $body . "\n";
$publicKey = openssl_pkey_get_public(file_get_contents('/path/platform_cert.pem'));
$result = openssl_verify($signSource, base64_decode($signature), $publicKey, OPENSSL_ALGO_SHA256);
Python Flask 中推荐使用 request.get_data(),这个 API 返回未经处理的请求体字节。验签时直接使用这些字节即可。如果一定要转字符串用于日志,也要明确使用 UTF-8 解码,并且日志字符串不要参与验签。
from flask import request
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
import base64
body = request.get_data()
timestamp = request.headers.get('Wechatpay-Timestamp')
nonce = request.headers.get('Wechatpay-Nonce')
signature = request.headers.get('Wechatpay-Signature')
message = (timestamp + "\n" + nonce + "\n" + body.decode('utf-8') + "\n").encode('utf-8')
# 使用 message 作为验签数据,不要使用 json.dumps(request.get_json()) 的结果
如果使用的是微信支付官方 SDK,一般会封装好验签逻辑,但传入的 body 仍然必须是原始请求体。部分开发者为了调试方便,会在进入 SDK 前对请求体做过滤、脱敏或格式化,这恰恰会把原始字节破坏掉。建议在过滤器或中间件中只做日志记录,不要修改请求体本身。
四、从日志和工具层面快速定位编码问题
验签失败后可以先不要把重点放在证书上。平台证书如果配置错误,通常会在加载证书或匹配序列号阶段就报错,而不是到最后一步签名不匹配。更值得先做的是记录请求体字节快照。比如打印 bodyBytes.length、前 16 个字节的十六进制值、以及按 UTF-8 解码后的可读字符串。如果日志里中文显示正常,但验签仍然失败,也要怀疑请求体是否在进入业务逻辑前已经被框架改过。
可以在本地写一个最小验签工具,用微信支付平台证书直接对收到的请求头、请求体验签。若本地保存的原始请求体能通过验签,而经过业务代码处理后的字符串不能通过,就说明问题出在编码或序列化环节。也可以对比两种方式生成的签名原文长度和 MD5。如果长度相同但 MD5 不同,多半是某些不可见字符或转义规则发生了变化。
服务器层面的编码设置也要检查。Java 应用可以增加 -Dfile.encoding=UTF-8,Tomcat 的 URIEncoding 参数影响的是 URL 查询串,不等同于 POST body 的字符集。真正影响 POST body 解码的是框架的字符编码过滤器。Spring Boot 可以配置 server.servlet.encoding.charset=UTF-8 以及 server.servlet.encoding.force=true,让请求编码统一走 UTF-8。PHP 需确认 default_charset 为 UTF-8,并避免在请求体上做 iconv 转换。
还有一点容易被忽略:验签失败时商户系统应该返回 4xx 或 5xx 状态码,不要返回 200 成功。微信支付退款结果通知有重试机制,如果商户错误地返回成功,但又因为验签失败没有处理订单,后续通知可能不再送达。换句话说,验签不通过时既不能更新退款状态,也不能确认接收。只有原始字节流验签通过,再返回 200 或 204,才是安全流程。
回到字符编码不一致这个问题本身,解决办法并不复杂:把请求体当成不可变的二进制数据,从入口处一次性读取并保留原始字节;签名验签只使用这个原始字节对应的 UTF-8 字符串;业务解析可以另外做一遍,但解析后的对象绝不能反向生成字符串参与验签。按照这个原则,绝大多数退款结果通知验签失败都能快速定位和修复。