Files
xlgo-core/middleware/logger.go
T
杭州明婳科技 6595e94677 fix: 多模型评审终审收口 — P0安全 + 主线A并发治理 + 文档对齐
经 deepseek/GLM/Claude 三轮对抗性评审 + 交叉核验 + 终审(见
last_report.md、cross_review_assessment.md),逐条回读源码核实并修复。
go build / vet / test -race ./... 全绿。

安全(P0):
- JWT alg confusion:WithValidMethods 固定 HMAC + keyfunc 方法断言
- JWT 空密钥 / 不支持算法 fail-closed(ErrEmptySecret / ErrUnsupportedAlgorithm)
- 上传大小实测封顶(enforceUploadSize),不信任客户端 file.Size
- HTTP headers/cookies map 竞态(写锁+快照)+ SSRF 防护(opt-in dialer.Control)

并发/资源(主线A 闭合):
- 包级可变全局一律 atomic.Pointer:DefaultRedis / DefaultStorage /
  DefaultManager / Validator / DefaultCache 等,消除裸指针数据竞争
- trace.Close 去 sync.Once(防 exporter 泄漏);app OnReady 失败走 Shutdown
- ws.Hub.Stop stopOnce 防 double-close;cron 接入 App 生命周期(WithCron)
- ratelimit failClosed atomic.Bool;Recover Written() 守卫
- cron checkAndRun 锁内收集锁外 spawn(wg.Add 在锁内防 Stop 竞态)

终审剩余项收口(H-A~M-H + L 系列):
- 副本连接池 MaxOpenConns/2 截断修复(replicaMaxOpenConns)
- JWT 黑名单 1s 超时 + 显式错误;ratelimit GetRedis 取一次复用
- GetPage 深分页上限;HashFile 流式;ReadFile 去 TOCTOU
- config Clone() 深拷贝;cache 锁操作返 ErrRedisNotReady
- compress 解压残留清理;正则预编译;EqualsIgnoreCase→EqualFold
- 删 redisLimiters 死代码;CLI 输入校验 + 回滚

文档:
- README/GUIDE/CHANGELOG 对齐当前 API(含破坏性变更迁移说明)
- docs/README 索引修正;config 注释修正(RS256 已不支持)

破坏性变更详见 CHANGELOG.md [Unreleased]。项目处于研发初期、无下游用户。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:16:03 +08:00

297 lines
9.0 KiB
Go
Raw 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 strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
// 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" || strings.HasPrefix(path, "/public") || strings.HasPrefix(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)),
)
}
}