2025-12-03 11:00:47 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* package middleware
|
|
|
|
|
|
* 作用:接口请求日志中间件
|
|
|
|
|
|
* 说明:记录所有API请求信息,异步写入数据库
|
|
|
|
|
|
*/
|
|
|
|
|
|
package middleware
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"encoding/json"
|
2025-12-15 13:21:08 +08:00
|
|
|
|
"fmt"
|
2025-12-03 11:00:47 +08:00
|
|
|
|
"io"
|
2026-08-24 15:29:53 +08:00
|
|
|
|
"regexp"
|
2025-12-03 11:00:47 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
"xk-websocket-v2/internal/model"
|
|
|
|
|
|
"xk-websocket-v2/internal/utils"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 13:21:08 +08:00
|
|
|
|
// 二进制 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/",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// 需要完全跳过中间件的路由(如 WebSocket、高频轮询)
|
2025-12-15 21:57:21 +08:00
|
|
|
|
var skipMiddlewareRoutes = []string{
|
|
|
|
|
|
"/ws",
|
|
|
|
|
|
"/api/call/ws-push",
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// 扫码登录状态轮询:PC 登录页每 2 秒一次,入库毫无审计价值还会刷爆日志表
|
|
|
|
|
|
"/api/qrcode/status",
|
2025-12-15 21:57:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 13:21:08 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 检查是否以常见的二进制文件头开始
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// 注意:GIF/PDF/ZIP 的魔数以可打印 ASCII 开头("GIF8"/"%PDF"/"PK"),
|
|
|
|
|
|
// 若只比对前两字节会把 "GI..."/"%P..." 开头的普通文本误判为二进制而漏记日志,
|
|
|
|
|
|
// 因此必须校验完整魔数
|
2025-12-15 13:21:08 +08:00
|
|
|
|
if len(data) >= 2 {
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// JPEG: FF D8(非 ASCII 前缀,两字节即可判定)
|
2025-12-15 13:21:08 +08:00
|
|
|
|
if data[0] == 0xFF && data[1] == 0xD8 {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// PNG: 89 50(首字节非 ASCII,两字节即可判定)
|
2025-12-15 13:21:08 +08:00
|
|
|
|
if data[0] == 0x89 && data[1] == 0x50 {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-08-24 15:29:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
if len(data) >= 4 {
|
|
|
|
|
|
// GIF: "GIF8"(GIF87a / GIF89a)
|
|
|
|
|
|
if data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8' {
|
2025-12-15 13:21:08 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// PDF: "%PDF"
|
|
|
|
|
|
if data[0] == '%' && data[1] == 'P' && data[2] == 'D' && data[3] == 'F' {
|
2025-12-15 13:21:08 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// ZIP/DOCX/XLSX: "PK" + 0x03/0x05/0x07(本地文件头/空档案尾/分卷标记)
|
|
|
|
|
|
if data[0] == 'P' && data[1] == 'K' && (data[2] == 0x03 || data[2] == 0x05 || data[2] == 0x07) {
|
2025-12-15 13:21:08 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// shouldSkipResponseBody 检测是否应该跳过响应体记录
|
|
|
|
|
|
func shouldSkipResponseBody(path string) bool {
|
|
|
|
|
|
for _, prefix := range skipResponseBodyRoutes {
|
|
|
|
|
|
if strings.HasPrefix(path, prefix) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 11:00:47 +08:00
|
|
|
|
// RequestLogMiddleware 请求日志中间件
|
|
|
|
|
|
type RequestLogMiddleware struct {
|
|
|
|
|
|
DB *gorm.DB
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewRequestLogMiddleware 创建请求日志中间件
|
|
|
|
|
|
func NewRequestLogMiddleware(db *gorm.DB) *RequestLogMiddleware {
|
|
|
|
|
|
return &RequestLogMiddleware{DB: db}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 21:57:21 +08:00
|
|
|
|
// shouldSkipMiddleware 检测是否应该完全跳过中间件
|
|
|
|
|
|
func shouldSkipMiddleware(path string) bool {
|
|
|
|
|
|
for _, route := range skipMiddlewareRoutes {
|
|
|
|
|
|
if strings.HasPrefix(path, route) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 11:00:47 +08:00
|
|
|
|
// Handler 中间件处理函数
|
|
|
|
|
|
func (m *RequestLogMiddleware) Handler() gin.HandlerFunc {
|
|
|
|
|
|
return func(c *gin.Context) {
|
|
|
|
|
|
// 跳过OPTIONS请求
|
|
|
|
|
|
if c.Request.Method == "OPTIONS" {
|
|
|
|
|
|
c.Next()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 21:57:21 +08:00
|
|
|
|
// 跳过 WebSocket 路由(包装 Writer 会干扰 WebSocket 升级)
|
|
|
|
|
|
if shouldSkipMiddleware(c.Request.URL.Path) {
|
|
|
|
|
|
c.Next()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 11:00:47 +08:00
|
|
|
|
// 获取请求IP
|
2026-07-08 08:18:58 +08:00
|
|
|
|
ip := utils.GetClientIP(c)
|
2025-12-15 13:21:08 +08:00
|
|
|
|
|
2025-12-03 11:00:47 +08:00
|
|
|
|
// 本地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,
|
2025-12-15 13:21:08 +08:00
|
|
|
|
body: &bytes.Buffer{},
|
2025-12-03 11:00:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
c.Writer = writer
|
|
|
|
|
|
|
|
|
|
|
|
// 处理请求
|
|
|
|
|
|
c.Next()
|
|
|
|
|
|
|
|
|
|
|
|
// 计算请求时间
|
|
|
|
|
|
duration := time.Since(startTime)
|
|
|
|
|
|
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// 获取用户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"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 11:00:47 +08:00
|
|
|
|
// 异步记录日志(避免影响性能)
|
2026-08-24 15:29:53 +08:00
|
|
|
|
go m.logRequest(snap, ip, userID, requestBody, writer.body.Bytes(), writer.status, duration)
|
2025-12-03 11:00:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// 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) {
|
2025-12-03 11:00:47 +08:00
|
|
|
|
// 获取IP归属地
|
|
|
|
|
|
location := utils.GetIPLocation(ip)
|
|
|
|
|
|
|
|
|
|
|
|
// 获取响应code(从响应体中解析)
|
|
|
|
|
|
responseCode := 0
|
2025-12-15 13:21:08 +08:00
|
|
|
|
if len(responseBody) > 0 && !isBinaryData(responseBody) {
|
|
|
|
|
|
// 尝试解析响应体获取code(仅对非二进制数据)
|
2025-12-03 11:00:47 +08:00
|
|
|
|
var resp model.ApiResponse
|
|
|
|
|
|
if err := json.Unmarshal(responseBody, &resp); err == nil {
|
|
|
|
|
|
responseCode = resp.Code
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 13:21:08 +08:00
|
|
|
|
// 处理请求参数
|
|
|
|
|
|
var requestParams string
|
2026-08-24 15:29:53 +08:00
|
|
|
|
if isBinaryContentType(snap.requestContentType) || isBinaryData(requestBody) {
|
2025-12-15 13:21:08 +08:00
|
|
|
|
// 二进制请求体,只记录大小
|
|
|
|
|
|
requestParams = fmt.Sprintf("[binary data: %d bytes]", len(requestBody))
|
|
|
|
|
|
} else {
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// 文本请求体:对密码/验证码/Token 等敏感字段脱敏后再记录
|
|
|
|
|
|
requestParams = sanitizeParams(requestBody)
|
2025-12-03 11:00:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 13:21:08 +08:00
|
|
|
|
// 处理响应参数
|
|
|
|
|
|
var responseParams string
|
2026-08-24 15:29:53 +08:00
|
|
|
|
if shouldSkipResponseBody(snap.path) {
|
2025-12-15 13:21:08 +08:00
|
|
|
|
// 静态文件路由,跳过响应体记录
|
|
|
|
|
|
responseParams = fmt.Sprintf("[static file: %d bytes]", len(responseBody))
|
2026-08-24 15:29:53 +08:00
|
|
|
|
} else if isBinaryContentType(snap.responseContentType) || isBinaryData(responseBody) {
|
2025-12-15 13:21:08 +08:00
|
|
|
|
// 二进制响应体,只记录大小
|
|
|
|
|
|
responseParams = fmt.Sprintf("[binary data: %d bytes]", len(responseBody))
|
|
|
|
|
|
} else {
|
2026-08-24 15:29:53 +08:00
|
|
|
|
// 文本响应体:与请求体一致做敏感字段脱敏(登录/注册响应中的 token 等),再截断长度
|
|
|
|
|
|
responseParams = sanitizeParams(responseBody)
|
2025-12-15 13:21:08 +08:00
|
|
|
|
if len(responseParams) > 5000 {
|
|
|
|
|
|
responseParams = responseParams[:5000] + "...(truncated)"
|
|
|
|
|
|
}
|
2025-12-03 11:00:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建日志记录
|
|
|
|
|
|
log := model.ApiRequestLog{
|
2026-08-24 15:29:53 +08:00
|
|
|
|
Route: snap.route,
|
2025-12-03 11:00:47 +08:00
|
|
|
|
IP: ip,
|
|
|
|
|
|
IPLocation: location,
|
|
|
|
|
|
UserID: userID,
|
2026-08-24 15:29:53 +08:00
|
|
|
|
Method: snap.method,
|
2025-12-03 11:00:47 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|