支付回调必须严格满足HTTP 200状态码且响应体恰好为字符串“success”,任何额外字符、换行或中间件提前写头都会导致微信持续重试,并可能引发Shutdown死锁;需禁用全局中间件、独立处理、验签解密双校验、数据库唯一索引保障幂等。

支付回调必须在 HTTP server shutdown 前完成处理
微服务下线时若正在处理支付回调,http.Server.Shutdown() 会等待其结束——但前提是 handler 没有被中间件或日志提前写响应头。一旦 w.WriteHeader() 被调用(哪怕只是 log 中间件输出了 trace ID),后续 fmt.Fprint(w, "success") 就会失败,微信持续重试,而 Shutdown 又卡在等这个请求“自然结束”,形成死锁。
常见错误:
- 在 handler 开头打日志:
log.Printf("got wx callback: %s", r.RemoteAddr)→ 日志库默认写到os.Stderr安全,但若用了自定义 writer 写入 HTTP response body 或 header,就会触发.WriteHeader() - 用了 Gin 的
c.AbortWithStatusJSON(200, ...)响应回调 → 微信只认纯字符串success,且不能带换行、空格、JSON 包裹 - handler 内部调用了其他 HTTP client 并设置了超时,但没设 context 传递给
Shutdown()→ 请求看似结束了,其实 goroutine 还在后台跑
正确做法:
- 回调 handler 必须是独立 endpoint,不经过任何全局中间件(尤其不要加 CORS、JWT 验证)
- 所有日志用
log或结构化 logger 直接输出到文件/标准输出,禁止写入http.ResponseWriter - 用
io.ReadAll(r.Body)读原始 body,不要用r.ParseForm()或c.ShouldBindXML()(Gin 默认会尝试解析,可能 panic) - 验签通过后才更新数据库,且更新操作必须带
context.WithTimeout(ctx, 5*time.Second),避免事务卡住 Shutdown
微信 V3 回调必须解密 + 验签双校验
V3 回调不是明文 XML,而是 AES-GCM 加密的 JSON;直接按 V2 方式解析 xml.Unmarshal() 会得到空结构体或 panic。平台证书、APIv3 key、商户私钥三者缺一不可,且顺序不能错。
立即学习“go语言免费学习笔记(深入)”;
关键点:
- 加密 payload 在 HTTP header
wechatpay-serial对应的证书下解密,不是用你本地的商户证书 - 解密前必须先验证
wechatpay-timestamp和wechatpay-nonce,防止重放攻击;时间戳偏差超过 5 分钟直接拒绝 - 解密后得到 JSON,字段如
resource.algorithm是AES-GCM,resource.ciphertext才是真正要解的 base64 字符串 - SDK
wechatpay-go的Decryptor.Decrypt()返回的是原始字节,需再json.Unmarshal()到 struct,不是直接能用的 map
容易踩的坑:
- 把平台证书 PEM 文件整个读进来传给
utils.LoadCertificateWithPath()→ 实际只需证书内容,不能带-----BEGIN CERTIFICATE-----头尾 - 误用 V2 的 MD5 签名逻辑去验 V3 回调 → V3 不走 sign 字段,靠 header + body + timestamp + nonce 拼出签名串,用平台公钥验
- 解密后没检查
resource.decrypt_error字段(存在时说明解密失败,但 HTTP 状态仍是 200)→ 导致业务逻辑继续执行,订单状态错乱
幂等性必须靠数据库唯一约束兜底
微信回调重试无规律,可能 1 秒内连发 3 次,也可能隔 15 分钟再补一次。仅靠内存 cache(如 sync.Map)或 Redis TTL 无法保证跨进程、重启后的幂等。
实操建议:
- 订单表加联合唯一索引:
UNIQUE KEY `uk_out_trade_no_notify_time` (`out_trade_no`, `notify_time`),其中notify_time用回调里的event_time字段(ISO8601 时间字符串) - 插入前先
INSERT ... ON CONFLICT DO NOTHING(PostgreSQL)或INSERT IGNORE(MySQL),失败即跳过处理 - 不要依赖
SELECT ... FOR UPDATE锁单条记录 → 高并发下易死锁,且无法防跨实例重复 - 退款回调同理,但要用
out_refund_no+event_time组合去重
注意:微信回调里 out_trade_no 是商户订单号,transaction_id 是微信订单号,二者都可能重复(比如用户多次支付同一订单),所以不能单靠它们做幂等键。
回调响应必须严格满足微信格式要求
微信判定回调成功的唯一条件是:HTTP 状态码 200 + 响应体**恰好等于字符串 success**(无空格、无换行、无 BOM、无任何额外字符)。任何偏差都会触发持续重试,最多 5 次,间隔指数增长。
典型问题:
-
fmt.Fprint(w, "success\n")→ 多了一个换行,失败 -
w.Write([]byte("success"))→ 正确,但若前面有w.Header().Set("Content-Type", "..."),部分反向代理会自动加\r\n,导致响应体污染 - handler 函数末尾多写了
return nil→ Gin 会自动返回 200,但 body 是空的 - 用了
http.Error(w, "success", http.StatusOK)→ 自动加 HTML 包裹,失败
安全写法只有一行:
fmt.Fprint(w, "success")
且该语句必须是 handler 函数最后一行可执行代码,前面不能有任何 w. 调用(包括 w.WriteHeader())。
复杂点在于:你永远不知道 Nginx、Cloudflare 或 Kubernetes Ingress 是否悄悄改了响应体。上线前务必用 curl -v 直连服务 IP,确认响应体确实是裸字符串 success,不多不少。


















