package logger import ( "fmt" "io" "log" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "douyu_go/internal/constants" "douyu_go/internal/types" ) const ( asyncLogBufferSize = 8192 logTimeLayout = "2006/01/02 15:04:05.000" logEntryTimeLayout = "2006-01-02 15:04:05.000" ) // minLevel 日志最低等级,低于此等级的日志不输出。0=info, 1=warn, 2=error。 var minLevel atomic.Int32 var douyuOffset atomic.Int64 // SetMinLevel 设置日志最低显示等级。 func SetMinLevel(level string) { minLevel.Store(int32(levelPriority(level))) } // SetDouyuOffset 设置斗鱼服务端时间与本地时间的偏移量,用于日志双时间显示。 func SetDouyuOffset(offset time.Duration) { douyuOffset.Store(int64(offset)) } func levelPriority(level string) int { switch strings.ToLower(level) { case "debug": return -1 case "info": return 0 case "warn": return 1 case "error": return 2 default: return 0 } } // AsyncLogWriter 异步日志写入器,队列满时丢弃日志以保护主链路。 type AsyncLogWriter struct { mu sync.RWMutex dst io.Writer ch chan []byte wg sync.WaitGroup closed uint32 dropped uint64 } // NewAsyncLogWriter 创建异步日志写入器。 func NewAsyncLogWriter(dst io.Writer, buffer int) *AsyncLogWriter { if buffer <= 0 { buffer = 1024 } w := &AsyncLogWriter{ dst: dst, ch: make(chan []byte, buffer), } w.wg.Add(1) go w.run() return w } // Write 异步写日志;队列满时丢弃,避免抢购请求被磁盘 I/O 阻塞。 func (w *AsyncLogWriter) Write(p []byte) (int, error) { if w == nil { return len(p), nil } w.mu.RLock() defer w.mu.RUnlock() if atomic.LoadUint32(&w.closed) == 1 { return len(p), nil } buf := append([]byte(nil), p...) select { case w.ch <- buf: default: atomic.AddUint64(&w.dropped, 1) } return len(p), nil } func (w *AsyncLogWriter) run() { defer w.wg.Done() for p := range w.ch { _, _ = w.dst.Write(p) } if dropped := atomic.LoadUint64(&w.dropped); dropped > 0 { _, _ = fmt.Fprintln(w.dst, formatLogLine(time.Now(), "日志", "warn", "", fmt.Sprintf("日志队列已丢弃 %d 条,已优先保护抢购链路", dropped))) } } // Close 关闭写入器,等待队列清空。 func (w *AsyncLogWriter) Close() error { if w == nil { return nil } w.mu.Lock() defer w.mu.Unlock() if atomic.CompareAndSwapUint32(&w.closed, 0, 1) { close(w.ch) w.wg.Wait() } return nil } // sink 日志输出汇集点,把结构化日志条目分发到内存、文件和终端。 type sink struct { async *AsyncLogWriter // 文件异步写入(无文件时为 nil,仅写内存+终端) store *MemoryLogStore stdout bool } // write 分发一条日志:内存同步写入,文件异步写入,终端尽力写入。 func (s *sink) write(entry types.LogEntry) { s.store.Append(entry) if s.async != nil { _, _ = s.async.Write([]byte(entry.Message + "\n")) } if s.stdout { _, _ = os.Stdout.Write([]byte(entry.Message + "\n")) } } var globalSink *sink // InitLogging 初始化日志输出:日志文件和内存缓冲同时写入。 // 终端输出通过 sink 同步写入(Windows GUI 应用无控制台时自动跳过)。 func InitLogging(store *MemoryLogStore, logDir string) func() { log.SetFlags(0) log.SetPrefix("") if logDir == "" { logDir = constants.DefaultLogDir } // 解析为绝对路径,避免打包后工作目录不同导致日志写到意外位置 logDir = resolveAbsolutePath(logDir) if err := os.MkdirAll(logDir, 0755); err != nil { globalSink = &sink{store: store, stdout: true} LogEvent("日志", "warn", "日志目录创建失败,仅写入内存: %v", err) 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 { globalSink = &sink{store: store, stdout: true} LogEvent("日志", "warn", "日志文件创建失败,仅写入内存: %v", err) return func() {} } store.SetFilePath(filePath) globalSink = &sink{ async: NewAsyncLogWriter(file, asyncLogBufferSize), store: store, stdout: true, } LogEvent("日志", "success", "日志文件已启用: %s", filePath) return func() { if globalSink != nil && globalSink.async != nil { _ = globalSink.async.Close() } _ = file.Close() } } // LogEvent 输出带分类和级别的结构化文本日志。低于当前最低等级的日志会被丢弃。 func LogEvent(category, level, format string, args ...any) { logStructuredEvent(category, level, "", format, args...) } // LogAccountEvent 输出带账号标签的结构化日志,便于前端按账号观察抢购链路。 func LogAccountEvent(category, level, account, format string, args ...any) { logStructuredEvent(category, level, account, format, args...) } func logStructuredEvent(category, level, account, format string, args ...any) { if category == "" { category = "系统" } if level == "" { level = "info" } if levelPriority(level) < int(minLevel.Load()) { return } 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 { ts := now.In(constants.CST).Format(logTimeLayout) offset := time.Duration(douyuOffset.Load()) var prefix string if offset != 0 { dyTime := now.Add(offset) prefix = fmt.Sprintf("%s(D%02d:%02d:%02d.%03d) ", ts, dyTime.Hour(), dyTime.Minute(), dyTime.Second(), dyTime.Nanosecond()/1e6) } else { prefix = ts + " " } if account != "" { return fmt.Sprintf("%s[%s][%s][账号:%s] %s", prefix, category, level, account, message) } return fmt.Sprintf("%s[%s][%s] %s", prefix, category, level, message) } // resolveAbsolutePath 将相对路径解析为绝对路径。 // 如果路径已经是绝对路径则直接返回;否则基于可执行文件所在目录解析。 func resolveAbsolutePath(path string) string { if filepath.IsAbs(path) { return path } // 先尝试基于当前工作目录解析 if abs, err := filepath.Abs(path); err == nil { return abs } // 兜底:基于可执行文件所在目录 if exe, err := os.Executable(); err == nil { return filepath.Join(filepath.Dir(exe), path) } return path }