package logger import ( "fmt" "io" "log" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "douyu_go/internal/constants" ) 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 { 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 } 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 } if atomic.CompareAndSwapUint32(&w.closed, 0, 1) { close(w.ch) w.wg.Wait() } return nil } // logFileWriter 同时写入文件、内存和终端。 // 终端写入失败时静默忽略(Windows GUI 应用无控制台)。 type logFileWriter struct { file *os.File store *MemoryLogStore } func (w *logFileWriter) Write(p []byte) (int, error) { // 写入文件 if _, err := w.file.Write(p); err != nil { return 0, err } // 写入内存 if _, err := w.store.Write(p); err != nil { return 0, err } // 尝试写入终端(失败时忽略,Windows GUI 应用无控制台) _, _ = os.Stdout.Write(p) return len(p), nil } // InitLogging 初始化日志输出:日志文件和内存缓冲同时写入。 // 终端输出通过 logFileWriter 同步写入(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 { async := NewAsyncLogWriter(store, asyncLogBufferSize) log.SetOutput(async) LogEvent("日志", "warn", "日志目录创建失败,仅写入内存: %v", err) return func() { _ = async.Close() } } 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) LogEvent("日志", "warn", "日志文件创建失败,仅写入内存: %v", err) return func() { _ = async.Close() } } store.SetFilePath(filePath) // 使用 logFileWriter 同时写入文件、内存和终端(终端失败时静默忽略) multi := &logFileWriter{file: file, store: store} async := NewAsyncLogWriter(multi, asyncLogBufferSize) log.SetOutput(async) LogEvent("日志", "success", "日志文件已启用: %s", filePath) return func() { _ = 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 } log.Print(formatLogLine(time.Now(), category, level, account, fmt.Sprintf(format, args...))) } 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) } // LogMeta 是从日志文本中解析出的结构化信息。 type LogMeta struct { Time string Category string Level string Account string } // ParseLogLine 从标准日志行中提取毫秒时间、分类、级别和账号。 func ParseLogLine(line string) LogMeta { meta := LogMeta{ Time: parseLogTime(line), } idx := strings.Index(line, "[") if idx < 0 { meta.Account = parseAccountFromText(line) return meta } category, rest, ok := cutBracket(line[idx:]) if !ok { meta.Account = parseAccountFromText(line) return meta } level, rest, ok := cutBracket(rest) if !ok || !IsLogLevel(level) { meta.Category = category meta.Account = parseAccountFromText(line) return meta } meta.Category = category meta.Level = level rest = strings.TrimLeft(rest, " \t") tag, _, ok := cutBracket(rest) if ok && strings.HasPrefix(tag, "账号:") { meta.Account = strings.TrimSpace(strings.TrimPrefix(tag, "账号:")) } if meta.Account == "" { meta.Account = parseAccountFromText(line) } return meta } func parseLogTime(line string) string { if len(line) < len(logTimeLayout) { return "" } raw := line[:len(logTimeLayout)] t, err := time.ParseInLocation(logTimeLayout, raw, constants.CST) if err != nil { return "" } return t.Format(logEntryTimeLayout) } func parseAccountFromText(line string) string { for _, marker := range []string{"账号=", "账号:"} { idx := strings.Index(line, marker) if idx < 0 { continue } start := idx + len(marker) rest := strings.TrimSpace(line[start:]) end := strings.IndexFunc(rest, func(r rune) bool { return r == ' ' || r == '\t' || r == ']' || r == ',' || r == ',' || r == '|' || r == ';' || r == ';' }) if end >= 0 { rest = rest[:end] } return strings.TrimSpace(rest) } return "" } // ParseLogMeta 从标准日志行中提取类似 [网络][warn] 的分类和级别。 func ParseLogMeta(line string) (string, string) { meta := ParseLogLine(line) return meta.Category, meta.Level } func cutBracket(s string) (string, string, bool) { if !strings.HasPrefix(s, "[") { return "", s, false } end := strings.Index(s, "]") if end <= 1 { return "", s, false } return s[1:end], s[end+1:], true } // IsLogLevel 判断是否为有效日志级别。 func IsLogLevel(level string) bool { switch strings.ToLower(level) { case "success", "error", "warn", "info", "debug": return true default: return false } } // LogDebug 输出 debug 级别日志,仅在日志等级设为 debug 时可见。 func LogDebug(category, format string, args ...any) { LogEvent(category, "debug", format, args...) } // 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 } // DetectLogLevel 根据日志文本粗略分类,方便前端染色。 func DetectLogLevel(line string) string { lower := strings.ToLower(line) switch { case strings.Contains(lower, "[debug]"): return "debug" case strings.Contains(lower, "[success]"): return "success" case strings.Contains(lower, "[error]"): return "error" case strings.Contains(lower, "[warn]"): return "warn" case strings.Contains(lower, "[info]"): return "info" case strings.Contains(line, "成功"): return "success" case strings.Contains(line, "失败") || strings.Contains(line, "错误") || strings.Contains(lower, "error") || strings.Contains(lower, "panic"): return "error" case strings.Contains(line, "降级") || strings.Contains(line, "跳过") || strings.Contains(line, "不存在") || strings.Contains(lower, "warn"): return "warn" default: return "info" } }