// Package logger 提供全应用共用的结构化日志。 // // 设计要点(多开场景下尤其重要): // - 每个进程启动时创建一个独占的日志文件(O_EXCL + 自增后缀), // 多个实例同时运行也不会写进同一个文件; // - 文件异步写入且队列有界,队列满时丢弃日志,避免磁盘 I/O 阻塞抢购主链路; // - 内存环形缓冲带自增 ID,界面可以按 ID 增量拉取; // - 条目带分类与账号标签,便于按账号观察抢购链路。 package logger import ( "fmt" "strings" "sync" "sync/atomic" "time" ) // 日志级别。 const ( LevelDebug = "debug" LevelInfo = "info" LevelSuccess = "success" LevelWarn = "warn" LevelError = "error" ) const ( entryTimeLayout = "15:04:05.000" lineTimeLayout = "2006-01-02 15:04:05.000" ) // Entry 是一条结构化日志。 type Entry struct { ID int64 `json:"id"` Time string `json:"time"` Level string `json:"level"` Category string `json:"category"` Account string `json:"account"` Message string `json:"message"` // at 只用于落盘,保证文件里带完整日期(不参与 JSON 序列化)。 at time.Time } var ( minLevel atomic.Int32 serverOffset atomic.Int64 ) var ( emitterMu sync.RWMutex emitter func(Entry) ) // SetServerOffset 设置服务端时间与本地时间的偏移量(服务端时间 - 本机时间), // 用于日志双时间显示(本地时间 + 估算虎牙时间)。 func SetServerOffset(offset time.Duration) { serverOffset.Store(int64(offset)) } // ServerOffset 返回当前设置的服务端时间偏移量。 func ServerOffset() time.Duration { return time.Duration(serverOffset.Load()) } // SetMinLevel 设置日志最低输出等级,低于该等级的日志(例如逐条网络请求的 debug) // 不会进入内存缓冲,也不会落盘。 func SetMinLevel(level string) { minLevel.Store(int32(levelPriority(level))) } // MinLevel 返回当前最低输出等级名。 func MinLevel() string { switch minLevel.Load() { case -1: return LevelDebug case 1: return LevelWarn case 2: return LevelError default: return LevelInfo } } // SetEmitter 注册日志推送回调(界面用来实时显示日志)。传 nil 表示取消。 func SetEmitter(fn func(Entry)) { emitterMu.Lock() emitter = fn emitterMu.Unlock() } func currentEmitter() func(Entry) { emitterMu.RLock() defer emitterMu.RUnlock() return emitter } func levelPriority(level string) int { switch strings.ToLower(strings.TrimSpace(level)) { case LevelDebug: return -1 case LevelWarn: return 1 case LevelError: return 2 case "", LevelInfo, LevelSuccess: return 0 default: return 0 } } func normalizeLevel(level string) string { switch strings.ToLower(strings.TrimSpace(level)) { case LevelDebug: return LevelDebug case LevelWarn: return LevelWarn case LevelError: return LevelError case LevelSuccess: return LevelSuccess default: return LevelInfo } } // LogEvent 输出带分类和级别的结构化日志。 func LogEvent(category, level, format string, args ...any) { logEntry(category, level, "", format, args...) } // LogAccountEvent 输出带账号标签的结构化日志,便于界面按账号观察抢购链路。 func LogAccountEvent(category, level, account string, format string, args ...any) { logEntry(category, level, account, format, args...) } func logEntry(category, level, account, format string, args ...any) { level = normalizeLevel(level) if levelPriority(level) < int(minLevel.Load()) { return } if category = strings.TrimSpace(category); category == "" { category = "系统" } now := time.Now() timeStr := now.Format(entryTimeLayout) if offset := time.Duration(serverOffset.Load()); offset != 0 { serverTime := now.Add(offset) timeStr = fmt.Sprintf("%s(H%s)", timeStr, serverTime.Format(entryTimeLayout)) } entry := Entry{ Time: timeStr, Level: level, Category: category, Account: account, Message: fmt.Sprintf(format, args...), at: now, } if current := currentSink(); current != nil { current.write(entry) } } // formatLogLine 把条目渲染成落盘的一行文本。 func formatLogLine(entry Entry) string { at := entry.at if at.IsZero() { at = time.Now() } prefix := at.Format(lineTimeLayout) if offset := time.Duration(serverOffset.Load()); offset != 0 { serverTime := at.Add(offset) prefix = fmt.Sprintf("%s(H%02d:%02d:%02d.%03d)", prefix, serverTime.Hour(), serverTime.Minute(), serverTime.Second(), serverTime.Nanosecond()/1e6) } if entry.Account != "" { return fmt.Sprintf("%s [%s][%s][账号:%s] %s\n", prefix, entry.Category, entry.Level, entry.Account, entry.Message) } return fmt.Sprintf("%s [%s][%s] %s\n", prefix, entry.Category, entry.Level, entry.Message) } // logFileHeader 说明日志格式与运行上下文,多实例日志汇总后仍能分清来源。 func logFileHeader(instanceName, instanceID, dataDir string) string { if instanceName == "" { instanceName = "未知实例" } if instanceID == "" { instanceID = "unknown" } var b strings.Builder b.WriteString("==================================================================\n") fmt.Fprintf(&b, "实例: %s (%s)\n", instanceName, instanceID) if dataDir != "" { fmt.Fprintf(&b, "数据目录: %s\n", dataDir) } b.WriteString("时间格式: 本地时间(H虎牙估算时间) [分类][级别][账号] 正文\n") b.WriteString(" H 为经服务端校准的虎牙时间估算;校准完成前偏移为 0,只显示本地时间\n") b.WriteString("逐条网络请求为 debug 级,默认不显示;排查时把日志等级改成 debug\n") b.WriteString("==================================================================\n") return b.String() }