微信v3回调验签必须读取原始body,因gin默认解析会破坏验签串一致性;需用c.request.body直接读取字节流,按“时间戳\n随机串\n报文主体\n”格式构造签名串,严格匹配换行与header大小写,并用pem公钥+sha256+rsa验证,成功后立即返回纯文本“success”。

微信V3回调验签必须读原始Body
微信V3异步通知的验签失败,90%以上是因为没拿到原始请求体。Gin默认的c.ShouldBindJSON()或c.PostForm()会触发自动解析、转码、去空格等操作,导致验签串与微信生成的不一致。
正确做法是用c.Request.Body直接读取字节流,并确保全程不修改内容(包括不调用io.ReadAll两次、不重复io.NopCloser包装)。
- 先调
io.ReadAll(c.Request.Body)一次,得到原始[]byte - 把该字节切片转为
string用于验签计算 - 再用
bytes.NewReader()重新包装成io.ReadCloser,赋给c.Request.Body供后续解析使用 - 如果后续还要解析XML/JSON,务必用
xml.Unmarshal()或json.Unmarshal()直接操作原始字节,别走Gin绑定流程
验签串构造不能漏掉换行符
微信要求的验签名串是三行拼接,每行末尾必须是\n(ASCII 0x0A),最后一行也必须带换行。常见错误是用fmt.Sprintf("%s\n%s\n%s", ts, nonce, body)但body本身含\r\n或结尾无换行,导致签名不匹配。
安全写法是显式拼接:
signString := fmt.Sprintf("%s\n%s\n%s\n", ts, nonce, string(rawBody))
其中ts和nonce从Wechatpay-Timestamp、Wechatpay-Nonce Header中取,rawBody就是上一步读出的原始字节。
- Header键名区分大小写,必须用
c.GetHeader("Wechatpay-Timestamp"),不能写"wechatpay-timestamp" - 如果
rawBody为空(极少见),最后一行仍应为\n,即signString := fmt.Sprintf("%s\n%s\n\n", ts, nonce) - 验签前先校验
Wechatpay-Serial头是否匹配你本地加载的公钥ID,不匹配直接拒收
公钥加载和RSA验证要避开常见坑
微信公钥是PEM格式文本,不是证书文件,也不是PKCS#8私钥。直接用crypto/x509的ParsePKIXPublicKey()会panic,必须用ParsePKIXPublicKey的前置步骤——先用pem.Decode()解包,再传给x509.ParsePKIXPublicKey()。
验证时要用crypto/sha256.New()哈希,且必须用rsa.VerifyPKCS1v15(),参数中的crypto.SHA256不能省略。
- 公钥文件内容开头必须是
-----BEGIN PUBLIC KEY-----,结尾-----END PUBLIC KEY-----,中间Base64编码不能换行或加空格 - 验签失败时,先打印
signString和signatureBytes(hex编码),比对微信文档示例,确认格式完全一致 - 不要在handler里每次重读公钥文件,应提前加载到全局变量或注入到
gin.Context中
验签通过后必须立即返回success且不带任何额外输出
微信服务器只认纯文本success(小写,无空格,无换行,无HTML标签)。任何额外字符(比如fmt.Println残留、log输出、JSON包装)都会让微信判定为失败,触发重试。
Gin里最稳妥的写法是:
c.Data(200, "text/plain; charset=utf-8", []byte("success"))
- 绝对不要用
c.String(200, "success"),它默认加\n - 不要在return前写日志到HTTP响应体,如
c.JSON(200, gin.H{"msg": "ok"}) - 验签失败或业务异常时,也必须返回
fail字符串(同样纯文本),否则微信会持续重发,可能压垮服务
真正容易被忽略的是:验签和业务处理必须原子化——哪怕验签成功,后续DB更新失败,也不能返回success;而一旦返回success,就代表你已确认支付结果并完成所有状态变更。这个边界模糊点,是线上资损最常见的根源。











