From 8a023dfd873ff624f7905df2cf6ca0ff77d88750 Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Sat, 28 Feb 2026 15:49:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(middleware):=20=E8=AF=B7=E6=B1=82=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E5=A2=9E=E5=BC=BA=20=E2=80=94=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=8F=82=E6=95=B0=20+=20=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Query 参数:INFO 级别始终记录(所有环境生效) - Request Body:INFO 级别始终记录(所有请求方式) - 文件上传自动跳过,仅标记 [file upload] - 敏感路径(登录/注册/改密码)自动脱敏 password 字段 - Body 超过 4KB 自动截断 - Response Body:DEBUG 级别记录正常响应,4xx/5xx 错误在 INFO 也记录 - 响应 Body 最大记录 2KB - 状态码分级:5xx→ERROR / 4xx→WARN / 慢请求→WARN / 正常→INFO Made-with: Cursor --- backend/go-service/pkg/middleware/logger.go | 158 +++++++++++++++++++- 1 file changed, 155 insertions(+), 3 deletions(-) diff --git a/backend/go-service/pkg/middleware/logger.go b/backend/go-service/pkg/middleware/logger.go index 4aeab21..716ed22 100644 --- a/backend/go-service/pkg/middleware/logger.go +++ b/backend/go-service/pkg/middleware/logger.go @@ -1,6 +1,9 @@ package middleware import ( + "bytes" + "io" + "strings" "time" "github.com/echochat/backend/pkg/logs" @@ -8,34 +11,118 @@ import ( "go.uber.org/zap" ) +const ( + maxBodyLogSize = 4096 // 请求/响应 Body 记录的最大字节数 + maxResponseLogSize = 2048 // 响应 Body 记录的最大字节数 +) + +// 敏感路径:这些路径的请求 Body 中密码字段需要脱敏 +var sensitivePathPrefixes = []string{ + "/api/v1/auth/login", + "/api/v1/auth/register", + "/api/v1/admin/auth/login", + "/api/v1/auth/password", +} + +// responseBodyWriter 包装 gin.ResponseWriter 以捕获响应 Body +type responseBodyWriter struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (w *responseBodyWriter) Write(b []byte) (int, error) { + if w.body.Len() < maxResponseLogSize { + remaining := maxResponseLogSize - w.body.Len() + if len(b) > remaining { + w.body.Write(b[:remaining]) + } else { + w.body.Write(b) + } + } + return w.ResponseWriter.Write(b) +} + // Logger 请求日志中间件 -// 记录每个请求的完整信息:方法、路径、状态码、耗时、IP、User-Agent +// 记录每个请求的完整信息:方法、路径、请求参数、状态码、耗时、IP、User-Agent +// 请求参数(Query + Body)在 INFO 级别记录(所有环境生效) +// 响应 Body 在 DEBUG 级别记录,错误响应(4xx/5xx)在 INFO 级别也记录 // 自动携带 trace_id,慢请求(>500ms)记录 WARN func Logger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() + // --- 请求阶段:捕获请求参数 --- + query := c.Request.URL.RawQuery + reqBody := readRequestBody(c) + + // 包装 ResponseWriter 以捕获响应 + rbw := &responseBodyWriter{ + ResponseWriter: c.Writer, + body: bytes.NewBufferString(""), + } + c.Writer = rbw + c.Next() + // --- 响应阶段:记录日志 --- latency := time.Since(start) ctx := c.Request.Context() funcName := "middleware.Logger" + status := rbw.Status() + path := c.Request.URL.Path fields := []zap.Field{ zap.String("method", c.Request.Method), - zap.String("path", c.Request.URL.Path), - zap.Int("status", c.Writer.Status()), + zap.String("path", path), + zap.Int("status", status), zap.Duration("latency", latency), zap.String("ip", c.ClientIP()), zap.String("user_agent", c.Request.UserAgent()), } + // Query 参数(始终记录) + if query != "" { + fields = append(fields, zap.String("query", query)) + } + + // Request Body(始终记录,敏感路径脱敏) + if reqBody != "" { + if isSensitivePath(path) { + reqBody = maskSensitiveBody(reqBody) + } + fields = append(fields, zap.String("req_body", reqBody)) + } + + // Response Body:错误响应(4xx/5xx)始终记录,正常响应仅 DEBUG + respBody := rbw.body.String() + if respBody != "" { + if status >= 400 { + fields = append(fields, zap.String("resp_body", truncate(respBody, maxResponseLogSize))) + } else { + logs.Debug(ctx, funcName, "响应数据", + zap.String("path", path), + zap.String("resp_body", truncate(respBody, maxResponseLogSize)), + ) + } + } + + // 按状态分级输出 if len(c.Errors) > 0 { fields = append(fields, zap.String("error", c.Errors.String())) logs.Error(ctx, funcName, "请求处理异常", fields...) return } + if status >= 500 { + logs.Error(ctx, funcName, "服务器错误", fields...) + return + } + + if status >= 400 { + logs.Warn(ctx, funcName, "客户端错误", fields...) + return + } + if latency > 500*time.Millisecond { logs.Warn(ctx, funcName, "慢请求告警", fields...) return @@ -44,3 +131,68 @@ func Logger() gin.HandlerFunc { logs.Info(ctx, funcName, "请求完成", fields...) } } + +// readRequestBody 读取请求 Body(读完后重新填回,不影响后续 Handler) +func readRequestBody(c *gin.Context) string { + if c.Request.Body == nil { + return "" + } + + // 文件上传不记录 Body + contentType := c.GetHeader("Content-Type") + if strings.Contains(contentType, "multipart/form-data") { + return "[file upload]" + } + + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxBodyLogSize+1)) + if err != nil { + return "[read error]" + } + // 将 Body 重新填回,供后续 Handler 使用 + c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + + if len(body) > maxBodyLogSize { + return string(body[:maxBodyLogSize]) + "...[truncated]" + } + return string(body) +} + +// isSensitivePath 判断是否为敏感路径(包含密码等字段的接口) +func isSensitivePath(path string) bool { + for _, prefix := range sensitivePathPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +// maskSensitiveBody 对敏感 Body 中的密码字段进行脱敏 +// 简单策略:将 "password":"xxx" 替换为 "password":"***" +func maskSensitiveBody(body string) string { + // 处理 JSON 中的 password 字段 + for _, field := range []string{"password", "old_password", "new_password", "confirm_password"} { + for { + key := `"` + field + `":"` + idx := strings.Index(body, key) + if idx < 0 { + break + } + start := idx + len(key) + end := strings.Index(body[start:], `"`) + if end < 0 { + break + } + body = body[:start] + "***" + body[start+end:] + } + } + return body +} + +// truncate 截断字符串到指定长度 +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "...[truncated]" +}