Files
huya-go/internal/logger/entry.go
T
yml2213 6e5beac151 refactor(sniper): 对齐 dygou_go 调度架构,增加双时间日志并解耦预热
- 抢购调度重构:参考 dygou_go 将 sniper 拆分为独立子模块(runner/run_control/retry/exchange/classify/config/summary/category/helpers);移除全局自适应降速与轮次上限,采用账号独立循环、统一账号级退避、单请求并发闸门与首发错峰
- 预热与建连优化:默认关闭预热(避免单账号超时连坐整机开抢),开启时转为后台非阻塞协程;WebSocket 握手超时从 10s 降至 1500ms
- 双时间日志:logger 增加 SetServerOffset,校准后日志前缀自动渲染「本地时间(H虎牙估算时间)」,实现跨机器绝对毫秒对齐
- 默认配置调优:版本提升至 v0.5.0,默认线程调为 2(缓解多实例并发限流),前端自动平滑迁移
- 验证方式:go test ./... -count=1 全通,npm run build 构建正常,单测覆盖双时间与预热配置
2026-10-17 18:29:17 +08:00

205 lines
5.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
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()
}