feat: 添加 zap 日志工厂和 GORM 日志桥接

- NewLogger 工厂函数:支持 JSON/Console 编码、stdout/文件/多输出、lumberjack 轮转

- NewGormLogger 实现 gorm.Interface:Trace 区分错误/慢查询/正常查询

- output_stdout 用 *bool 三态处理(nil=true, true, false)

- 默认值:level=info, encoding=json, max_size=100, max_backups=5, max_age=30

- 慢查询阈值 200ms,ErrRecordNotFound 不视为错误

- 编译时接口检查: var _ gormlogger.Interface = (*GormLogger)(nil)

- 完整 TDD 测试覆盖

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-04-10 08:39:21 +08:00
parent 7550e75945
commit f7a21ee455
4 changed files with 1036 additions and 0 deletions

View File

@@ -0,0 +1,509 @@
package logger
import (
"context"
"errors"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// newObservedLogger creates a zap logger backed by an observer for test assertions.
func newObservedLogger() (*zap.Logger, *observer.ObservedLogs) {
core, recorded := observer.New(zapcore.DebugLevel)
return zap.New(core), recorded
}
func TestNewGormLogger_DefaultLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "")
g, ok := gl.(*GormLogger)
if !ok {
t.Fatal("expected *GormLogger")
}
if g.level != zapcore.WarnLevel {
t.Fatalf("expected warn level, got %v", g.level)
}
if g.silent {
t.Fatal("expected silent=false")
}
}
func TestNewGormLogger_Silent(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "silent")
g, ok := gl.(*GormLogger)
if !ok {
t.Fatal("expected *GormLogger")
}
if !g.silent {
t.Fatal("expected silent=true")
}
}
func TestNewGormLogger_ExplicitLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "error")
g, ok := gl.(*GormLogger)
if !ok {
t.Fatal("expected *GormLogger")
}
if g.level != zapcore.ErrorLevel {
t.Fatalf("expected error level, got %v", g.level)
}
}
func TestNewGormLogger_InvalidLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "bogus")
g, ok := gl.(*GormLogger)
if !ok {
t.Fatal("expected *GormLogger")
}
// Should default to warn
if g.level != zapcore.WarnLevel {
t.Fatalf("expected warn level fallback, got %v", g.level)
}
}
func TestGormLogger_Info(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
gl.Info(context.Background(), "test info", "key", "value")
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 log entry, got %d", len(entries))
}
if entries[0].Message != "test info" {
t.Fatalf("expected message 'test info', got %q", entries[0].Message)
}
if entries[0].Level != zapcore.InfoLevel {
t.Fatalf("expected InfoLevel, got %v", entries[0].Level)
}
}
func TestGormLogger_Warn(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
gl.Warn(context.Background(), "test warn", "code", 42)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 log entry, got %d", len(entries))
}
if entries[0].Message != "test warn" {
t.Fatalf("expected message 'test warn', got %q", entries[0].Message)
}
if entries[0].Level != zapcore.WarnLevel {
t.Fatalf("expected WarnLevel, got %v", entries[0].Level)
}
}
func TestGormLogger_Error(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "error")
gl.Error(context.Background(), "test error", "module", "gorm")
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 log entry, got %d", len(entries))
}
if entries[0].Message != "test error" {
t.Fatalf("expected message 'test error', got %q", entries[0].Message)
}
if entries[0].Level != zapcore.ErrorLevel {
t.Fatalf("expected ErrorLevel, got %v", entries[0].Level)
}
}
func TestGormLogger_LevelFiltering(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
// Info should be suppressed at warn level
gl.Info(context.Background(), "should be suppressed")
if len(recorded.All()) != 0 {
t.Fatal("info should be suppressed at warn level")
}
// Warn should pass
gl.Warn(context.Background(), "should pass")
if len(recorded.All()) != 1 {
t.Fatal("warn should pass at warn level")
}
}
func TestGormLogger_SilentSuppressesAll(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "silent")
gl.Info(context.Background(), "info msg")
gl.Warn(context.Background(), "warn msg")
gl.Error(context.Background(), "error msg")
if len(recorded.All()) != 0 {
t.Fatalf("silent mode should suppress all logs, got %d", len(recorded.All()))
}
}
func TestGormLogger_LogMode_ReturnsNewInstance(t *testing.T) {
log, _ := newObservedLogger()
original := NewGormLogger(log, "warn").(*GormLogger)
modified := original.LogMode(gormlogger.Info)
// Must be a different instance
if modified == original {
t.Fatal("LogMode should return a new instance")
}
// Original should be unchanged
if original.level != zapcore.WarnLevel {
t.Fatalf("original level should remain warn, got %v", original.level)
}
// New instance should have InfoLevel
mod := modified.(*GormLogger)
if mod.level != zapcore.InfoLevel {
t.Fatalf("modified level should be info, got %v", mod.level)
}
}
func TestGormLogger_LogMode_Silent(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "info").(*GormLogger)
silent := gl.LogMode(gormlogger.Silent).(*GormLogger)
if !silent.silent {
t.Fatal("LogMode(Silent) should set silent=true")
}
// Original should not be silent
if gl.silent {
t.Fatal("original should not be affected")
}
}
func TestGormLogger_LogMode_ErrorLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "info").(*GormLogger)
modified := gl.LogMode(gormlogger.Error).(*GormLogger)
if modified.level != zapcore.ErrorLevel {
t.Fatalf("expected ErrorLevel, got %v", modified.level)
}
}
func TestGormLogger_LogMode_WarnLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "info").(*GormLogger)
modified := gl.LogMode(gormlogger.Warn).(*GormLogger)
if modified.level != zapcore.WarnLevel {
t.Fatalf("expected WarnLevel, got %v", modified.level)
}
}
func TestGormLogger_Trace_WithError(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now()
fc := func() (string, int64) { return "SELECT * FROM users", 0 }
err := errors.New("connection refused")
gl.Trace(context.Background(), begin, fc, err)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Level != zapcore.ErrorLevel {
t.Fatalf("expected ErrorLevel for real errors, got %v", entries[0].Level)
}
if entries[0].Message != "gorm query error" {
t.Fatalf("unexpected message: %q", entries[0].Message)
}
}
func TestGormLogger_Trace_ErrRecordNotFound(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
begin := time.Now()
fc := func() (string, int64) { return "SELECT * FROM users WHERE id = ?", 0 }
gl.Trace(context.Background(), begin, fc, gorm.ErrRecordNotFound)
// ErrRecordNotFound should NOT be logged as error
// At default level with no slow query, it should log as info
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Level == zapcore.ErrorLevel {
t.Fatal("ErrRecordNotFound should NOT be logged at ErrorLevel")
}
if entries[0].Message != "gorm query" {
t.Fatalf("expected 'gorm query', got %q", entries[0].Message)
}
}
func TestGormLogger_Trace_ErrRecordNotFound_SuppressedAtWarnLevel(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now()
fc := func() (string, int64) { return "SELECT * FROM users WHERE id = ?", 0 }
gl.Trace(context.Background(), begin, fc, gorm.ErrRecordNotFound)
// ErrRecordNotFound is not a real error, so it falls through to the default path.
// At warn level, the default path (info-level) is suppressed.
entries := recorded.All()
if len(entries) != 0 {
t.Fatalf("expected 0 entries at warn level, got %d", len(entries))
}
}
func TestGormLogger_Trace_SlowQuery(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
// Simulate a begin time far enough in the past to exceed the threshold
begin := time.Now().Add(-500 * time.Millisecond)
fc := func() (string, int64) { return "SELECT SLEEP(1)", 1 }
gl.Trace(context.Background(), begin, fc, nil)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Level != zapcore.WarnLevel {
t.Fatalf("expected WarnLevel for slow query, got %v", entries[0].Level)
}
if entries[0].Message != "gorm slow query" {
t.Fatalf("expected 'gorm slow query', got %q", entries[0].Message)
}
}
func TestGormLogger_Trace_NormalQuery(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
begin := time.Now()
fc := func() (string, int64) { return "SELECT 1", 1 }
gl.Trace(context.Background(), begin, fc, nil)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Level != zapcore.InfoLevel {
t.Fatalf("expected InfoLevel for normal query, got %v", entries[0].Level)
}
if entries[0].Message != "gorm query" {
t.Fatalf("expected 'gorm query', got %q", entries[0].Message)
}
}
func TestGormLogger_Trace_NormalQuerySuppressedAtWarnLevel(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now()
fc := func() (string, int64) { return "SELECT 1", 1 }
gl.Trace(context.Background(), begin, fc, nil)
// Normal queries are info-level, suppressed at warn
if len(recorded.All()) != 0 {
t.Fatal("normal query should be suppressed at warn level")
}
}
func TestGormLogger_Trace_SilentSuppressesAll(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "silent")
begin := time.Now().Add(-500 * time.Millisecond)
fc := func() (string, int64) { return "SELECT SLEEP(1)", 1 }
err := errors.New("some error")
gl.Trace(context.Background(), begin, fc, err)
if len(recorded.All()) != 0 {
t.Fatal("silent mode should suppress even Trace errors")
}
}
func TestGormLogger_Trace_StructuredFields(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
begin := time.Now()
fc := func() (string, int64) { return "SELECT * FROM users", 42 }
gl.Trace(context.Background(), begin, fc, nil)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
// Verify structured fields
fields := entries[0].ContextMap()
if fields["sql"] != "SELECT * FROM users" {
t.Fatalf("expected sql field, got %v", fields["sql"])
}
if fields["rows"] != int64(42) {
t.Fatalf("expected rows=42, got %v", fields["rows"])
}
if _, ok := fields["elapsed"]; !ok {
t.Fatal("expected elapsed field")
}
}
func TestGormLogger_Trace_ErrorFields(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now()
fc := func() (string, int64) { return "INSERT INTO users", 0 }
err := errors.New("duplicate key")
gl.Trace(context.Background(), begin, fc, err)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
fields := entries[0].ContextMap()
if fields["sql"] != "INSERT INTO users" {
t.Fatalf("expected sql field, got %v", fields["sql"])
}
if _, ok := fields["error"]; !ok {
t.Fatal("expected error field")
}
}
func TestGormLogger_Trace_SlowQueryFields(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now().Add(-500 * time.Millisecond)
fc := func() (string, int64) { return "SELECT * FROM large_table", 1000 }
gl.Trace(context.Background(), begin, fc, nil)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
fields := entries[0].ContextMap()
if fields["sql"] != "SELECT * FROM large_table" {
t.Fatalf("expected sql field, got %v", fields["sql"])
}
if fields["rows"] != int64(1000) {
t.Fatalf("expected rows=1000, got %v", fields["rows"])
}
if _, ok := fields["elapsed_ms"]; !ok {
t.Fatal("expected elapsed_ms field for slow query")
}
}
func TestArgsToFields(t *testing.T) {
tests := []struct {
name string
args []interface{}
expected int
}{
{"empty", nil, 0},
{"single_pair", []interface{}{"key", "value"}, 1},
{"two_pairs", []interface{}{"a", 1, "b", 2}, 2},
{"odd_args_ignores_last", []interface{}{"key", "value", "orphan"}, 1},
{"non_string_key_ignored", []interface{}{123, "value"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fields := argsToFields(tt.args)
if len(fields) != tt.expected {
t.Fatalf("expected %d fields, got %d", tt.expected, len(fields))
}
})
}
}
func TestArgsToFields_FieldValues(t *testing.T) {
fields := argsToFields([]interface{}{"name", "test", "count", 42})
if len(fields) != 2 {
t.Fatalf("expected 2 fields, got %d", len(fields))
}
if fields[0].Key != "name" {
t.Fatalf("expected key 'name', got %q", fields[0].Key)
}
if fields[1].Key != "count" {
t.Fatalf("expected key 'count', got %q", fields[1].Key)
}
}
func TestParseGormLevel(t *testing.T) {
tests := []struct {
input string
expected zapcore.Level
}{
{"debug", zapcore.DebugLevel},
{"info", zapcore.InfoLevel},
{"warn", zapcore.WarnLevel},
{"error", zapcore.ErrorLevel},
{"", zapcore.WarnLevel},
{"silent", zapcore.WarnLevel},
{"invalid", zapcore.WarnLevel},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := parseGormLevel(tt.input)
if got != tt.expected {
t.Fatalf("expected %v, got %v", tt.expected, got)
}
})
}
}
func TestGormLogger_InfoWithMultipleFields(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
gl.Info(context.Background(), "multi fields", "key1", "val1", "key2", 123, "key3", true)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
fields := entries[0].ContextMap()
if fields["key1"] != "val1" {
t.Fatalf("expected key1=val1, got %v", fields["key1"])
}
if fields["key2"] != int64(123) {
t.Fatalf("expected key2=123, got %v", fields["key2"])
}
if fields["key3"] != true {
t.Fatalf("expected key3=true, got %v", fields["key3"])
}
}