验签必须在中间件中统一处理,因需覆盖所有回调入口、避免业务逻辑污染、确保原始 body 仅读一次且按平台规则精确拼接签名原文,失败时返回协议要求的纯文本响应。

为什么验签必须在中间件里做,而不是 handler 开头
因为验签逻辑要覆盖所有回调入口(如 /pay/notify、/oauth/callback),而 handler 是业务终点,重复写会散落在各处、漏校验、难维护。中间件能统一拦截、提前终止非法请求——比如微信回调要求失败时返回纯字符串 fail,若放在 handler 里,可能已经执行了部分业务逻辑(如扣库存)再返回,造成状态不一致。
验签中间件必须手动读原始 body,不能用 c.FormValue 或 c.Bind()
支付宝回调是 application/x-www-form-urlencoded,微信是 application/xml,PayJS 是 JSON —— 它们的 sign 字段都嵌在原始 body 里,且签名原文必须保持原始编码顺序和大小写。一旦调用 c.FormValue("sign") 或 c.Bind(),Go 的标准库会自动解析并丢弃原始流,导致后续无法重读,payjs.ParseNotify() 或自定义验签函数直接 panic:http: read on closed response body。
Echo框架 5.1.0 版本源码包下载,适合关注 RealIP 行为变化、StartConfig.Listener、NewDefaultFS 和观测性中间件入口的开发团队。
- 正确做法:用
io.ReadAll(c.Request().Body)一次性读取原始字节,再分别解析和验签 - 读完后需用
io.NopCloser重新包装,才能让后续c.Bind()正常工作(仅当 handler 确实需要结构化解析时) - 务必先校验
c.Request().Header.Get("Content-Type"),拒绝非预期类型(如伪造的application/json请求绕过 XML 校验)
签名原文拼接规则因平台差异极大,不能复用同一套逻辑
微信 v2 签名是「所有非空字段按字典序拼接 + key」再 MD5;支付宝是「除去 sign 和 sign_type 后,按参数名升序拼接键值对」再 RSA/SHA256;PayJS 是「JSON body 原文 + 商户密钥」计算 HMAC-SHA256。混用会导致 100% 验签失败。
- 微信 XML 回调:用
xml.Unmarshal解析后,剔除sign字段,按 map key 字典序拼接(注意字段名全小写) - 支付宝 form 回调:用
url.ParseQuery(string(rawBody))解析,手动过滤sign和sign_type,再排序拼接 - PayJS JSON 回调:直接对
rawBody计算hmac.New(...).Sum(nil),别先json.Unmarshal再拼——浮点数精度、空格、换行都会破坏原文 - 所有密钥必须从配置加载,禁止硬编码;v2 密钥是 32 字节纯字母数字,v3 是 PEM 私钥,别传错类型
验签失败必须立即返回规范响应,且不能触发 recovery 中间件
微信收到非 fail 响应(哪怕 HTTP 200 + {"code":500})会持续重试;支付宝验签失败返回非 200 或无 success 字段也会反复推送。更关键的是,如果中间件里 panic(比如 XML 解析出错),会被顶层 echo.MiddlewareFunc(recovery.Recovery()) 捕获,返回 HTML 错误页或 JSON stack trace——这完全不符合第三方平台的协议要求。
- 失败时调用
c.String(http.StatusOK, "fail")(微信)或c.String(http.StatusOK, "success")(支付宝成功通知) - 全程用
if err != nil判断,避免任何未处理的 panic;XML 解析前加xml.Name.Local == "xml"根节点白名单 - 不要在验签中间件里调用
c.JSON()或c.XML(),它们会设置Content-Type头,而微信只要纯文本 - 日志记录要包含原始 body 哈希(如
sha256.Sum256(rawBody).String()[:12]),便于事后审计,但别打明文敏感字段










