- 抢购调度重构:参考 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 构建正常,单测覆盖双时间与预热配置
893 lines
33 KiB
Go
893 lines
33 KiB
Go
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(¬Found, 1)
|
||
}
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := atomic.LoadInt32(¬Found); 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)
|
||
}
|
||
}
|