/** * package middleware * 作用:接口请求日志中间件 * 说明:记录所有API请求信息,异步写入数据库 */ package middleware import ( "bytes" "encoding/json" "fmt" "io" "regexp" "strings" "time" "xk-websocket-v2/internal/model" "xk-websocket-v2/internal/utils" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // sensitiveKeyPattern 匹配需要脱敏的字段名(大小写不敏感):密码、验证码、Token、密钥等 var sensitiveKeyPattern = regexp.MustCompile(`(?i)(password|pwd|passwd|code|token|secret|authorization)`) // sanitizeParams 对请求体中的敏感字段做脱敏,避免明文密码/验证码/Token 落库。 // 为什么这样写:请求日志会把原始 body 写入数据库,登录/注册/验证码接口的密码与验证码若明文入库风险很高, // 因此优先按 JSON 逐字段递归脱敏,非 JSON 时退化为整体截断。 func sanitizeParams(body []byte) string { if len(body) == 0 { return "" } var m map[string]interface{} if err := json.Unmarshal(body, &m); err == nil { redactSensitiveMap(m) if b, err := json.Marshal(m); err == nil { return truncateString(string(b), 5000) } } return truncateString(string(body), 5000) } // redactSensitiveMap 递归地把 map 中的敏感字段值替换为 *** func redactSensitiveMap(m map[string]interface{}) { for k, v := range m { if sensitiveKeyPattern.MatchString(k) { m[k] = "***" continue } if child, ok := v.(map[string]interface{}); ok { redactSensitiveMap(child) } } } // truncateString 超长字符串截断,避免日志过大 func truncateString(s string, n int) string { if len(s) > n { return s[:n] + "...(truncated)" } return s } // 二进制 Content-Type 前缀列表 var binaryContentTypes = []string{ "image/", "audio/", "video/", "application/octet-stream", "application/pdf", "application/zip", "application/x-rar", "application/x-7z", "application/gzip", "application/x-tar", "font/", } // 不需要记录响应体的路由前缀 var skipResponseBodyRoutes = []string{ "/uploads/", "/static/", } // 需要完全跳过中间件的路由(如 WebSocket、高频轮询) var skipMiddlewareRoutes = []string{ "/ws", "/api/call/ws-push", // 扫码登录状态轮询:PC 登录页每 2 秒一次,入库毫无审计价值还会刷爆日志表 "/api/qrcode/status", } // isBinaryContentType 检测是否为二进制 Content-Type func isBinaryContentType(contentType string) bool { contentType = strings.ToLower(contentType) for _, prefix := range binaryContentTypes { if strings.HasPrefix(contentType, prefix) { return true } } return false } // isBinaryData 检测数据是否为二进制(通过检查是否包含非 UTF-8 字符) func isBinaryData(data []byte) bool { if len(data) == 0 { return false } // 检查前 512 字节是否包含二进制特征 checkLen := len(data) if checkLen > 512 { checkLen = 512 } for i := 0; i < checkLen; i++ { // 检测常见的二进制文件头 if data[i] == 0 { return true } } // 检查是否以常见的二进制文件头开始 // 注意:GIF/PDF/ZIP 的魔数以可打印 ASCII 开头("GIF8"/"%PDF"/"PK"), // 若只比对前两字节会把 "GI..."/"%P..." 开头的普通文本误判为二进制而漏记日志, // 因此必须校验完整魔数 if len(data) >= 2 { // JPEG: FF D8(非 ASCII 前缀,两字节即可判定) if data[0] == 0xFF && data[1] == 0xD8 { return true } // PNG: 89 50(首字节非 ASCII,两字节即可判定) if data[0] == 0x89 && data[1] == 0x50 { return true } } if len(data) >= 4 { // GIF: "GIF8"(GIF87a / GIF89a) if data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8' { return true } // PDF: "%PDF" if data[0] == '%' && data[1] == 'P' && data[2] == 'D' && data[3] == 'F' { return true } // ZIP/DOCX/XLSX: "PK" + 0x03/0x05/0x07(本地文件头/空档案尾/分卷标记) if data[0] == 'P' && data[1] == 'K' && (data[2] == 0x03 || data[2] == 0x05 || data[2] == 0x07) { return true } } return false } // shouldSkipResponseBody 检测是否应该跳过响应体记录 func shouldSkipResponseBody(path string) bool { for _, prefix := range skipResponseBodyRoutes { if strings.HasPrefix(path, prefix) { return true } } return false } // RequestLogMiddleware 请求日志中间件 type RequestLogMiddleware struct { DB *gorm.DB } // NewRequestLogMiddleware 创建请求日志中间件 func NewRequestLogMiddleware(db *gorm.DB) *RequestLogMiddleware { return &RequestLogMiddleware{DB: db} } // shouldSkipMiddleware 检测是否应该完全跳过中间件 func shouldSkipMiddleware(path string) bool { for _, route := range skipMiddlewareRoutes { if strings.HasPrefix(path, route) { return true } } return false } // Handler 中间件处理函数 func (m *RequestLogMiddleware) Handler() gin.HandlerFunc { return func(c *gin.Context) { // 跳过OPTIONS请求 if c.Request.Method == "OPTIONS" { c.Next() return } // 跳过 WebSocket 路由(包装 Writer 会干扰 WebSocket 升级) if shouldSkipMiddleware(c.Request.URL.Path) { c.Next() return } // 获取请求IP ip := utils.GetClientIP(c) // 本地IP不记录 if utils.GetIPLocation(ip) == "本地" { c.Next() return } // 获取请求参数 var requestBody []byte if c.Request.Body != nil { requestBody, _ = io.ReadAll(c.Request.Body) c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) } // 记录开始时间 startTime := time.Now() // 创建响应写入器 writer := &responseWriter{ ResponseWriter: c.Writer, body: &bytes.Buffer{}, } c.Writer = writer // 处理请求 c.Next() // 计算请求时间 duration := time.Since(startTime) // 获取用户ID(从Context中获取,未登录为"0") // 注意必须在 c.Next() 之后读取:JWT 认证中间件在 c.Next() 内部才执行 c.Set("user_id"), // 若在 c.Next() 之前读取则永远是 "0" userID := "0" if uid, exists := c.Get("user_id"); exists { if uidStr, ok := uid.(string); ok { userID = uidStr } } // Gin 通过 sync.Pool 复用 Context,本函数返回后 c 可能被重置并服务其他请求; // 若在异步协程里继续读 c,会产生数据竞争,甚至把别的请求的 Method/Path/Header 写进日志。 // 因此进入协程前把所有需要的字段快照成值,协程内不再触碰 c。 snap := requestLogSnapshot{ route: c.FullPath(), method: c.Request.Method, path: c.Request.URL.Path, requestContentType: c.GetHeader("Content-Type"), responseContentType: writer.Header().Get("Content-Type"), } // 异步记录日志(避免影响性能) go m.logRequest(snap, ip, userID, requestBody, writer.body.Bytes(), writer.status, duration) } } // requestLogSnapshot 请求上下文快照 // 在请求协程内取值、传给异步日志协程使用,避免跨协程访问被复用的 *gin.Context type requestLogSnapshot struct { route string // 注册的路由模板(如 /api/user/:id) method string // HTTP 方法 path string // 实际请求路径 requestContentType string // 请求体 Content-Type responseContentType string // 响应体 Content-Type } // logRequest 记录请求日志(运行在独立协程,只使用快照数据,不访问 gin.Context) func (m *RequestLogMiddleware) logRequest(snap requestLogSnapshot, ip, userID string, requestBody, responseBody []byte, httpStatus int, duration time.Duration) { // 获取IP归属地 location := utils.GetIPLocation(ip) // 获取响应code(从响应体中解析) responseCode := 0 if len(responseBody) > 0 && !isBinaryData(responseBody) { // 尝试解析响应体获取code(仅对非二进制数据) var resp model.ApiResponse if err := json.Unmarshal(responseBody, &resp); err == nil { responseCode = resp.Code } } // 处理请求参数 var requestParams string if isBinaryContentType(snap.requestContentType) || isBinaryData(requestBody) { // 二进制请求体,只记录大小 requestParams = fmt.Sprintf("[binary data: %d bytes]", len(requestBody)) } else { // 文本请求体:对密码/验证码/Token 等敏感字段脱敏后再记录 requestParams = sanitizeParams(requestBody) } // 处理响应参数 var responseParams string if shouldSkipResponseBody(snap.path) { // 静态文件路由,跳过响应体记录 responseParams = fmt.Sprintf("[static file: %d bytes]", len(responseBody)) } else if isBinaryContentType(snap.responseContentType) || isBinaryData(responseBody) { // 二进制响应体,只记录大小 responseParams = fmt.Sprintf("[binary data: %d bytes]", len(responseBody)) } else { // 文本响应体:与请求体一致做敏感字段脱敏(登录/注册响应中的 token 等),再截断长度 responseParams = sanitizeParams(responseBody) if len(responseParams) > 5000 { responseParams = responseParams[:5000] + "...(truncated)" } } // 创建日志记录 log := model.ApiRequestLog{ Route: snap.route, IP: ip, IPLocation: location, UserID: userID, Method: snap.method, RequestParams: requestParams, ResponseParams: responseParams, ResponseCode: responseCode, HTTPStatus: httpStatus, RequestTime: time.Now(), } // 异步写入数据库 m.DB.Create(&log) } // responseWriter 响应写入器(用于捕获响应内容) type responseWriter struct { gin.ResponseWriter body *bytes.Buffer status int } func (w *responseWriter) Write(b []byte) (int, error) { w.body.Write(b) return w.ResponseWriter.Write(b) } func (w *responseWriter) WriteString(s string) (int, error) { w.body.WriteString(s) return w.ResponseWriter.WriteString(s) } func (w *responseWriter) WriteHeader(statusCode int) { w.status = statusCode w.ResponseWriter.WriteHeader(statusCode) }