180 lines
4.7 KiB
Go
180 lines
4.7 KiB
Go
// 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
|
|
|
|
var (
|
|
emitterMu sync.RWMutex
|
|
emitter func(Entry)
|
|
)
|
|
|
|
// 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()
|
|
entry := Entry{
|
|
Time: now.Format(entryTimeLayout),
|
|
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 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("时间格式: YYYY-MM-DD HH:MM:SS.mmm [分类][级别][账号] 正文\n")
|
|
b.WriteString("逐条网络请求为 debug 级,默认不显示;排查时把日志等级改成 debug\n")
|
|
b.WriteString("==================================================================\n")
|
|
return b.String()
|
|
}
|