diff --git a/internal/logging/logging.go b/internal/logging/logging.go index c9fdb63..499be14 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -1,16 +1,27 @@ package logging import ( + "io" "os" + "strings" "time" "github.com/rs/zerolog" "github.com/valyala/fasthttp" + "rua.plus/lolly/internal/config" ) +// Logger 日志管理器,分离访问日志和错误日志。 +type Logger struct { + accessLog zerolog.Logger + errorLog zerolog.Logger + accessFile *os.File + errorFile *os.File +} + var log zerolog.Logger -// Init 初始化日志系统 +// Init 初始化日志系统(兼容旧接口)。 func Init(level string, pretty bool) { l := parseLevel(level) if pretty { @@ -20,7 +31,38 @@ func Init(level string, pretty bool) { } } -// LogAccess 记录访问日志 +// New 创建日志管理器,支持访问/错误日志分离。 +func New(cfg *config.LoggingConfig) *Logger { + if cfg == nil { + cfg = &config.LoggingConfig{} + } + + logger := &Logger{ + accessLog: zerolog.New(getOutput(cfg.Access.Path)).With().Timestamp().Logger(), + errorLog: zerolog.New(getOutput(cfg.Error.Path)).Level(parseLevel(cfg.Error.Level)).With().Timestamp().Logger(), + } + + return logger +} + +// getOutput 获取输出目标(stdout/stderr/文件)。 +func getOutput(path string) io.Writer { + path = strings.TrimSpace(path) + if path == "" || path == "stdout" { + return os.Stdout + } + if path == "stderr" { + return os.Stderr + } + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return os.Stdout + } + return f +} + +// LogAccess 记录访问日志。 func LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, duration time.Duration) { log.Info(). Str("method", string(ctx.Method())). @@ -32,14 +74,58 @@ func LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, duration time.D Msg("request") } -// Error 返回 Error 级别日志记录器 +// LogAccessWithLogger 使用 Logger 实例记录访问日志(nginx 格式变量)。 +func (l *Logger) LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, duration time.Duration) { + l.accessLog.Info(). + Str("remote_addr", ctx.RemoteAddr().String()). + Str("request", string(ctx.Method())+" "+string(ctx.Path())). + Int("status", status). + Int64("body_bytes_sent", size). + Dur("request_time", duration). + Str("http_referrer", string(ctx.Request.Header.Peek("Referer"))). + Str("http_user_agent", string(ctx.Request.Header.Peek("User-Agent"))). + Msg("") +} + +// Debug 返回 Debug 级别日志记录器。 +func (l *Logger) Debug() *zerolog.Event { + return l.errorLog.Debug() +} + +// Info 返回 Info 级别日志记录器。 +func (l *Logger) Info() *zerolog.Event { + return l.errorLog.Info() +} + +// Warn 返回 Warn 级别日志记录器。 +func (l *Logger) Warn() *zerolog.Event { + return l.errorLog.Warn() +} + +// Error 返回 Error 级别日志记录器。 +func (l *Logger) Error() *zerolog.Event { + return l.errorLog.Error() +} + +// Close 关闭日志文件。 +func (l *Logger) Close() error { + if l.accessFile != nil { + l.accessFile.Close() + } + if l.errorFile != nil { + l.errorFile.Close() + } + return nil +} + +// Error 返回 Error 级别日志记录器(全局实例)。 func Error() *zerolog.Event { return log.Error() } -// parseLevel 解析日志级别 +// parseLevel 解析日志级别。 func parseLevel(level string) zerolog.Level { - switch level { + switch strings.ToLower(level) { case "debug": return zerolog.DebugLevel case "info": diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go index cadc502..52bdd39 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -1,9 +1,17 @@ package logging import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/rs/zerolog" + "github.com/valyala/fasthttp" + "rua.plus/lolly/internal/config" ) func TestParseLevel(t *testing.T) { @@ -43,9 +51,9 @@ func TestParseLevel(t *testing.T) { expected: zerolog.InfoLevel, }, { - name: "uppercase DEBUG is case sensitive", + name: "uppercase DEBUG now works", input: "DEBUG", - expected: zerolog.InfoLevel, + expected: zerolog.DebugLevel, }, } @@ -57,4 +65,121 @@ func TestParseLevel(t *testing.T) { } }) } +} + +func TestNewLogger(t *testing.T) { + tests := []struct { + name string + cfg *config.LoggingConfig + }{ + {"nil config", nil}, + {"empty paths", &config.LoggingConfig{}}, + {"with access format", &config.LoggingConfig{Access: config.AccessLogConfig{Format: "json"}}}, + {"with error level", &config.LoggingConfig{Error: config.ErrorLogConfig{Level: "debug"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := New(tt.cfg) + if logger == nil { + t.Error("Expected non-nil Logger") + } + }) + } +} + +func TestLoggerWithFile(t *testing.T) { + tmpDir := t.TempDir() + accessPath := filepath.Join(tmpDir, "access.log") + errorPath := filepath.Join(tmpDir, "error.log") + + cfg := &config.LoggingConfig{ + Access: config.AccessLogConfig{Path: accessPath, Format: "json"}, + Error: config.ErrorLogConfig{Path: errorPath, Level: "info"}, + } + + logger := New(cfg) + + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/test") + ctx.Request.Header.SetMethod("GET") + + logger.LogAccess(ctx, 200, 10, 100*time.Millisecond) + logger.Error().Str("test", "value").Msg("test error") + logger.Close() + + if _, err := os.Stat(accessPath); os.IsNotExist(err) { + t.Error("Expected access log file to be created") + } +} + +func TestGetOutput(t *testing.T) { + if getOutput("") != os.Stdout { + t.Error("Expected stdout for empty path") + } + if getOutput("stderr") != os.Stderr { + t.Error("Expected stderr for 'stderr' path") + } + + tmpFile := filepath.Join(t.TempDir(), "test.log") + out := getOutput(tmpFile) + if out == nil { + t.Error("Expected non-nil writer for file path") + } +} + +func TestLoggerNginxFormat(t *testing.T) { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + logger := New(&config.LoggingConfig{Access: config.AccessLogConfig{Format: "json"}}) + + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/api/users?id=123") + ctx.Request.Header.SetMethod("POST") + ctx.Request.Header.Set("User-Agent", "test-agent") + ctx.Request.Header.Set("Referer", "http://example.com/") + + logger.LogAccess(ctx, 201, 512, 250*time.Millisecond) + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + + output := buf.String() + expectedFields := []string{"request", "status", "body_bytes_sent", "request_time", "remote_addr"} + + for _, field := range expectedFields { + if !strings.Contains(output, field) { + t.Errorf("Expected output to contain '%s'", field) + } + } +} + +func TestLoggerDebug(t *testing.T) { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + logger := New(&config.LoggingConfig{Error: config.ErrorLogConfig{Level: "debug"}}) + + logger.Debug().Msg("debug message") + logger.Info().Msg("info message") + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + + output := buf.String() + if !strings.Contains(output, "debug message") { + t.Error("Expected debug message to be logged") + } + if !strings.Contains(output, "info message") { + t.Error("Expected info message to be logged") + } } \ No newline at end of file