package sniper import ( "context" "errors" "fmt" "sync" "sync/atomic" "testing" "time" "huya-go/internal/huya" ) // TestClassifyExchangeThrottleTexts 覆盖 2026-10-16 三台机器日志里真实出现过的限流文案。 // // 这些文案过去全部落到 failed:既看不出被限流,也享受不到限流该有的退避语义 // (pc-1 的 1421 次请求里 917 次「活动太火爆」+ 244 次「领取频率限制」)。 func TestClassifyExchangeThrottleTexts(t *testing.T) { throttleTexts := []string{ "活动太火爆了,请稍后重试", "领取频率限制,请稍后重试!!", "请求太频繁,请稍后重试", "操作频繁,请稍后再试", "当前网络繁忙,请稍后重试", "您点击太快,请稍等再试", } for _, text := range throttleTexts { resp := &huya.ScoreExchangePrizeResp{Status: 500, Msg: text} if got := ClassifyExchange(resp, nil); got != CategoryTooFrequent { t.Fatalf("ClassifyExchange(%q)=%s, want %s", text, got, CategoryTooFrequent) } } } // TestClassifyExchangeOrderPrefersSpecificReason 验证具体原因优先于限流: // 「库存不足,请稍后重试」必须判成售罄而不是限流。 func TestClassifyExchangeOrderPrefersSpecificReason(t *testing.T) { cases := []struct { text string want string }{ {text: "库存不足,请稍后重试", want: CategorySoldOut}, {text: "该奖品已抢完", want: CategorySoldOut}, {text: "兑换货币不足,请参与活动获得兑换货币", want: CategoryNotEnough}, {text: "奖品暂未开放兑换", want: CategoryNotOpen}, {text: "账号未绑定!", want: CategorySessionLost}, {text: "登录态已失效,请重新登录", want: CategorySessionLost}, {text: "奖品已下架", want: CategoryOffShelf}, } for _, tt := range cases { resp := &huya.ScoreExchangePrizeResp{Status: 500, Msg: tt.text} if got := ClassifyExchange(resp, nil); got != tt.want { t.Fatalf("ClassifyExchange(%q)=%s, want %s", tt.text, got, tt.want) } } } // TestClassifyExchangeProtocolError 验证协议层错误与普通失败分开归类。 func TestClassifyExchangeProtocolError(t *testing.T) { protocolErr := fmt.Errorf("%w: webActUI.scoreExchangePrize 响应缺少 tRsp/tResp(iRet=-1 keys={tErr})", huya.ErrProtocolResponse) if got := ClassifyExchange(nil, protocolErr); got != CategoryProtocol { t.Fatalf("协议错误应归为 %s,实际 %s", CategoryProtocol, got) } if got := ClassifyExchange(nil, errors.New("timeout")); got != CategoryError { t.Fatalf("普通网络错误应归为 %s,实际 %s", CategoryError, got) } } // TestCategoryLabelAndLogLevel 验证新增分类的标签与日志等级映射。 func TestCategoryLabelAndLogLevel(t *testing.T) { if got := CategoryLabel(CategoryProtocol); got != "协议异常" { t.Fatalf("CategoryLabel(protocol)=%q", got) } if got := LogLevelForCategory(CategoryProtocol); got != "error" { t.Fatalf("协议异常应为 error 级,实际 %s", got) } if got := LogLevelForCategory(CategorySessionLost); got != "warn" { t.Fatalf("会话失效应为 warn 级,实际 %s", got) } } // TestRetryEscalationMath 验证退避阶梯的两个纯函数。 func TestRetryEscalationMath(t *testing.T) { wantStreak := map[int]int{1: 1, 2: 1, 3: 2, 4: 4, 5: 4, 9: 4} for streak, want := range wantStreak { if got := streakMultiplier(streak); got != want { t.Fatalf("streakMultiplier(%d)=%d, want %d", streak, got, want) } } wantStage := map[time.Duration]int{ 500 * time.Millisecond: 1, 3 * time.Second: 2, 6 * time.Second: 3, } for elapsed, want := range wantStage { if got := stageMultiplier(elapsed, CategoryFailed); got != want { t.Fatalf("stageMultiplier(%v)=%d, want %d", elapsed, got, want) } } // 正常等待类(售罄/未开放)放慢得更保守,避免错过库存回流。 if got := stageMultiplier(6*time.Second, CategorySoldOut); got != 2 { t.Fatalf("售罄阶段倍率=%d, want 2", got) } } // TestThrottleGovernorEscalatesAndRecovers 验证全局降速器:连续无进展才降速、有进展立刻回退。 func TestThrottleGovernorEscalatesAndRecovers(t *testing.T) { g := newThrottleGovernor() g.minRequests = 2 g.stallWindow = time.Millisecond g.lastProgress = time.Now().Add(-time.Second) g.lastEscalate = time.Now().Add(-time.Second) g.observe(CategoryTooFrequent) if got := g.current(); got != 1 { t.Fatalf("未达阈值不该降速,factor=%v", got) } g.observe(CategoryTooFrequent) if got := g.current(); got <= 1 { t.Fatalf("连续无进展应开始降速,factor=%v", got) } if got := g.peak(); got <= 1 { t.Fatalf("peak 应记录最大倍率,实际 %v", got) } before := g.current() g.observe(CategorySuccess) if got := g.current(); got >= before { t.Fatalf("有进展后应回退,factor 从 %v 到 %v", before, got) } if got := g.current(); got < 1 { t.Fatalf("倍率不应小于 1,实际 %v", got) } } // TestRunStateFirstWaveStagger 验证首发错峰:第 0 个账号立即发,其余按步长错开,每个账号只生效一次。 func TestRunStateFirstWaveStagger(t *testing.T) { accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}, {UID: 3, Cookie: "yyuid=3"}} state := newRunState(accounts, 1, 1, Config{ OpenAt: time.Now(), FirstWaveSpread: DefaultFirstWaveSpread, FirstWaveMaxStep: DefaultFirstWaveMaxStep, }) want := []time.Duration{0, DefaultFirstWaveMaxStep, 2 * DefaultFirstWaveMaxStep} for index, account := range accounts { if got := state.takeFirstWaveDelay(account.UID); got != want[index] { t.Fatalf("账号 %d 首发等待=%v, want %v", account.UID, got, want[index]) } } if got := state.takeFirstWaveDelay(1); got != 0 { t.Fatalf("同一账号不应重复错峰,实际 %v", got) } } // TestRunTargetsWindowModeKeepsTryingUntilDeadline 是 P1 的核心回归: // 账号很少时,旧实现会「20 轮 × 150ms」在开抢前把预算烧光(pc-3 instance-4 0 成功), // 窗口模式必须一直打到窗口结束。 func TestRunTargetsWindowModeKeepsTryingUntilDeadline(t *testing.T) { client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil }, } runner := Runner{Client: client} started := time.Now() err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, OpenAt: started, windowOverride: 120 * time.Millisecond, Loops: 0, // 窗口模式:不限制轮次 Delay: 5 * time.Millisecond, ErrorBackoff: time.Millisecond, TooFrequentBackoff: time.Millisecond, }, nil) if err != nil { t.Fatal(err) } if elapsed := time.Since(started); elapsed < 100*time.Millisecond { t.Fatalf("窗口模式应跑到窗口结束,实际只跑了 %v", elapsed) } _, exchangeCount, _ := client.counts() if exchangeCount < 5 { t.Fatalf("窗口内应持续尝试,实际只有 %d 次", exchangeCount) } } // TestRunTargetsPrefireOnceBeforeOpen 验证开抢前只预打一轮,然后静默等到窗口起点。 func TestRunTargetsPrefireOnceBeforeOpen(t *testing.T) { var mu sync.Mutex var calls []time.Time client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { mu.Lock() calls = append(calls, time.Now()) mu.Unlock() return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品暂未开放兑换"}, nil }, } runner := Runner{Client: client} // 触发时刻远早于窗口起点:应当先预打一轮,再静默等到窗口起点。 openAt := time.Now().Add(500 * time.Millisecond) started := time.Now() err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, OpenAt: openAt, windowOverride: 60 * time.Millisecond, Loops: 0, Delay: 5 * time.Millisecond, StartAhead: 20 * time.Millisecond, ErrorBackoff: time.Millisecond, TooFrequentBackoff: time.Millisecond, }, nil) if err != nil { t.Fatal(err) } mu.Lock() captured := append([]time.Time(nil), calls...) mu.Unlock() if len(captured) < 2 { t.Fatalf("应有预打 + 窗口内重试,实际 %d 次", len(captured)) } windowStart := openAt.Add(-20 * time.Millisecond) if !captured[0].Before(windowStart.Add(-50 * time.Millisecond)) { t.Fatalf("第一次请求应明显早于窗口起点(预打),实际 %v(窗口起点 %v)", captured[0], windowStart) } for _, at := range captured[1:] { if at.Before(windowStart.Add(-30 * time.Millisecond)) { t.Fatalf("预打之后应等到窗口起点,实际在 %v(窗口起点 %v)", at, windowStart) } } if elapsed := time.Since(started); elapsed < 500*time.Millisecond { t.Fatalf("应跑到窗口结束(%v),实际 %v", openAt.Add(60*time.Millisecond), elapsed) } } // TestRunTargetsSkipsPrefireNearWindowStart 验证触发时刻与窗口起点重合时不会把首发齐射打两遍。 func TestRunTargetsSkipsPrefireNearWindowStart(t *testing.T) { client := &fakeActivityClient{} runner := Runner{Client: client} openAt := time.Now().Add(60 * time.Millisecond) err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, OpenAt: openAt, windowOverride: 40 * time.Millisecond, Loops: 1, // 只跑一轮,用于观察首发是否被重复 StartAhead: 60 * time.Millisecond, }, nil) if err != nil { t.Fatal(err) } if _, exchangeCount, _ := client.counts(); exchangeCount != 1 { t.Fatalf("窗口起点附近不应额外预打,实际请求 %d 次", exchangeCount) } } // TestRunTargetsWindowAlreadyExpiredSkips 验证窗口已过时不再发请求。 func TestRunTargetsWindowAlreadyExpiredSkips(t *testing.T) { client := &fakeActivityClient{} runner := Runner{Client: client} started := time.Now() err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, OpenAt: time.Now().Add(-2 * time.Second), windowOverride: time.Second, Loops: 0, }, nil) if err != nil { t.Fatal(err) } if _, exchangeCount, _ := client.counts(); exchangeCount != 0 { t.Fatalf("窗口已过不该发请求,实际 %d 次", exchangeCount) } if elapsed := time.Since(started); elapsed > 200*time.Millisecond { t.Fatalf("窗口已过应立即返回,实际 %v", elapsed) } } // TestRunTargetsSessionLostStopsAfterLimit 验证会话失效连续多次后停止该账号。 func TestRunTargetsSessionLostStopsAfterLimit(t *testing.T) { client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "账号未绑定!"}, nil }, } runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, Loops: 10, Delay: 2 * time.Millisecond, ErrorBackoff: time.Millisecond, SessionLostLimit: 3, }, nil) if err != nil { t.Fatal(err) } _, exchangeCount, _ := client.counts() if exchangeCount != 3 { t.Fatalf("连续失效达到阈值应停止该账号,实际请求 %d 次", exchangeCount) } } // TestRunTargetsSummaryIncludesRequestAndAccountStats 验证接口统计与逐账号结局。 func TestRunTargetsSummaryIncludesRequestAndAccountStats(t *testing.T) { client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { return nil, errors.New("网络抖动") }, } var summary RunSummary runner := Runner{Client: client, OnSummary: func(item RunSummary) { summary = item }} err := runner.RunTargets(context.Background(), []Account{{UID: 7, Cookie: "yyuid=7"}}, []Target{{PID: 12889}}, Config{ SID: 2203, Loops: 2, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, StopAfterSuccess: true, }, func(Result) {}) if err != nil { t.Fatal(err) } var exchangeStat *RequestStat for index := range summary.RequestStats { if summary.RequestStats[index].Kind == reqExchange.String() { exchangeStat = &summary.RequestStats[index] } } if exchangeStat == nil { t.Fatalf("缺少兑换接口统计: %+v", summary.RequestStats) } if exchangeStat.Count < 1 || exchangeStat.Failed != exchangeStat.Count || exchangeStat.LastErr == "" { t.Fatalf("兑换接口统计不正确: %+v", *exchangeStat) } if exchangeStat.Slowest <= 0 { t.Fatalf("应记录最慢耗时: %+v", *exchangeStat) } if len(summary.AccountOutcomes) != 1 { t.Fatalf("应有 1 条账号结局,实际 %+v", summary.AccountOutcomes) } outcome := summary.AccountOutcomes[0] if outcome.UID != 7 || outcome.Category != CategoryError || outcome.Attempts < 1 { t.Fatalf("账号结局不正确: %+v", outcome) } } // TestPlanTriggerDynamicAdvance 验证定时触发计划:动态提前量、预热/复校/窗口时刻。 func TestPlanTriggerDynamicAdvance(t *testing.T) { target := time.Date(2026, 10, 16, 17, 0, 0, 0, time.Local) calibration := huya.TimeCalibration{ Offset: 900 * time.Millisecond, // 本机比服务端慢 0.9s(pc-3 实测) ErrorBound: 30 * time.Millisecond, RTT: 60 * time.Millisecond, } plan := PlanTrigger(target, calibration, 0, 30*time.Second) if want := 60 * time.Millisecond; plan.Advance != want { t.Fatalf("动态提前量=%v, want %v", plan.Advance, want) } wantOpen := target.Add(-900 * time.Millisecond) if !plan.OpenAt.Equal(wantOpen) { t.Fatalf("开抢时刻=%v, want %v", plan.OpenAt, wantOpen) } if !plan.TriggerAt.Equal(wantOpen.Add(-60 * time.Millisecond)) { t.Fatalf("触发时刻=%v", plan.TriggerAt) } if !plan.WarmupAt.Equal(wantOpen.Add(-WarmupLead)) { t.Fatalf("预热时刻=%v", plan.WarmupAt) } if !plan.ResyncAt.Equal(wantOpen.Add(-ResyncLead)) { t.Fatalf("复校时刻=%v", plan.ResyncAt) } if !plan.WindowEnd.Equal(wantOpen.Add(30 * time.Second)) { t.Fatalf("窗口结束=%v", plan.WindowEnd) } if got := PlanTrigger(target, calibration, 2500*time.Millisecond, time.Second).Advance; got != 2500*time.Millisecond { t.Fatalf("显式提前量应覆盖动态值,实际 %v", got) } if got := PlanTrigger(target, huya.TimeCalibration{}, 0, time.Second).Advance; got != MinTriggerAdvance { t.Fatalf("提前量下限=%v, want %v", got, MinTriggerAdvance) } if got := WindowDuration(0); got != DefaultWindowSeconds*time.Second { t.Fatalf("默认窗口=%v", got) } } // TestRunTargetsPerAccountLoopsAreIndependent 是「每账号独立循环」的核心回归: // 一个账号卡在慢请求上时,其他账号必须继续按自己的节奏推进。 // // 旧实现按「一轮打完等所有人」(wg.Wait)同步,账号 1 的慢请求会把账号 2 的第二轮 // 死死卡在起跑线上;账号少的实例尤其吃亏(pc-3 instance-4 因此 0 成功)。 func TestRunTargetsPerAccountLoopsAreIndependent(t *testing.T) { blocked := make(chan struct{}) entered := make(chan struct{}) var enterOnce sync.Once var slowExchanges, fastExchanges int64 client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { if uid == 1 { atomic.AddInt64(&slowExchanges, 1) enterOnce.Do(func() { close(entered) }) <-blocked return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil } atomic.AddInt64(&fastExchanges, 1) return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil }, } runner := Runner{Client: client} accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}} done := make(chan error, 1) go func() { done <- runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{ SID: 2203, Loops: 30, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, TooFrequentBackoff: time.Millisecond, Concurrency: 4, }, nil) }() select { case <-entered: case <-time.After(2 * time.Second): t.Fatal("账号 1 的慢请求未开始") } // 账号 1 仍阻塞在第一个请求里:账号 2 应当已经独立跑完多轮。 deadline := time.Now().Add(2 * time.Second) for atomic.LoadInt64(&fastExchanges) < 3 && time.Now().Before(deadline) { time.Sleep(2 * time.Millisecond) } got := atomic.LoadInt64(&fastExchanges) close(blocked) if err := <-done; err != nil { t.Fatal(err) } if got < 3 { t.Fatalf("账号 1 阻塞期间账号 2 只完成了 %d 次兑换,独立循环应继续推进(want >= 3)", got) } } // TestRunTargetsSharedListTTLSingleFlight 验证共享列表快照是「TTL + 单飞」: // 多个账号多轮里只查一次列表,既不被单次抖动拖垮,也不把名单打爆。 // // 只做单飞(无 TTL)时每轮都会重新查询,2 账号 × 3 轮最坏要 6 次。 func TestRunTargetsSharedListTTLSingleFlight(t *testing.T) { client := &fakeActivityClient{ listResp: &huya.GetActPrizeListResp{ Status: 200, Msg: "请求成功", Prizes: []huya.PrizeInfo{{ID: 12889, SID: 2203, Name: "套装-潮萌小黄鸭", NewScore: 988, LeftNum: 10}}, }, exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil }, } runner := Runner{Client: client} accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}} err := runner.RunTargets(context.Background(), accounts, []Target{{Name: "小黄鸭"}}, Config{ SID: 2203, Loops: 3, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, TooFrequentBackoff: time.Millisecond, Concurrency: 4, ListBeforeExchange: true, SharePrizeList: true, }, nil) if err != nil { t.Fatal(err) } listCount, _, _ := client.counts() if listCount != 1 { t.Fatalf("共享列表应只查一次(TTL 内复用),实际 %d 次", listCount) } } // TestRunStateClipToDeadline 验证等待时间被裁剪到窗口结束,不会睡过 deadline。 func TestRunStateClipToDeadline(t *testing.T) { state := &runState{deadline: time.Now().Add(50 * time.Millisecond)} if got := state.clipToDeadline(10 * time.Millisecond); got != 10*time.Millisecond { t.Fatalf("窗口内等待不应被改动,实际 %v", got) } if got := state.clipToDeadline(time.Second); got <= 0 || got > 60*time.Millisecond { t.Fatalf("超出窗口的等待应被裁剪,实际 %v", got) } if got := (&runState{deadline: time.Now().Add(-time.Millisecond)}).clipToDeadline(time.Second); got != 0 { t.Fatalf("窗口已过应不再等待,实际 %v", got) } if got := (&runState{}).clipToDeadline(time.Second); got != time.Second { t.Fatalf("非窗口模式不应裁剪,实际 %v", got) } } // TestRunStateAccountFinishedAndRoundTracking 覆盖独立循环新增的两个判定: // accountFinished(账号自己打完就退出,同时尊重全局目标终止)与最大轮次统计。 func TestRunStateAccountFinishedAndRoundTracking(t *testing.T) { state := newRunState([]Account{{UID: 1}}, 2, 1, Config{}) if state.accountFinished(1, 2) { t.Fatal("目标都没完成时不应判定账号结束") } state.accounts[1][0].Done = true if state.accountFinished(1, 2) { t.Fatal("还有一个目标未完成时不应判定账号结束") } state.accounts[1][1].Done = true if !state.accountFinished(1, 2) { t.Fatal("所有目标完成后应判定账号结束") } // 全局目标终止(售罄 / 下架)后,即使本账号状态未落,也算完成。 global := newRunState([]Account{{UID: 2}}, 1, 1, Config{}) global.targetDone[0] = true if !global.accountFinished(2, 1) { t.Fatal("全局目标已终止时账号应判定结束") } rounds := newRunState([]Account{{UID: 3}}, 1, 1, Config{}) rounds.noteRound(3) rounds.noteRound(2) rounds.noteRound(7) if got := rounds.maxRound(); got != 7 { t.Fatalf("最大轮次=%d, want 7", got) } }