Files
nl-im-service/internal/middleware/request_log.go
2026-08-24 15:29:53 +08:00

344 lines
9.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
}