Files
dygou_go/internal/logger/logger.go
T
yml2213 46c06180a5 重构锁单支付重试内核并修复多实例启动,发布0.3.0
- 抢购内核改为"每账号独立重试循环",移除轮次屏障:旧实现在 3 个中签账号卡住
  60 秒支付退避时,另外 31 个账号空转 39 秒,还出现过"某一轮只跑 1 个账号、其余整轮没被尝试"
- 锁单退避改为读取配置的"重试间隔策略"(同一账号连续失败成倍放大,封顶 5 秒),
  删掉硬编码 30 秒退避与"只重试一次"
- 锁单失败不再提前停止:限流(活动火爆/网络繁忙)继续重试;售罄也继续降频探测
  (别人锁单后支付失败或锁单过期,库存会回流),只在窗口结束时按售罄/限流/普通失败分类上报
- 支付重试按斗鱼错误码决策:1644430001/1644400002/1644400014 视为"订单已受理、结果未定"
  并快重试(300ms 起),取代按文案把"订单操作进行中"误判成频控的 60 秒退避;
  不再设次数上限,只受 120 秒预算与锁单过期约束,超出预算如实报"支付结果未知"
- 已锁订单在 75 秒窗口结束后仍会支付完,不再受窗口约束
- 多实例:为每个实例分配独占的 WebView2 用户数据目录。Wails 默认所有实例共用
  %APPDATA%\<可执行文件名>,而一个数据目录只能有一个浏览器进程,导致偶发开不出新实例、
  必须全部关掉重开;实例占位因此提前到 wails.Run 之前完成
- 多实例:占位失败不再静默退回默认目录(会让两个进程共享 accounts.txt/config.json、
  同秒启动还会写同一个日志文件),改为进程唯一的临时实例目录并记录降级原因
- 多实例:单个槽位锁被瞬时占用(杀软/索引器)时短暂重试,仍失败则继续试下一个槽位
  而不是整个分配失败;日志文件改为独占创建,同秒启动自动追加 -2/-3 后缀
- 新增 %LOCALAPPDATA%\douyu-go\startup.log,记录每次启动与失败原因,
  便于排查"窗口起不来"这类连应用日志都不会生成的问题
- 修复预热缓存日志每个账号打两遍、"账号尝试"计数误导(改为真实锁单/支付/直接兑换次数)
- 修复前端把含"成功"字样的失败文案显示成抢到(改为只认"成功|"前缀)
- 修复 jitterDuration 在基准小于 10ms 时 rand.Int63n(0) 会 panic 的隐患
- 设置页文案与作用域同步:重试间隔策略明确作用于锁单重试、"尝试包数"明确仅用于和平小店
2026-10-03 19:09:19 +08:00

273 lines
7.2 KiB
Go

package logger
import (
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"douyu_go/internal/constants"
"douyu_go/internal/types"
)
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 {
mu sync.RWMutex
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
}
w.mu.RLock()
defer w.mu.RUnlock()
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
}
w.mu.Lock()
defer w.mu.Unlock()
if atomic.CompareAndSwapUint32(&w.closed, 0, 1) {
close(w.ch)
w.wg.Wait()
}
return nil
}
// sink 日志输出汇集点,把结构化日志条目分发到内存、文件和终端。
type sink struct {
async *AsyncLogWriter // 文件异步写入(无文件时为 nil,仅写内存+终端)
store *MemoryLogStore
stdout bool
}
// write 分发一条日志:内存同步写入,文件异步写入,终端尽力写入。
func (s *sink) write(entry types.LogEntry) {
s.store.Append(entry)
if s.async != nil {
_, _ = s.async.Write([]byte(entry.Message + "\n"))
}
if s.stdout {
_, _ = os.Stdout.Write([]byte(entry.Message + "\n"))
}
}
var globalSink *sink
// InitLogging 初始化日志输出:日志文件和内存缓冲同时写入。
// 终端输出通过 sink 同步写入(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 {
globalSink = &sink{store: store, stdout: true}
LogEvent("日志", "warn", "日志目录创建失败,仅写入内存: %v", err)
return func() {}
}
base := filepath.Join(logDir, "douyu_"+time.Now().In(constants.CST).Format("20060102_150405"))
file, filePath, err := createUniqueLogFile(base)
if err != nil {
globalSink = &sink{store: store, stdout: true}
LogEvent("日志", "warn", "日志文件创建失败,仅写入内存: %v", err)
return func() {}
}
store.SetFilePath(filePath)
globalSink = &sink{
async: NewAsyncLogWriter(file, asyncLogBufferSize),
store: store,
stdout: true,
}
LogEvent("日志", "success", "日志文件已启用: %s", filePath)
return func() {
if globalSink != nil && globalSink.async != nil {
_ = globalSink.async.Close()
}
_ = file.Close()
}
}
// createUniqueLogFile 以 O_EXCL 递增后缀创建日志文件。
//
// 早期实现用 O_CREATE|O_APPEND + 秒级时间戳:同一目录下同秒启动的两个进程会
// 打开同一个文件并交错写入,日志既分不清实例,也会互相污染。现在改用独占创建,
// 冲突时自动追加 -2 / -3 后缀。
func createUniqueLogFile(base string) (*os.File, string, error) {
for i := 1; i <= 50; i++ {
path := base + ".log"
if i > 1 {
path = fmt.Sprintf("%s-%d.log", base, i)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err == nil {
return file, path, nil
}
if !errors.Is(err, os.ErrExist) {
return nil, "", err
}
}
return nil, "", fmt.Errorf("日志文件名连续冲突: %s", base)
}
// 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
}
now := time.Now()
message := fmt.Sprintf(format, args...)
entry := types.LogEntry{
Time: now.In(constants.CST).Format(logEntryTimeLayout),
Level: level,
Category: category,
Account: account,
Message: formatLogLine(now, category, level, account, message),
}
if globalSink != nil {
globalSink.write(entry)
}
}
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)
}
// 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
}