因为默认 recovery 中间件不读取 c.request.body,而 request.body 是单次读取流,panic 发生前若未缓存,后续无法获取原始请求体;需在 panic 触发前用中间件预读并缓存 body 到 c.set("raw_body"),再于自定义 recovery 中提取记录。

为什么默认 Recovery 中间件不记录请求体
Gin 的 Recovery 中间件只捕获 panic 并打印堆栈,它不会调用 c.Request.Body,更不会读取原始 payload。一旦你调用 c.ShouldBindJSON() 或 c.GetRawData() 之前发生 panic,请求体就永远丢失——因为 Request.Body 是单次读取流,被 Gin 内部解析逻辑(如绑定)或中间件跳过时,后续再读就是空。
如何在 panic 发生时拿到完整原始 Payload
关键是在 panic 触发前,把原始 body 缓存下来,并确保它对 Recovery 可见。Gin 不允许中间件修改 c.Request 的底层 Body 字段(它是 io.ReadCloser),但你可以用 c.Set() 把缓存的字节存进去,然后在自定义 Recovery 里取。
- 必须在所有可能触发 panic 的逻辑(比如绑定、业务 handler)之前执行 body 缓存
- 缓存后要重置
c.Request.Body,否则后续c.ShouldBindJSON()会读到空 - 推荐使用
c.Request.ContentLength做上限限制,防止恶意大 payload 耗尽内存
示例中间件:
func CacheRequestBody(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Body == nil {
c.Next()
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxSize))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
return
}
c.Set("raw_body", body)
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
c.Next()
}
}
自定义 Recovery 中如何安全提取并记录 Payload
原生 Recovery 是闭包形式,没法直接注入逻辑。你需要自己实现一个等效中间件,在 recover 后从 c.Get("raw_body") 取数据,并写入日志或上报系统。
- 务必检查
c.Get("raw_body")是否存在且为[]byte类型,避免 panic 嵌套 - 不要在日志中直接打印完整 payload(尤其含敏感字段),建议只记录长度、哈希或采样前 200 字符
- 如果用了 gzip 请求,
c.Request.Header.Get("Content-Encoding")为"gzip",此时缓存的是压缩后字节,解压需额外处理
简版 Recovery 片段:
func CustomRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
rawBody, _ := c.Get("raw_body")
if b, ok := rawBody.([]byte); ok && len(b) > 0 {
log.Printf("[PANIC] path=%s method=%s body-len=%d body-hash=%x",
c.Request.URL.Path, c.Request.Method, len(b), md5.Sum(b)[:8])
} else {
log.Printf("[PANIC] path=%s method=%s no raw body cached", c.Request.URL.Path, c.Request.Method)
}
c.AbortWithStatus(http.StatusInternalServerError)
}
}()
c.Next()
}
}
容易忽略的边界情况和兼容性问题
这个方案在多数场景下可行,但几个点不注意就会失效:
-
multipart/form-data请求:Gin 默认会调用c.FormFile()或c.PostForm()触发ParseMultipartForm,这会消耗 body 流——必须在CacheRequestBody之后、任何表单解析之前注册该中间件 - HTTP/2 或 streaming 请求(如 SSE):body 可能是分块传输,
io.ReadAll会阻塞直到结束,不适合长连接场景 - 某些代理(如 Nginx)可能重写
Content-Length或去掉 header,导致LimitReader截断或放行超限内容
真正难的不是“怎么记”,而是“什么时候记、记哪部分、记完还能不能继续用”——这三个判断点,每个都得贴着你的 API 协议和部署链路来调。











