支付回调必须做幂等,因为微信、支付宝等平台会在回调失败时重试,可能多次发送完全相同的请求,若不控制则导致重复扣款或状态错乱;Gin 作为轻量 HTTP 框架,不自动识别重复请求、不存储请求指纹,需开发者在具体 handler 中按“验签→解析→Redis 幂等校验(key 含 out_trade_no)→业务执行”顺序手动实现。

为什么支付回调必须做幂等,而 Gin 默认不处理
Gin 本身只是 HTTP 路由和中间件框架,它不会自动识别重复请求、也不存储请求指纹。支付平台(如微信、支付宝)在回调失败时会主动重试,可能间隔几秒到几十分钟发来完全相同的 POST 请求——body、timestamp、nonce、sign 全都一样。如果你直接解析并执行扣库存、改订单状态、发消息,就大概率出现“一次支付扣两次款”或“订单变成已发货又变回待支付”。
关键不是“能不能拦住重复请求”,而是“能不能让重复请求的副作用只发生一次”。
用 Redis + 订单号 + 时间窗口实现最简幂等控制
这是生产中最常用、最易落地的方式,不依赖分布式锁,也不需要引入复杂组件。
- 幂等 key 必须包含业务唯一标识,比如微信回调里的
out_trade_no,支付宝里的out_trade_no或trade_no - 不要用整个
body做 hash 当 key:签名字段(如sign)可能因密钥轮换或拼接顺序微调而变化,导致误判 - 过期时间建议设为 24 小时:
redis.Set(ctx, "pay:dedup:"+outTradeNo, "1", 24*time.Hour),太短可能拦截不了真实重试,太长会堆积无效 key - 写入前先
GET判断是否存在,存在则直接返回成功(HTTP 200 + 正确响应体),避免下游逻辑重复执行 - 注意:Redis 操作必须是原子的,推荐用
SET key value EX seconds NX(即redis.SetNX),防止竞态写入
if ok, err := rdb.SetNX(ctx, "pay:dedup:"+outTradeNo, "1", 24*time.Hour).Result(); err != nil {
// redis 错误,不能跳过,应记录并返回 500
log.Error("redis setnx fail", "err", err)
c.AbortWithStatusJSON(500, gin.H{"msg": "system error"})
return
} else if !ok {
// 已存在,说明是重放请求
c.JSON(200, gin.H{"code": 0, "msg": "success"}) // 微信要求固定格式
return
}别在 Gin 中间件里统一做幂等校验
看似省事,但实际会踩三个坑:
立即学习“go语言免费学习笔记(深入)”;
- 支付回调接口通常不需要鉴权、不走 JWT,中间件里硬塞统一校验逻辑,反而让正常接口被误拦
- 不同支付渠道字段名不同(微信用
out_trade_no,支付宝用out_trade_no或trade_no),中间件无法泛化提取 - 回调验签必须在幂等之前做!否则攻击者可伪造任意
out_trade_no打满 Redis。正确顺序是:读 body → 验签 → 解析业务字段 → 幂等检查 → 业务处理
所以幂等逻辑必须写在具体回调 handler 里,且紧挨着验签之后。
注意 body 读取只能一次,别让 Gin 自动绑定抢了流
Gin 的 c.ShouldBindJSON() 或 c.PostForm() 内部会调用 c.Request.Body,读完就关闭。如果你先验签(需原始 body),再调用绑定,就会得到空 body 或 io.EOF。
- 必须手动缓存:用
ioutil.ReadAll(c.Request.Body)(Go 1.16+ 用io.ReadAll)读一次,然后用bytes.NewReader重建两个 stream - 微信回调是 XML,支付宝是 form 或 JSON,要按 Content-Type 分支处理,不能无脑
ShouldBindJSON - 验签函数里传入的是原始字节,幂等 key 从解析后的结构体里取,两者不要混用同一份未解析数据
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"msg": "read body fail"})
return
}
// 重建 Body 供后续绑定使用
c.Request.Body = io.NopCloser(bytes.NewReader(body))
<p>// 验签(需原始 body)
if !verifyWechatSign(body, c.Request.FormValue("sign")) {
c.AbortWithStatusJSON(401, gin.H{"msg": "sign invalid"})
return
}</p><p>// 解析 XML 得到 out_trade_no
var req WechatNotifyReq
if err := xml.Unmarshal(body, &req); err != nil {
c.AbortWithStatusJSON(400, gin.H{"msg": "parse xml fail"})
return
}</p><p>// 此时才能用 req.OutTradeNo 做幂等 keyGin 不管你是不是在处理支付,它只管转发请求;幂等性是你对业务语义的理解和落地。最容易被忽略的点是:验签和幂等的先后顺序、body 复用方式、以及 key 构造是否真的稳定——这三个地方错一个,整个幂等就形同虚设。


















