- 抢购调度重构:参考 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 构建正常,单测覆盖双时间与预热配置
205 lines
5.7 KiB
Go
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()
|
|
}
|