diff --git a/internal/logger/log_store.go b/internal/logger/log_store.go index b24a4c1..a80ceb2 100644 --- a/internal/logger/log_store.go +++ b/internal/logger/log_store.go @@ -1,15 +1,12 @@ package logger import ( - "strings" "sync" - "time" - "douyu_go/internal/constants" "douyu_go/internal/types" ) -// MemoryLogStore 内存日志缓冲,实现 io.Writer 接口。 +// MemoryLogStore 内存日志缓冲,供前端增量拉取。 type MemoryLogStore struct { mu sync.RWMutex nextID int64 @@ -29,44 +26,19 @@ func NewMemoryLogStore(max int) *MemoryLogStore { } } -// Write 实现 io.Writer,把标准 log 输出转存为内存日志。 -func (s *MemoryLogStore) Write(p []byte) (int, error) { - text := strings.TrimRight(string(p), "\r\n") - if text == "" { - return len(p), nil - } - +// Append 追加一条结构化日志,由内部分配自增 ID。 +// 超过容量时丢弃最旧的条目,保留最近的 max 条。 +func (s *MemoryLogStore) Append(entry types.LogEntry) types.LogEntry { s.mu.Lock() defer s.mu.Unlock() - for _, line := range strings.Split(text, "\n") { - line = strings.TrimRight(line, "\r") - if strings.TrimSpace(line) == "" { - continue - } - meta := ParseLogLine(line) - category, level := meta.Category, meta.Level - if level == "" { - level = DetectLogLevel(line) - } - logTime := meta.Time - if logTime == "" { - logTime = time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000") - } - s.nextID++ - s.entries = append(s.entries, types.LogEntry{ - ID: s.nextID, - Time: logTime, - Level: level, - Category: category, - Account: meta.Account, - Message: line, - }) - if len(s.entries) > s.max { - copy(s.entries, s.entries[len(s.entries)-s.max:]) - s.entries = s.entries[:s.max] - } + s.nextID++ + entry.ID = s.nextID + s.entries = append(s.entries, entry) + if len(s.entries) > s.max { + copy(s.entries, s.entries[len(s.entries)-s.max:]) + s.entries = s.entries[:s.max] } - return len(p), nil + return entry } // SetFilePath 保存当前日志文件路径。 diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 82e74c0..48d75ba 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -12,6 +12,7 @@ import ( "time" "douyu_go/internal/constants" + "douyu_go/internal/types" ) const ( @@ -112,29 +113,28 @@ func (w *AsyncLogWriter) Close() error { return nil } -// logFileWriter 同时写入文件、内存和终端。 -// 终端写入失败时静默忽略(Windows GUI 应用无控制台)。 -type logFileWriter struct { - file *os.File - store *MemoryLogStore +// sink 日志输出汇集点,把结构化日志条目分发到内存、文件和终端。 +type sink struct { + async *AsyncLogWriter // 文件异步写入(无文件时为 nil,仅写内存+终端) + store *MemoryLogStore + stdout bool } -func (w *logFileWriter) Write(p []byte) (int, error) { - // 写入文件 - if _, err := w.file.Write(p); err != nil { - return 0, err +// write 分发一条日志:内存同步写入,文件异步写入,终端尽力写入。 +func (s *sink) write(entry types.LogEntry) { + s.store.Append(entry) + if s.async != nil { + _, _ = s.async.Write([]byte(entry.Message + "\n")) } - // 写入内存 - if _, err := w.store.Write(p); err != nil { - return 0, err + if s.stdout { + _, _ = os.Stdout.Write([]byte(entry.Message + "\n")) } - // 尝试写入终端(失败时忽略,Windows GUI 应用无控制台) - _, _ = os.Stdout.Write(p) - return len(p), nil } +var globalSink *sink + // InitLogging 初始化日志输出:日志文件和内存缓冲同时写入。 -// 终端输出通过 logFileWriter 同步写入(Windows GUI 应用无控制台时自动跳过)。 +// 终端输出通过 sink 同步写入(Windows GUI 应用无控制台时自动跳过)。 func InitLogging(store *MemoryLogStore, logDir string) func() { log.SetFlags(0) log.SetPrefix("") @@ -147,29 +147,30 @@ func InitLogging(store *MemoryLogStore, logDir string) func() { logDir = resolveAbsolutePath(logDir) if err := os.MkdirAll(logDir, 0755); err != nil { - async := NewAsyncLogWriter(store, asyncLogBufferSize) - log.SetOutput(async) + globalSink = &sink{store: store, stdout: true} LogEvent("日志", "warn", "日志目录创建失败,仅写入内存: %v", err) - return func() { _ = async.Close() } + return func() {} } filePath := filepath.Join(logDir, "douyu_"+time.Now().In(constants.CST).Format("20060102_150405")+".log") file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { - async := NewAsyncLogWriter(store, asyncLogBufferSize) - log.SetOutput(async) + globalSink = &sink{store: store, stdout: true} LogEvent("日志", "warn", "日志文件创建失败,仅写入内存: %v", err) - return func() { _ = async.Close() } + return func() {} } store.SetFilePath(filePath) - // 使用 logFileWriter 同时写入文件、内存和终端(终端失败时静默忽略) - multi := &logFileWriter{file: file, store: store} - async := NewAsyncLogWriter(multi, asyncLogBufferSize) - log.SetOutput(async) + globalSink = &sink{ + async: NewAsyncLogWriter(file, asyncLogBufferSize), + store: store, + stdout: true, + } LogEvent("日志", "success", "日志文件已启用: %s", filePath) return func() { - _ = async.Close() + if globalSink != nil && globalSink.async != nil { + _ = globalSink.async.Close() + } _ = file.Close() } } @@ -194,7 +195,18 @@ func logStructuredEvent(category, level, account, format string, args ...any) { if levelPriority(level) < int(minLevel.Load()) { return } - log.Print(formatLogLine(time.Now(), category, level, account, fmt.Sprintf(format, args...))) + now := time.Now() + message := fmt.Sprintf(format, args...) + entry := types.LogEntry{ + Time: now.In(constants.CST).Format(logEntryTimeLayout), + Level: level, + Category: category, + Account: account, + Message: formatLogLine(now, category, level, account, message), + } + if globalSink != nil { + globalSink.write(entry) + } } func formatLogLine(now time.Time, category, level, account, message string) string { diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 318fb5a..8d47d56 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -1,6 +1,7 @@ package logger import ( + "strings" "testing" ) @@ -79,3 +80,71 @@ func TestParseLogMeta(t *testing.T) { t.Errorf("ParseLogMeta = (%q, %q), want ('网络', 'warn')", cat, lvl) } } + +// TestStructuredLogging 验证 LogEvent→sink→MemoryLogStore 结构化链路: +// LogEntry 的 category/level/account 应直接由调用方填入,而非字符串反解析。 +func TestStructuredLogging(t *testing.T) { + store := NewMemoryLogStore(100) + cleanup := InitLogging(store, t.TempDir()) + defer func() { + cleanup() + globalSink = nil + }() + + LogEvent("抢购", "success", "兑换成功: %s", "商品A") + LogAccountEvent("抢购", "warn", "testuser", "冷却: %ds", 5) + + entries := store.Since(0) + // InitLogging 自身产生 1 条"日志文件已启用",加上上述 2 条,共 3 条 + if len(entries) != 3 { + t.Fatalf("Since(0) = %d entries, want 3", len(entries)) + } + if entries[0].Category != "日志" { + t.Errorf("entry0 Category = %q, want '日志' (InitLogging 启动日志)", entries[0].Category) + } + + e1 := entries[1] + if e1.ID != 2 { + t.Errorf("entry1 ID = %d, want 2", e1.ID) + } + if e1.Category != "抢购" { + t.Errorf("entry1 Category = %q, want '抢购'", e1.Category) + } + if e1.Level != "success" { + t.Errorf("entry1 Level = %q, want 'success'", e1.Level) + } + if e1.Account != "" { + t.Errorf("entry1 Account = %q, want empty", e1.Account) + } + if e1.Time == "" { + t.Error("entry1 Time is empty") + } + if !strings.Contains(e1.Message, "[抢购][success]") { + t.Errorf("entry1 Message = %q, want contains '[抢购][success]'", e1.Message) + } + if !strings.Contains(e1.Message, "兑换成功: 商品A") { + t.Errorf("entry1 Message = %q, want contains '兑换成功: 商品A'", e1.Message) + } + + e2 := entries[2] + if e2.ID != 3 { + t.Errorf("entry2 ID = %d, want 3", e2.ID) + } + if e2.Account != "testuser" { + t.Errorf("entry2 Account = %q, want 'testuser'", e2.Account) + } + if !strings.Contains(e2.Message, "[账号:testuser]") { + t.Errorf("entry2 Message = %q, want contains '[账号:testuser]'", e2.Message) + } + if !strings.Contains(e2.Message, "冷却: 5s") { + t.Errorf("entry2 Message = %q, want contains '冷却: 5s'", e2.Message) + } + + // 验证 minLevel 过滤:设为 error 后 info 日志不应进入内存 + SetMinLevel("error") + LogEvent("测试", "info", "这条应被过滤") + if got := store.Since(e2.ID); len(got) != 0 { + t.Errorf("after SetMinLevel(error), Since(%d) = %d entries, want 0", e2.ID, len(got)) + } + SetMinLevel("info") // 恢复,避免影响后续测试 +}