Files
huya-go/internal/sniper/optimization_test.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

893 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 != 3 {
t.Fatalf("售罄阶段倍率=%d, want 3", got)
}
}
// TestCompleteRoundSessionStreakIsScopedToAccount 验证会话失效计数只属于当前账号,
// 网络/协议错误不会洗掉计数,任意可信业务回包才会清零。
func TestCompleteRoundSessionStreakIsScopedToAccount(t *testing.T) {
state := newRunState([]Account{{UID: 1}, {UID: 2}}, 1, 1, Config{})
state.completeRound(1, []string{CategorySessionLost}, defaultSessionLostLimit)
state.completeRound(1, []string{CategorySessionLost}, defaultSessionLostLimit)
if got := state.accounts[1].sessionStreak; got != 2 {
t.Fatalf("sessionStreak=%d, want 2", got)
}
state.completeRound(2, []string{CategorySoldOut}, defaultSessionLostLimit)
if got := state.accounts[1].sessionStreak; got != 2 {
t.Fatalf("其他账号不应重置会话失效计数,got=%d", got)
}
state.completeRound(1, []string{CategoryError}, defaultSessionLostLimit)
state.completeRound(1, []string{CategoryProtocol}, defaultSessionLostLimit)
if got := state.accounts[1].sessionStreak; got != 2 {
t.Fatalf("网络/协议错误不应重置会话失效计数,got=%d", got)
}
state.completeRound(1, []string{CategorySoldOut}, defaultSessionLostLimit)
if got := state.accounts[1].sessionStreak; got != 0 {
t.Fatalf("可信业务回包后应清零,got=%d", got)
}
}
// TestCompleteRoundCountsEachRoundOnce 验证多目标同一轮失败只增加一次连续失败,
// 不会因为 target 多而把退避倍率放大两次。
func TestCompleteRoundCountsEachRoundOnce(t *testing.T) {
state := newRunState([]Account{{UID: 1}}, 2, 1, Config{})
state.completeRound(1, []string{CategoryTooFrequent, CategorySessionLost}, defaultSessionLostLimit)
if got := state.accounts[1].failStreak; got != 1 {
t.Fatalf("failStreak=%d, want 1(每轮只计一次)", got)
}
if got := state.accounts[1].sessionStreak; got != 1 {
t.Fatalf("sessionStreak=%d, want 1", got)
}
state.completeRound(1, []string{CategorySuccess}, defaultSessionLostLimit)
if got := state.accounts[1].failStreak; got != 0 {
t.Fatalf("成功后 failStreak 应清零,got=%d", got)
}
}
// TestDefaultSessionLostLimitToleratesTransientBurst 验证未显式配置时,不会因为
// 洪峰中常见的 3 次瞬时 session_lost 就把有效账号整场踢出。
func TestDefaultSessionLostLimitToleratesTransientBurst(t *testing.T) {
if got := sessionLostLimit(Config{}); got <= 3 {
t.Fatalf("默认会话失效阈值=%d,want > 3(避免瞬时洪峰误停账号)", got)
}
}
// TestPrefireProbeCountBounds 验证探活账号数不会跟着高并发设置无限放大。
func TestPrefireProbeCountBounds(t *testing.T) {
tests := []struct {
configured int
total int
want int
}{
{configured: 0, total: 100, want: 1},
{configured: 2, total: 100, want: 2},
{configured: 8, total: 100, want: 8},
{configured: 100, total: 100, want: 8},
{configured: 5, total: 3, want: 3},
{configured: 5, total: 0, want: 0},
}
for _, tt := range tests {
if got := prefireProbeCount(tt.configured, tt.total); got != tt.want {
t.Fatalf("prefireProbeCount(%d,%d)=%d, want %d", tt.configured, tt.total, got, tt.want)
}
}
}
// TestPrewarmConcurrencyBounds 验证预热并发不会跟着抢购线程设置过低或过高。
func TestPrewarmConcurrencyBounds(t *testing.T) {
tests := []struct {
input int
want int
}{
{input: 0, want: 8},
{input: 1, want: 8},
{input: 5, want: 8},
{input: 12, want: 12},
{input: 100, want: 16},
}
for _, tt := range tests {
if got := prewarmConcurrency(tt.input); got != tt.want {
t.Fatalf("prewarmConcurrency(%d)=%d, want %d", tt.input, got, tt.want)
}
}
}
// 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)
}
}
// TestRunTargetsThrottleOnOneTargetDoesNotSkipAnother 验证账号级退避不会让
// 一个 target 的限流结果阻塞同一轮里的另一个 target。这是 per-target 重试
// 状态被移除后的核心行为。
func TestRunTargetsThrottleOnOneTargetDoesNotSkipAnother(t *testing.T) {
var secondSucceeded atomic.Bool
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
if pid == 1 {
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil
}
secondSucceeded.Store(true)
return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功", OrderID: "O1"}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{
{PID: 1},
{PID: 2},
}, Config{
SID: 2203,
maxAttempts: 1,
Delay: time.Millisecond,
DirectExchange: true,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
if _, count, _ := client.counts(); count != 2 {
t.Fatalf("exchangeCount=%d, want 2(同一轮两个目标都要尝试)", count)
}
if !secondSucceeded.Load() {
t.Fatal("第二个目标应成功")
}
}
func TestRunTargetsWaitsForWindowWithoutPrefire(t *testing.T) {
var mu sync.Mutex
var first time.Time
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
mu.Lock()
if first.IsZero() {
first = time.Now()
}
mu.Unlock()
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil
},
}
openAt := time.Now().Add(300 * time.Millisecond)
err := (&Runner{Client: client}).RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 1}}, Config{
SID: 2203,
OpenAt: openAt,
windowOverride: 20 * time.Millisecond,
maxAttempts: 1,
Delay: time.Millisecond,
StartAhead: 100 * time.Millisecond,
ErrorBackoff: time.Millisecond,
TooFrequentBackoff: time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
mu.Lock()
got := first
mu.Unlock()
windowStart := openAt.Add(-100 * time.Millisecond)
if got.Before(windowStart.Add(-10 * time.Millisecond)) {
t.Fatalf("首次请求=%v, 早于窗口起点 %v", got, windowStart)
}
}
// TestRunTargetsStopAccountAfterSuccessStopsAllTargets 验证「账号成功停」是账号级开关:
// 成功一个商品后,同一账号不应继续兑换剩余目标。
func TestRunTargetsStopAccountAfterSuccessStopsAllTargets(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{
{PID: 1},
{PID: 2},
}, Config{
SID: 2203,
maxAttempts: 2,
Delay: time.Millisecond,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
if _, exchangeCount, _ := client.counts(); exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1(账号成功后应停止全部目标)", exchangeCount)
}
}
// TestRunTargetsSessionLostStopsWholeAccount 验证会话连续失效达到阈值后停止整个账号,
// 而不是只停当前目标。
func TestRunTargetsSessionLostStopsWholeAccount(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: 1},
{PID: 2},
}, Config{
SID: 2203,
maxAttempts: 10,
Delay: 5 * time.Millisecond,
ErrorBackoff: time.Millisecond,
SessionLostLimit: 3,
}, nil)
if err != nil {
t.Fatal(err)
}
// 每轮会尝试两个目标;第 1 轮 2 次、第 2 轮 2 次、第 3 轮两个目标都返回
// session_lost 后账号级计数达到 3,共 6 次请求。
if _, exchangeCount, _ := client.counts(); exchangeCount != 6 {
t.Fatalf("exchangeCount=%d, want 6(达到阈值后应停止整个账号)", exchangeCount)
}
}
// TestRunStateElapsedUsesOpenAt 验证退避阶段从开抢时刻起算,而不是从提前启动时起算。
func TestRunStateElapsedUsesOpenAt(t *testing.T) {
state := &runState{
startedAt: time.Now().Add(-10 * time.Second),
openAt: time.Now().Add(-2 * time.Second),
}
if got := state.elapsed(); got < 1900*time.Millisecond || got > 3*time.Second {
t.Fatalf("elapsed=%v, want 约 2s(按 openAt 计算)", got)
}
}
// TestRunTargetsNotFoundBacksOff 验证未命中的每次真实尝试之间会退避。
func TestRunTargetsNotFoundBacksOff(t *testing.T) {
client := &fakeActivityClient{listResp: &huya.GetActPrizeListResp{Status: 200}}
runner := Runner{Client: client}
var notFound int32
started := time.Now()
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{
{Name: "不存在的奖品"},
}, Config{
SID: 2203,
maxAttempts: 3,
Delay: time.Millisecond,
ErrorBackoff: 25 * time.Millisecond,
ListBeforeExchange: true,
}, func(result Result) {
if result.Category == CategoryNotFound {
atomic.AddInt32(&notFound, 1)
}
})
if err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(&notFound); got != 3 {
t.Fatalf("not_found 事件=%d, want 3 次真实尝试", got)
}
if elapsed := time.Since(started); elapsed < 45*time.Millisecond {
t.Fatalf("未命中退避没有生效,耗时=%v", elapsed)
}
}
// TestRunTargetsNotOpenBacksOff 验证未开放的每次真实尝试之间也会退避。
func TestRunTargetsNotOpenBacksOff(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: 1},
}, Config{
SID: 2203,
maxAttempts: 3,
Delay: time.Millisecond,
ErrorBackoff: 25 * time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
if _, exchangeCount, _ := client.counts(); exchangeCount != 3 {
t.Fatalf("exchangeCount=%d, want 3 次真实尝试", exchangeCount)
}
if elapsed := time.Since(started); elapsed < 90*time.Millisecond {
t.Fatalf("未开放退避没有生效,耗时=%v", elapsed)
}
}
// TestExchangeTargetSkipsAfterWindow 验证排队拿到并发额度时窗口已结束,
// 不会把兑换请求补发到窗口外。
func TestExchangeTargetSkipsAfterWindow(t *testing.T) {
client := &fakeActivityClient{}
account := Account{UID: 1, Cookie: "yyuid=1"}
state := newRunState([]Account{account}, 1, 1, Config{
OpenAt: time.Now().Add(-2 * time.Second),
WindowSeconds: 1,
})
result := (&Runner{Client: client}).exchangeTarget(
context.Background(), client, state, account, Target{PID: 1}, 0, 1, 2203, nil,
)
if result.Action != actionExchangeCanceled {
t.Fatalf("action=%q, want %q", result.Action, actionExchangeCanceled)
}
if _, exchangeCount, _ := client.counts(); exchangeCount != 0 {
t.Fatalf("exchangeCount=%d, want 0(窗口外不能补发)", exchangeCount)
}
}
// TestRunTargetsDirectExchangeHonorsPriceFilter 验证设置价格门槛后不能走直兑绕过过滤。
func TestRunTargetsDirectExchangeHonorsPriceFilter(t *testing.T) {
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Prizes: []huya.PrizeInfo{{ID: 1, Name: "涨价商品", NewScore: 100}},
},
}
err := (&Runner{Client: client}).RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{
{PID: 1, MaxPrice: 50},
}, Config{
SID: 2203,
maxAttempts: 1,
Delay: time.Millisecond,
DirectExchange: true,
ListBeforeExchange: false,
}, nil)
if err != nil {
t.Fatal(err)
}
listCount, exchangeCount, _ := client.counts()
if listCount != 1 || exchangeCount != 0 {
t.Fatalf("list=%d exchange=%d, want list=1 exchange=0(价格超限不能直兑)", listCount, exchangeCount)
}
}
// TestRunTargetsPrefireProbeIsBounded 验证预打探活最多只用一个并发批次,
// 不会在账号很多时把请求拖到开抢之后。
func TestRunTargetsPrefireProbeIsBounded(t *testing.T) {
var mu sync.Mutex
calls := make([]time.Time, 0, 8)
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
},
}
accounts := make([]Account, 0, 6)
for uid := int64(1); uid <= 6; uid++ {
accounts = append(accounts, Account{UID: uid, Cookie: "yyuid=1"})
}
openAt := time.Now().Add(350 * time.Millisecond)
err := (&Runner{Client: client}).RunTargets(context.Background(), accounts, []Target{{PID: 1}}, Config{
SID: 2203,
OpenAt: openAt,
windowOverride: 30 * time.Millisecond,
maxAttempts: 1,
Delay: time.Millisecond,
StartAhead: 20 * time.Millisecond,
Concurrency: 2,
ErrorBackoff: time.Millisecond,
TooFrequentBackoff: time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
windowStart := openAt.Add(-20 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
prefire := 0
for _, at := range calls {
if at.Before(windowStart) {
prefire++
}
}
if prefire > 2 {
t.Fatalf("预打请求=%d, want <= 2(仅一个并发批次)", prefire)
}
}
// TestRetryDelayNeverExceedsCap 验证阶段倍率、连续失败倍率和抖动不会突破上限。
func TestRetryDelayNeverExceedsCap(t *testing.T) {
state := newRunState([]Account{{UID: 1}}, 1, 1, Config{})
state.accounts[1].failStreak = 20
for i := 0; i < 20; i++ {
got := state.retryDelay(1, Config{
ErrorBackoff: 300 * time.Millisecond,
TooFrequentBackoff: 300 * time.Millisecond,
}, CategoryTooFrequent)
if got > maxRetryDelay {
t.Fatalf("retryDelay=%v, want <= %v", got, maxRetryDelay)
}
}
}
// 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,
maxAttempts: 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,
maxAttempts: 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,
maxAttempts: 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,
maxAttempts: 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,
maxAttempts: 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,
maxAttempts: 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,
maxAttempts: 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,
maxAttempts: 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].targets[0].Done = true
if state.accountFinished(1, 2) {
t.Fatal("还有一个目标未完成时不应判定账号结束")
}
state.accounts[1].targets[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("全局目标已终止时账号应判定结束")
}
maxAttemptCount := newRunState([]Account{{UID: 3}}, 1, 1, Config{})
maxAttemptCount.noteAttempt(3)
maxAttemptCount.noteAttempt(2)
maxAttemptCount.noteAttempt(7)
if got := maxAttemptCount.maxAttempt(); got != 7 {
t.Fatalf("最大轮次=%d, want 7", got)
}
}