Files
dygou_go/internal/logger/logger_test.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

101 lines
3.1 KiB
Go

package logger
import (
"path/filepath"
"strings"
"testing"
)
// TestStructuredLogging 验证 LogEvent→sink→MemoryLogStore 结构化链路:
// LogEntry 的 category/level/account 应直接由调用方填入,而非字符串反解析。
func TestStructuredLogging(t *testing.T) {
store := NewMemoryLogStore(100)
cleanup := InitLogging(store, t.TempDir())
defer func() {
cleanup()
globalSink = nil
}()
LogEvent("抢购", "success", "兑换成功: %s", "商品A")
LogAccountEvent("抢购", "warn", "testuser", "冷却: %ds", 5)
entries := store.Since(0)
// InitLogging 自身产生 1 条"日志文件已启用",加上上述 2 条,共 3 条
if len(entries) != 3 {
t.Fatalf("Since(0) = %d entries, want 3", len(entries))
}
if entries[0].Category != "日志" {
t.Errorf("entry0 Category = %q, want '日志' (InitLogging 启动日志)", entries[0].Category)
}
e1 := entries[1]
if e1.ID != 2 {
t.Errorf("entry1 ID = %d, want 2", e1.ID)
}
if e1.Category != "抢购" {
t.Errorf("entry1 Category = %q, want '抢购'", e1.Category)
}
if e1.Level != "success" {
t.Errorf("entry1 Level = %q, want 'success'", e1.Level)
}
if e1.Account != "" {
t.Errorf("entry1 Account = %q, want empty", e1.Account)
}
if e1.Time == "" {
t.Error("entry1 Time is empty")
}
if !strings.Contains(e1.Message, "[抢购][success]") {
t.Errorf("entry1 Message = %q, want contains '[抢购][success]'", e1.Message)
}
if !strings.Contains(e1.Message, "兑换成功: 商品A") {
t.Errorf("entry1 Message = %q, want contains '兑换成功: 商品A'", e1.Message)
}
e2 := entries[2]
if e2.ID != 3 {
t.Errorf("entry2 ID = %d, want 3", e2.ID)
}
if e2.Account != "testuser" {
t.Errorf("entry2 Account = %q, want 'testuser'", e2.Account)
}
if !strings.Contains(e2.Message, "[账号:testuser]") {
t.Errorf("entry2 Message = %q, want contains '[账号:testuser]'", e2.Message)
}
if !strings.Contains(e2.Message, "冷却: 5s") {
t.Errorf("entry2 Message = %q, want contains '冷却: 5s'", e2.Message)
}
// 验证 minLevel 过滤:设为 error 后 info 日志不应进入内存
SetMinLevel("error")
LogEvent("测试", "info", "这条应被过滤")
if got := store.Since(e2.ID); len(got) != 0 {
t.Errorf("after SetMinLevel(error), Since(%d) = %d entries, want 0", e2.ID, len(got))
}
SetMinLevel("info") // 恢复,避免影响后续测试
}
// TestCreateUniqueLogFileAvoidsCollision 同一目录同秒启动的两个进程不能写同一个文件,
// 否则日志会交错、分不清实例。
func TestCreateUniqueLogFileAvoidsCollision(t *testing.T) {
dir := t.TempDir()
base := filepath.Join(dir, "douyu_20261003_160856")
first, firstPath, err := createUniqueLogFile(base)
if err != nil {
t.Fatal(err)
}
_ = first.Close()
if got := filepath.Base(firstPath); got != "douyu_20261003_160856.log" {
t.Fatalf("first = %q", got)
}
second, secondPath, err := createUniqueLogFile(base)
if err != nil {
t.Fatal(err)
}
_ = second.Close()
if got := filepath.Base(secondPath); got != "douyu_20261003_160856-2.log" {
t.Fatalf("second = %q, want 同目录 -2 后缀", got)
}
}