309 lines
9.2 KiB
Go
309 lines
9.2 KiB
Go
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)),
|
||
)
|
||
}
|
||
}
|