不能直接在路由handler中写解密逻辑,因会导致重复代码、错误处理分散、无法统一拦截非法请求;应设计可复用中间件,按平台差异分switch处理验签解密,并重放body供后续绑定。

为什么不能直接在路由里写解密逻辑
把微信/支付宝/飞书等平台的回调解密、验签、时间戳校验全塞进 GET 或 POST 的 handler 里,短期能跑通,但很快会暴露三个硬伤:一是每个回调接口都要重复写一遍密钥加载、AES/GCM 解密、HMAC 校验;二是错误处理分散(比如验签失败该返回 401 还是 400?是否要记录原始密文?);三是无法统一拦截非法请求——攻击者伪造回调体绕过中间件,直接打到业务层,可能触发未授权操作。
如何设计一个可复用的开放平台回调中间件
核心是把「平台差异」和「通用流程」拆开。以微信支付回调为例,中间件需完成:读取原始 body(禁用自动绑定)、验签、解密、还原成明文 JSON、注入到 c.Request.Body 供后续绑定使用。关键点如下:
- 必须调用
c.Request.Body = io.NopCloser(bytes.NewReader(decrypted))替换原始 body,否则c.ShouldBindJSON()会读空流 - 验签失败时用
c.AbortWithStatusJSON(401, gin.H{"code": "SIGN_VERIFY_FAILED"})终止链,不执行后续 handler - 密钥和配置建议从
c.MustGet("config")获取,避免硬编码或全局变量(利于多租户场景) - 微信要求回调 URL 必须返回
success纯文本,所以中间件末尾不能提前写响应,要留给 handler 控制
微信/支付宝/飞书回调中间件的参数差异
不同平台对签名字段、加密方式、时间戳容忍窗口的要求完全不同,不能共用一套参数结构。例如:
- 微信支付 v3:签名头是
wechatpay-signature,密钥是平台证书私钥,解密用 AEAD_AES_256_GCM,时间戳误差 ≤ 5 分钟 - 支付宝:签名字段在 query 中叫
sign,验签用 RSA2,body 是原始 form 表单(非 JSON),需先c.PostForm提取再验 - 飞书:签名头为
X-Lark-Signature-V2,密钥是应用 secret,HMAC-SHA256,且要求X-Lark-Timestamp与服务端时间差 ≤ 30 秒
这意味着中间件函数签名最好带平台标识:OpenPlatformMiddleware(platform string, opts OpenPlatformOptions),内部再分 switch 处理。
容易被忽略的坑:Body 读取与重放
Gin 默认会把请求 body 读一次后丢弃,而开放平台回调往往需要多次读取(验签读一次、解密读一次、业务绑定再读一次)。不处理好就会出现 http: request body closed 或绑定为空对象。正确做法是:
- 在中间件开头用
body, _ := io.ReadAll(c.Request.Body)一次性读完 - 验签和解密都基于这个
body字节切片操作 - 解密成功后,用
io.NopCloser(bytes.NewReader(newBody))重建Request.Body - 绝对不要在中间件里调用
c.Bind或c.ShouldBindJSON—— 那是 handler 的事
最麻烦的是飞书回调:它把 timestamp 和 nonce 都放在 header 里,但签名值却要对「header + body」拼接后计算。这意味着你必须在读 body 前就拿到 header 值,然后等 body 读完再一起验签——顺序不能错。











