1
0

refactor: 后端日志系统重构

- 新增模块化日志器(pkg/logger/module.go)
- 新增 GORM 日志适配器
- 统一日志入口,移除所有 zap.L() 全局 logger 调用
- 字段标准化
- 启动阶段使用结构化日志
- 更新所有相关测试
This commit is contained in:
2026-04-23 18:37:51 +08:00
parent 8c075194e5
commit 280099b89c
33 changed files with 1105 additions and 161 deletions

View File

@@ -1,13 +1,20 @@
package logger
import "go.uber.org/zap"
import (
"context"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type ctxKey struct{}
const requestIDKey = "request_id"
// WithRequestID 向 logger 添加 request_id 字段
func WithRequestID(logger *zap.Logger, requestID string) *zap.Logger {
return logger.With(zap.String("request_id", requestID))
return logger.With(zap.String(requestIDKey, requestID))
}
// WithContext 向 logger 添加多个自定义字段
func WithContext(logger *zap.Logger, fields map[string]interface{}) *zap.Logger {
zapFields := make([]zap.Field, 0, len(fields))
for k, v := range fields {
@@ -15,3 +22,37 @@ func WithContext(logger *zap.Logger, fields map[string]interface{}) *zap.Logger
}
return logger.With(zapFields...)
}
func RequestIDFromGinContext(c *gin.Context) zap.Field {
requestID, exists := c.Get("request_id")
if !exists {
return zap.Skip()
}
if id, ok := requestID.(string); ok {
return RequestID(id)
}
return zap.Skip()
}
func RequestIDFromContext(ctx context.Context) zap.Field {
requestID := ctx.Value(ctxKey{})
if requestID == nil {
return zap.Skip()
}
if id, ok := requestID.(string); ok {
return RequestID(id)
}
return zap.Skip()
}
func ContextWithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, ctxKey{}, requestID)
}
func LoggerFromContext(ctx context.Context, baseLogger *zap.Logger) *zap.Logger {
field := RequestIDFromContext(ctx)
if field == zap.Skip() {
return baseLogger
}
return baseLogger.With(field)
}

View File

@@ -0,0 +1,77 @@
package logger
import "go.uber.org/zap"
const (
FieldRequestID = "request_id"
FieldProviderID = "provider_id"
FieldModelName = "model_name"
FieldMethod = "method"
FieldPath = "path"
FieldStatusCode = "status"
FieldLatency = "latency"
FieldClientIP = "client_ip"
FieldQuery = "query"
FieldBodySize = "body_size"
FieldSQL = "sql"
FieldRows = "rows_affected"
)
func RequestID(id string) zap.Field {
return zap.String(FieldRequestID, id)
}
func ProviderID(id string) zap.Field {
return zap.String(FieldProviderID, id)
}
func ModelName(name string) zap.Field {
return zap.String(FieldModelName, name)
}
func Method(method string) zap.Field {
return zap.String(FieldMethod, method)
}
func Path(path string) zap.Field {
return zap.String(FieldPath, path)
}
func StatusCode(code int) zap.Field {
return zap.Int(FieldStatusCode, code)
}
func Latency(latency interface{}) zap.Field {
switch v := latency.(type) {
case int64:
return zap.Int64(FieldLatency, v)
case int:
return zap.Int(FieldLatency, v)
default:
return zap.Any(FieldLatency, latency)
}
}
func ClientIP(ip string) zap.Field {
return zap.String(FieldClientIP, ip)
}
func Query(query string) zap.Field {
return zap.String(FieldQuery, query)
}
func BodySize(size int) zap.Field {
return zap.Int(FieldBodySize, size)
}
func SQL(sql string) zap.Field {
return zap.String(FieldSQL, sql)
}
func Rows(rows int64) zap.Field {
return zap.Int64(FieldRows, rows)
}
func Err(err error) zap.Field {
return zap.Error(err)
}

View File

@@ -0,0 +1,99 @@
package logger
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFieldConstants(t *testing.T) {
assert.Equal(t, "request_id", FieldRequestID)
assert.Equal(t, "provider_id", FieldProviderID)
assert.Equal(t, "model_name", FieldModelName)
assert.Equal(t, "method", FieldMethod)
assert.Equal(t, "path", FieldPath)
assert.Equal(t, "status", FieldStatusCode)
assert.Equal(t, "latency", FieldLatency)
assert.Equal(t, "client_ip", FieldClientIP)
assert.Equal(t, "query", FieldQuery)
assert.Equal(t, "body_size", FieldBodySize)
assert.Equal(t, "sql", FieldSQL)
assert.Equal(t, "rows_affected", FieldRows)
}
func TestFieldConstructors(t *testing.T) {
t.Run("RequestID", func(t *testing.T) {
field := RequestID("test-id")
assert.Equal(t, FieldRequestID, field.Key)
assert.Equal(t, "test-id", field.String)
})
t.Run("ProviderID", func(t *testing.T) {
field := ProviderID("provider-123")
assert.Equal(t, FieldProviderID, field.Key)
assert.Equal(t, "provider-123", field.String)
})
t.Run("ModelName", func(t *testing.T) {
field := ModelName("gpt-4")
assert.Equal(t, FieldModelName, field.Key)
assert.Equal(t, "gpt-4", field.String)
})
t.Run("Method", func(t *testing.T) {
field := Method("POST")
assert.Equal(t, FieldMethod, field.Key)
assert.Equal(t, "POST", field.String)
})
t.Run("Path", func(t *testing.T) {
field := Path("/v1/chat")
assert.Equal(t, FieldPath, field.Key)
assert.Equal(t, "/v1/chat", field.String)
})
t.Run("StatusCode", func(t *testing.T) {
field := StatusCode(200)
assert.Equal(t, FieldStatusCode, field.Key)
})
t.Run("Latency", func(t *testing.T) {
field := Latency(int64(100))
assert.Equal(t, FieldLatency, field.Key)
})
t.Run("ClientIP", func(t *testing.T) {
field := ClientIP("127.0.0.1")
assert.Equal(t, FieldClientIP, field.Key)
assert.Equal(t, "127.0.0.1", field.String)
})
t.Run("Query", func(t *testing.T) {
field := Query("key=value")
assert.Equal(t, FieldQuery, field.Key)
assert.Equal(t, "key=value", field.String)
})
t.Run("BodySize", func(t *testing.T) {
field := BodySize(1024)
assert.Equal(t, FieldBodySize, field.Key)
})
t.Run("SQL", func(t *testing.T) {
field := SQL("SELECT * FROM users")
assert.Equal(t, FieldSQL, field.Key)
assert.Equal(t, "SELECT * FROM users", field.String)
})
t.Run("Rows", func(t *testing.T) {
field := Rows(42)
assert.Equal(t, FieldRows, field.Key)
assert.Equal(t, int64(42), field.Integer)
})
t.Run("Err", func(t *testing.T) {
err := assert.AnError
field := Err(err)
assert.Equal(t, "error", field.Key)
})
}

130
backend/pkg/logger/gorm.go Normal file
View File

@@ -0,0 +1,130 @@
package logger
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
type GormLogger struct {
logger *zap.Logger
level zapcore.Level
}
func NewGormLogger(logger *zap.Logger) *GormLogger {
return &GormLogger{
logger: logger.Named("database"),
level: zapcore.DebugLevel,
}
}
func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
newLogger := &GormLogger{
logger: l.logger,
level: l.gormLevelToZap(level),
}
return newLogger
}
func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
if l.level > zapcore.DebugLevel {
return
}
l.log(ctx, zapcore.DebugLevel, msg, data...)
}
func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
if l.level > zapcore.WarnLevel {
return
}
l.log(ctx, zapcore.WarnLevel, msg, data...)
}
func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
if l.level > zapcore.ErrorLevel {
return
}
l.log(ctx, zapcore.ErrorLevel, msg, data...)
}
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.level > zapcore.DebugLevel {
return
}
elapsed := time.Since(begin)
sql, rows := fc()
fields := []zap.Field{
zap.String("sql", l.formatSQL(sql)),
zap.Int64("rows", rows),
zap.Duration("latency", elapsed),
}
if requestIDField := RequestIDFromContext(ctx); requestIDField != zap.Skip() {
fields = append([]zap.Field{requestIDField}, fields...)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
fields = append(fields, zap.Error(err))
l.logger.Error("SQL执行错误", fields...)
return
}
l.logger.Debug("SQL查询", fields...)
}
func (l *GormLogger) log(ctx context.Context, level zapcore.Level, msg string, data ...interface{}) {
fields := make([]zap.Field, 0, len(data)/2+1)
if requestIDField := RequestIDFromContext(ctx); requestIDField != zap.Skip() {
fields = append(fields, requestIDField)
}
for i := 0; i < len(data); i += 2 {
if i+1 < len(data) {
key, ok := data[i].(string)
if !ok {
continue
}
fields = append(fields, zap.Any(key, data[i+1]))
}
}
switch level {
case zapcore.DebugLevel:
l.logger.Debug(fmt.Sprintf(msg, data...), fields...)
case zapcore.WarnLevel:
l.logger.Warn(fmt.Sprintf(msg, data...), fields...)
case zapcore.ErrorLevel:
l.logger.Error(fmt.Sprintf(msg, data...), fields...)
}
}
func (l *GormLogger) gormLevelToZap(level gormlogger.LogLevel) zapcore.Level {
switch level {
case gormlogger.Silent:
return zapcore.PanicLevel
case gormlogger.Error:
return zapcore.ErrorLevel
case gormlogger.Warn:
return zapcore.WarnLevel
case gormlogger.Info:
return zapcore.DebugLevel
default:
return zapcore.DebugLevel
}
}
func (l *GormLogger) formatSQL(sql string) string {
re := regexp.MustCompile(`\s+`)
return strings.TrimSpace(re.ReplaceAllString(sql, " "))
}

View File

@@ -0,0 +1,131 @@
package logger
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func TestNewGormLogger(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
assert.NotNil(t, gormLogger)
assert.NotNil(t, gormLogger.logger)
assert.Equal(t, zap.DebugLevel, gormLogger.level)
}
func TestGormLogger_LogMode(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
tests := []struct {
name string
level gormlogger.LogLevel
expectedLevel zapcore.Level
}{
{"Silent", gormlogger.Silent, zapcore.PanicLevel},
{"Error", gormlogger.Error, zapcore.ErrorLevel},
{"Warn", gormlogger.Warn, zapcore.WarnLevel},
{"Info", gormlogger.Info, zapcore.DebugLevel},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newLogger := gormLogger.LogMode(tt.level)
assert.NotNil(t, newLogger)
})
}
}
func TestGormLogger_Trace(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
begin := time.Now()
fc := func() (string, int64) {
return "SELECT * FROM users", 10
}
gormLogger.Trace(ctx, begin, fc, nil)
}
func TestGormLogger_TraceWithError(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
begin := time.Now()
fc := func() (string, int64) {
return "SELECT * FROM users", 0
}
gormLogger.Trace(ctx, begin, fc, gorm.ErrRecordNotFound)
}
func TestGormLogger_Info(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
gormLogger.Info(ctx, "test info message")
}
func TestGormLogger_Warn(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
gormLogger.Warn(ctx, "test warn message")
}
func TestGormLogger_Error(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
gormLogger.Error(ctx, "test error message")
}
func TestGormLogger_FormatSQL(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
tests := []struct {
name string
input string
expected string
}{
{
name: "simple query",
input: "SELECT * FROM users",
expected: "SELECT * FROM users",
},
{
name: "query with extra spaces",
input: "SELECT * FROM users",
expected: "SELECT * FROM users",
},
{
name: "query with newlines",
input: "SELECT\n*\nFROM\nusers",
expected: "SELECT * FROM users",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := gormLogger.formatSQL(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -50,6 +50,7 @@ func New(cfg Config) (*zap.Logger, error) {
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
EncodeName: encodeLoggerName,
})
stdoutCore := zapcore.NewCore(
@@ -115,3 +116,10 @@ func logFileName() string {
func logFilePath(dir string) string {
return filepath.Join(dir, logFileName())
}
// encodeLoggerName 自定义 logger 名称编码器,输出 [name] 格式
func encodeLoggerName(name string, enc zapcore.PrimitiveArrayEncoder) {
if name != "" {
enc.AppendString("[" + name + "]")
}
}

View File

@@ -0,0 +1,35 @@
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func NewMinimal() *zap.Logger {
encoder := zapcore.NewConsoleEncoder(zapcore.EncoderConfig{
TimeKey: "ts",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
FunctionKey: zapcore.OmitKey,
MessageKey: "msg",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalColorLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
})
core := zapcore.NewCore(
encoder,
zapcore.AddSync(stdoutWriter{}),
zapcore.DebugLevel,
)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))
}
func Upgrade(minimalLogger *zap.Logger, cfg Config) (*zap.Logger, error) {
return New(cfg)
}

View File

@@ -0,0 +1,7 @@
package logger
import "go.uber.org/zap"
func WithModule(logger *zap.Logger, moduleName string) *zap.Logger {
return logger.Named(moduleName)
}

View File

@@ -0,0 +1,25 @@
package logger
import (
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
)
func TestWithModule(t *testing.T) {
baseLogger := zap.NewNop()
moduleLogger := WithModule(baseLogger, "handler.proxy")
assert.NotNil(t, moduleLogger)
}
func TestWithModuleMultiple(t *testing.T) {
baseLogger := zap.NewNop()
logger1 := WithModule(baseLogger, "handler.proxy")
logger2 := WithModule(baseLogger, "provider.client")
assert.NotNil(t, logger1)
assert.NotNil(t, logger2)
}