项目文件夹

文件
2026-07-14 10:24:10 +08:00

309 行
9.2 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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)),
)
}
}