package middleware import ( "bytes" "io" "net/url" "regexp" "strings" "time" "github.com/EthanCodeCraft/xlgo-core/logger" "github.com/gin-gonic/gin" "go.uber.org/zap" ) // LoggerConfig 日志中间件配置 type LoggerConfig struct { // LogRequestBody 是否记录请求体 LogRequestBody bool // LogResponseBody 是否记录响应体 LogResponseBody bool // MaxBodyLength 最大记录的请求/响应体长度(字节) MaxBodyLength int // SkipPaths 不记录日志的路径 SkipPaths []string // SkipPathPrefixes 不记录日志的路径前缀 SkipPathPrefixes []string // SlowRequestThreshold 慢请求阈值(超过此时间记录警告) SlowRequestThreshold time.Duration } // DefaultLoggerConfig 默认日志配置 var DefaultLoggerConfig = LoggerConfig{ LogRequestBody: false, // 默认不记录请求体(敏感信息风险) LogResponseBody: false, // 默认不记录响应体 MaxBodyLength: 1024, // 最大 1KB SkipPaths: []string{"/health", "/swagger"}, SkipPathPrefixes: []string{"/public", "/static"}, SlowRequestThreshold: 500 * time.Millisecond, } // Logger 日志中间件(使用默认配置) func Logger() gin.HandlerFunc { return LoggerWithConfig(DefaultLoggerConfig) } // LoggerWithConfig 使用自定义配置的日志中间件 func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc { // 统一封顶:MaxBodyLength<=0 时回退默认值,确保请求/响应 body 捕获均有上限(防 OOM) if cfg.MaxBodyLength <= 0 { cfg.MaxBodyLength = DefaultLoggerConfig.MaxBodyLength } return func(c *gin.Context) { // 检查是否跳过此路径 path := c.Request.URL.Path if shouldSkipPath(path, cfg) { c.Next() return } start := time.Now() // 记录请求体(可选) var requestBody []byte if cfg.LogRequestBody && c.Request.Body != nil { requestBody = readBodyBounded(c, cfg.MaxBodyLength) } // 记录响应体(可选) var responseBody []byte if cfg.LogResponseBody { // 使用 ResponseWriter 包装器捕获响应体(缓冲区封顶,防止大响应 OOM) blw := &bodyLogWriter{body: bytes.NewBufferString(""), maxLen: cfg.MaxBodyLength, ResponseWriter: c.Writer} c.Writer = blw c.Next() responseBody = blw.body.Bytes() } else { c.Next() } latency := time.Since(start) // 构建日志字段 fields := []zap.Field{ zap.Int("status", c.Writer.Status()), zap.String("method", c.Request.Method), zap.String("path", path), zap.String("query", filterSensitiveQuery(c.Request.URL.RawQuery)), zap.String("ip", c.ClientIP()), zap.Duration("latency", latency), zap.String("user-agent", c.Request.UserAgent()), zap.Int("body_size", c.Writer.Size()), zap.String("request_id", GetRequestID(c)), } // 添加请求体 if cfg.LogRequestBody && len(requestBody) > 0 { // 过滤敏感字段 safeBody := filterSensitiveFields(requestBody) fields = append(fields, zap.String("request_body", safeBody)) } // 添加响应体 if cfg.LogResponseBody && len(responseBody) > 0 { fields = append(fields, zap.String("response_body", string(responseBody))) } // 添加用户信息(如果已登录) userID := GetUserID(c) if userID > 0 { fields = append(fields, zap.Uint("user_id", userID), zap.String("username", GetUsername(c)), zap.String("user_type", GetUserType(c)), ) } // 根据状态码和延迟选择日志级别 status := c.Writer.Status() if latency > cfg.SlowRequestThreshold { // 慢请求警告 fields = append(fields, zap.Bool("slow_request", true)) logger.APILog().Warn("慢请求", fields...) } else if status >= 500 { logger.APILog().Error("请求错误", fields...) } else if status >= 400 { logger.APILog().Warn("客户端请求错误", fields...) } else { logger.APILog().Info("API 请求", fields...) } } } // readBodyBounded 读取请求体用于日志记录,封顶 maxLen 字节以防 OOM。 // // 仅向内存读入最多 maxLen+1 字节(+1 用于检测截断),其余部分不读入内存; // 通过 io.MultiReader 把「已读前缀 + 原始 body 剩余」复原为 c.Request.Body, // 因此下游处理器仍能拿到完整请求体。返回的日志副本截断为 maxLen。 func readBodyBounded(c *gin.Context, maxLen int) []byte { if maxLen <= 0 { maxLen = DefaultLoggerConfig.MaxBodyLength } // io.ReadAll 永远返回非 nil 切片(即使出错也带已读部分)。LimitReader 封顶至 maxLen+1 字节。 read, _ := io.ReadAll(io.LimitReader(c.Request.Body, int64(maxLen)+1)) // 复原完整请求体供后续处理:已读前缀 + 原始 body 剩余部分(出错时保留已读字节,下游可重试读取剩余) c.Request.Body = io.NopCloser(io.MultiReader(bytes.NewReader(read), c.Request.Body)) // 日志副本截断到 maxLen if len(read) > maxLen { return read[:maxLen] } return read } // bodyLogWriter 响应体记录包装器 type bodyLogWriter struct { gin.ResponseWriter body *bytes.Buffer maxLen int // 缓冲区上限(字节),<=0 表示不限制 } // appendBounded 向缓冲区追加字节,但不超过 maxLen 上限,防止大响应 OOM。 func (w *bodyLogWriter) appendBounded(b []byte) { if w.maxLen <= 0 { w.body.Write(b) return } remaining := w.maxLen - w.body.Len() if remaining <= 0 { return } if len(b) > remaining { b = b[:remaining] } w.body.Write(b) } // Write 捕获响应体(缓冲区封顶,完整响应仍写入下游 ResponseWriter) func (w *bodyLogWriter) Write(b []byte) (int, error) { w.appendBounded(b) return w.ResponseWriter.Write(b) } // WriteString 捕获字符串响应 func (w *bodyLogWriter) WriteString(s string) (int, error) { w.appendBounded([]byte(s)) return w.ResponseWriter.WriteString(s) } // shouldSkipPath 检查是否跳过路径 func shouldSkipPath(path string, cfg LoggerConfig) bool { // 检查完整路径 for _, p := range cfg.SkipPaths { if path == p { return true } } // 检查路径前缀 for _, prefix := range cfg.SkipPathPrefixes { if strings.HasPrefix(path, prefix) { return true } } return false } // filterSensitiveFields 过滤敏感字段(密码、token等) // sensitiveFieldsRE M1 修复:编译期正则,匹配 "key":"value" 整体并将 value 替换为 [FILTERED]。 // 支持 JSON 字符串中标准转义(\" \\ \/ \b \f \n \r \t \uXXXX)。 var sensitiveFieldsRE = regexp.MustCompile( `"(password|passwd|pwd|token|access_token|refresh_token|secret|api_key|apikey|credit_card|card_number)"\s*:\s*"((?:[^"\\]|\\.)*)"`, ) // filterSensitiveFields M1 修复后实现:将敏感字段的值替换为 [FILTERED],完整移除原始值。 // 对非 JSON 输入原样返回。 func filterSensitiveFields(body []byte) string { return sensitiveFieldsRE.ReplaceAllString(string(body), `"$1":"[FILTERED]"`) } // sensitiveQueryKeys 需要在访问日志中脱敏的 query 参数名(小写匹配,P1 #14)。 // 与 sensitiveFieldsRE 覆盖的字段保持一致。 var sensitiveQueryKeys = map[string]struct{}{ "password": {}, "passwd": {}, "pwd": {}, "token": {}, "access_token": {}, "refresh_token": {}, "secret": {}, "api_key": {}, "apikey": {}, "credit_card": {}, "card_number": {}, } // filterSensitiveQuery 脱敏 query 字符串中的敏感参数值(P1 #14): // 如 ?access_token=xxx 会被记为 access_token=%5BFILTERED%5D,防止令牌等随访问日志落盘。 // 无敏感键时原样返回(不重排);解析失败时整体标记以免原文泄露。 func filterSensitiveQuery(rawQuery string) string { if rawQuery == "" { return "" } values, err := url.ParseQuery(rawQuery) if err != nil { return "[redacted: unparsable query]" } changed := false for k := range values { if _, ok := sensitiveQueryKeys[strings.ToLower(k)]; ok { values[k] = []string{"[FILTERED]"} changed = true } } if !changed { return rawQuery } return values.Encode() } // LoggerForAPI API 专用日志中间件(更详细) func LoggerForAPI() gin.HandlerFunc { cfg := DefaultLoggerConfig cfg.LogRequestBody = true cfg.LogResponseBody = false // 响应体通常较大 return LoggerWithConfig(cfg) } // LoggerForDebug 调试专用日志中间件(最详细) func LoggerForDebug() gin.HandlerFunc { cfg := LoggerConfig{ LogRequestBody: true, LogResponseBody: true, MaxBodyLength: 4096, // 4KB SkipPaths: []string{"/health"}, SkipPathPrefixes: []string{}, SlowRequestThreshold: 200 * time.Millisecond, } return LoggerWithConfig(cfg) } // LoggerMinimal 最简日志中间件(只记录基本信息) func LoggerMinimal() gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path // 跳过健康检查和静态资源 if path == "/health" || strings.HasPrefix(path, "/public") || strings.HasPrefix(path, "/static") { c.Next() return } start := time.Now() c.Next() logger.APILog().Info("请求", zap.Int("status", c.Writer.Status()), zap.String("method", c.Request.Method), zap.String("path", path), zap.String("ip", c.ClientIP()), zap.Duration("latency", time.Since(start)), ) } }