Files
2026-07-14 10:24:10 +08:00

309 lines
9.2 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
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 pathPrefixMatch(path, prefix) {
return true
}
}
return false
}
func pathPrefixMatch(path, prefix string) bool {
if prefix == "" {
return false
}
cleanPrefix := strings.TrimRight(prefix, "/")
if cleanPrefix == "" {
return path == "/"
}
return path == cleanPrefix || strings.HasPrefix(path, cleanPrefix+"/")
}
// 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" || pathPrefixMatch(path, "/public") || pathPrefixMatch(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)),
)
}
}