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,286 @@
package logger
import (
"os"
"path/filepath"
"strings"
"testing"
"gcy_hpc_server/internal/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest"
)
func ptrBool(v bool) *bool { return &v }
// TestNewLogger_JSONConfig creates a logger with JSON encoding and verifies
// that log entries are emitted successfully.
func TestNewLogger_JSONConfig(t *testing.T) {
cfg := config.LogConfig{
Level: "debug",
Encoding: "json",
OutputStdout: ptrBool(true),
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
// Should not panic when logging
log.Info("json logger test", zap.String("key", "value"))
}
// TestNewLogger_ConsoleConfig creates a logger with console encoding.
func TestNewLogger_ConsoleConfig(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Encoding: "console",
OutputStdout: ptrBool(true),
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
log.Info("console logger test", zap.Int("num", 42))
}
// TestNewLogger_InvalidLevel verifies that an invalid log level returns an error.
func TestNewLogger_InvalidLevel(t *testing.T) {
cfg := config.LogConfig{
Level: "bogus",
Encoding: "json",
}
_, err := NewLogger(cfg)
if err == nil {
t.Fatal("expected error for invalid log level, got nil")
}
}
// TestNewLogger_EmptyConfig verifies defaults are applied when config is zero-value.
func TestNewLogger_EmptyConfig(t *testing.T) {
cfg := config.LogConfig{} // all zero values
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
log.Info("default config test")
}
// TestNewLogger_FileOutput verifies that file output with rotation config works.
func TestNewLogger_FileOutput(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "test.log")
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
FilePath: logFile,
MaxSize: 10,
MaxBackups: 3,
MaxAge: 7,
Compress: true,
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
log.Info("file output test", zap.String("msg", "hello"))
log.Sync()
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if len(data) == 0 {
t.Fatal("log file is empty, expected output")
}
if !strings.Contains(string(data), "file output test") {
t.Fatalf("log file content does not contain expected message;\ngot: %s", string(data))
}
}
// TestNewLogger_MultiWriter verifies that both stdout and file output work together.
func TestNewLogger_MultiWriter(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "multi.log")
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
OutputStdout: ptrBool(true),
FilePath: logFile,
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
log.Info("multi writer test", zap.String("writer", "both"))
log.Sync()
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if !strings.Contains(string(data), "multi writer test") {
t.Fatalf("log file content does not contain expected message;\ngot: %s", string(data))
}
}
// TestNewLogger_Observer verifies actual log output content using zaptest.
func TestNewLogger_Observer(t *testing.T) {
// Use zaptest.NewLogger to capture logs in test output
log := zaptest.NewLogger(t,
zaptest.WrapOptions(zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)),
zaptest.Level(zapcore.DebugLevel),
)
// These should all succeed without panicking
log.Debug("debug msg", zap.String("k", "v"))
log.Info("info msg", zap.Int("n", 1))
log.Warn("warn msg")
log.Error("error msg")
}
// TestNewLogger_AllLevels verifies all valid log levels parse correctly.
func TestNewLogger_AllLevels(t *testing.T) {
levels := []string{"debug", "info", "warn", "error", "dpanic", "panic", "fatal"}
for _, level := range levels {
t.Run(level, func(t *testing.T) {
cfg := config.LogConfig{
Level: level,
Encoding: "json",
OutputStdout: ptrBool(true),
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("level %q: NewLogger returned error: %v", level, err)
}
log.Sync()
})
}
}
// TestNewLogger_InvalidEncoding falls back gracefully — the factory should
// treat an unrecognized encoding as an error or default to JSON.
func TestNewLogger_InvalidEncoding(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Encoding: "xml",
OutputStdout: ptrBool(true),
}
// The implementation should default to JSON for unknown encoding.
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("unexpected error for invalid encoding: %v", err)
}
defer log.Sync()
log.Info("invalid encoding test")
}
// TestNewLogger_DefaultRotation verifies rotation defaults are applied.
func TestNewLogger_DefaultRotation(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "rotation.log")
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
FilePath: logFile,
// MaxSize, MaxBackups, MaxAge, Compress all zero → defaults apply
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
log.Info("rotation defaults test")
log.Sync()
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if len(data) == 0 {
t.Fatal("log file is empty")
}
}
func TestNewLogger_OutputStdoutNil(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
log.Info("default stdout test")
}
func TestNewLogger_OutputStdoutFalseWithFile(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "nostdout.log")
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
OutputStdout: ptrBool(false),
FilePath: logFile,
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
log.Info("file only test")
log.Sync()
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if !strings.Contains(string(data), "file only test") {
t.Fatalf("log file content does not contain expected message;\ngot: %s", string(data))
}
}
func TestNewLogger_OutputStdoutFalseFallback(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
OutputStdout: ptrBool(false),
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
log.Info("fallback stdout test")
}