package sniper import ( "context" "errors" "sync" "sync/atomic" "testing" "time" "huya-go/internal/huya" ) type fakeActivityClient struct { mu sync.Mutex listCount int exchangeCount int prewarmCount int scoreCount int recordCount int listResp *huya.GetActPrizeListResp exchangeFn func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) } func (f *fakeActivityClient) GetActPrizeList(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetActPrizeListResp, error) { f.mu.Lock() f.listCount++ f.mu.Unlock() if f.listResp != nil { return f.listResp, nil } return &huya.GetActPrizeListResp{}, nil } func (f *fakeActivityClient) QueryUserScore(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserScoreResp, error) { f.mu.Lock() f.scoreCount++ f.mu.Unlock() return &huya.GetUserScoreResp{Status: 200, Msg: "请求成功"}, nil } func (f *fakeActivityClient) GetUserPrizeRecords(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserPrizeRecordsResp, error) { f.mu.Lock() f.recordCount++ f.mu.Unlock() return &huya.GetUserPrizeRecordsResp{Status: 200, Msg: "请求成功"}, nil } func (f *fakeActivityClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie string, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { f.mu.Lock() f.exchangeCount++ f.mu.Unlock() if f.exchangeFn != nil { return f.exchangeFn(uid, sid, pid) } return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil } func (f *fakeActivityClient) PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error { f.mu.Lock() f.prewarmCount++ f.mu.Unlock() return nil } func (f *fakeActivityClient) counts() (int, int, int) { f.mu.Lock() defer f.mu.Unlock() return f.listCount, f.exchangeCount, f.prewarmCount } func TestClassifyExchange(t *testing.T) { tests := []struct { name string resp *huya.ScoreExchangePrizeResp err error want string }{ {name: "success", resp: &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, want: CategorySuccess}, {name: "frequent", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "操作频繁,请稍后再试"}, want: CategoryTooFrequent}, {name: "not open", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品暂未开放兑换"}, want: CategoryNotOpen}, {name: "not enough", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "兑换货币不足"}, want: CategoryNotEnough}, {name: "already", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "已兑换该奖品"}, want: CategoryAlreadyDone}, {name: "off shelf", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品已下架"}, want: CategoryOffShelf}, {name: "sold out", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "库存不足"}, want: CategorySoldOut}, {name: "error", err: errors.New("timeout"), want: CategoryError}, { name: "success with order id", resp: &huya.ScoreExchangePrizeResp{Status: 200, OrderID: "sOrderId_2203_12889_1_1783328404_957"}, want: CategorySuccess, }, { name: "not enough in precondition tip", resp: &huya.ScoreExchangePrizeResp{ Status: 500, Msg: "兑换失败", ActPreCondition: huya.ActPreCondition{Tip: "积分不足,无法兑换"}, }, want: CategoryNotEnough, }, { name: "not enough in condition params", resp: &huya.ScoreExchangePrizeResp{ Status: 500, Msg: "兑换失败", ActPreCondition: huya.ActPreCondition{ConditionParams: map[string]string{"tip": "余额不足"}}, }, want: CategoryNotEnough, }, { name: "condition gate without text", resp: &huya.ScoreExchangePrizeResp{ Status: 500, ActPreCondition: huya.ActPreCondition{ConditionType: 2}, }, want: CategoryCondition, }, {name: "empty response", resp: &huya.ScoreExchangePrizeResp{Status: 500}, want: CategoryFailed}, {name: "nil response", resp: nil, want: CategoryFailed}, { name: "text wins over order id", resp: &huya.ScoreExchangePrizeResp{Status: 200, OrderID: "order", Msg: "库存不足"}, want: CategorySoldOut, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := ClassifyExchange(tt.resp, tt.err); got != tt.want { t.Fatalf("ClassifyExchange()=%s, want %s", got, tt.want) } }) } } func TestRunTargetsDirectExchangeSkipsListAndStopsPerAccount(t *testing.T) { client := &fakeActivityClient{} runner := Runner{Client: client} accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}} err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{ SID: 2203, maxAttempts: 5, Delay: time.Millisecond, Concurrency: 2, StopAccountAfterSuccess: true, }, nil) if err != nil { t.Fatal(err) } listCount, exchangeCount, _ := client.counts() if listCount != 0 { t.Fatalf("listCount=%d, want 0", listCount) } if exchangeCount != len(accounts) { t.Fatalf("exchangeCount=%d, want %d", exchangeCount, len(accounts)) } } func TestRunTargetsTerminalNotEnoughStopsAccountTarget(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: 4, Delay: time.Millisecond, }, nil) if err != nil { t.Fatal(err) } _, exchangeCount, _ := client.counts() if exchangeCount != 1 { t.Fatalf("exchangeCount=%d, want 1", exchangeCount) } } func TestRunTargetsTooFrequentBackoffDelaysActualAttempts(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, maxAttempts: 2, Delay: time.Millisecond, TooFrequentBackoff: 50 * time.Millisecond, }, nil) if err != nil { t.Fatal(err) } _, exchangeCount, _ := client.counts() if exchangeCount != 2 { t.Fatalf("exchangeCount=%d, want 2(maxAttempts 应统计真实尝试)", exchangeCount) } if elapsed := time.Since(started); elapsed < 45*time.Millisecond { t.Fatalf("退避没有生效,耗时=%v", elapsed) } } func TestRunTargetsSharedListForNameTargets(t *testing.T) { client := &fakeActivityClient{ listResp: &huya.GetActPrizeListResp{ Status: 200, Msg: "请求成功", Prizes: []huya.PrizeInfo{{ID: 12889, SID: 2203, Name: "套装-潮萌小黄鸭", NewScore: 988, LeftNum: 10}}, }, } 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: 1, Concurrency: 2, StopAccountAfterSuccess: true, ListBeforeExchange: true, SharePrizeList: true, }, nil) if err != nil { t.Fatal(err) } listCount, exchangeCount, _ := client.counts() if listCount != 1 { t.Fatalf("listCount=%d, want 1", listCount) } if exchangeCount != len(accounts) { t.Fatalf("exchangeCount=%d, want %d", exchangeCount, len(accounts)) } } func TestRunTargetsAutoSelectPrefersPrizeWithLeft(t *testing.T) { var gotPID int32 client := &fakeActivityClient{ listResp: &huya.GetActPrizeListResp{ Status: 200, Msg: "请求成功", Prizes: []huya.PrizeInfo{ {ID: 1, SID: 2203, Name: "无剩余", NewScore: 10, LeftNum: 0}, {ID: 2, SID: 2203, Name: "有剩余", NewScore: 20, LeftNum: 5}, }, }, exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { gotPID = pid return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil }, } runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Auto: true}}, Config{ SID: 2203, maxAttempts: 1, StopAccountAfterSuccess: true, }, nil) if err != nil { t.Fatal(err) } if gotPID != 2 { t.Fatalf("gotPID=%d, want 2", gotPID) } } // TestRunTargetsCategoryFilter 验证按类目限定后只会在该类的商品里成交。 func TestRunTargetsCategoryFilter(t *testing.T) { var gotPID int32 client := &fakeActivityClient{ listResp: &huya.GetActPrizeListResp{ Status: 200, Msg: "请求成功", Tags: []huya.PrizeTag{ {ID: 101, Name: "精英专享", Weight: 2}, {ID: 99, Name: "限量返场", Weight: 1}, }, Prizes: []huya.PrizeInfo{ {ID: 13415, Name: "套装-雪山精英", NewScore: 600, LeftNum: 100, Tags: "101"}, {ID: 13433, Name: "怪诞乔克服装礼包", NewScore: 8000, LeftNum: 50, Tags: "99"}, }, }, exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { gotPID = pid return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil }, } runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Auto: true}}, Config{ SID: 2277, Category: "限量返场", maxAttempts: 1, StopAccountAfterSuccess: true, }, nil) if err != nil { t.Fatal(err) } if gotPID != 13433 { t.Fatalf("gotPID=%d, want 13433(应只在限量返场里选品)", gotPID) } } // TestRunTargetsCategoryFilterMisses 验证类目里没有目标时给出带类目的未命中提示。 func TestRunTargetsCategoryFilterMisses(t *testing.T) { client := &fakeActivityClient{ listResp: &huya.GetActPrizeListResp{ Status: 200, Msg: "请求成功", Tags: []huya.PrizeTag{{ID: 99, Name: "限量返场", Weight: 1}}, Prizes: []huya.PrizeInfo{{ID: 13415, Name: "套装-雪山精英", NewScore: 600, Tags: "101"}}, }, } results := make([]Result, 0, 4) runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Name: "雪山"}}, Config{ SID: 2277, Category: "限量返场", maxAttempts: 1, ListBeforeExchange: true, }, func(result Result) { results = append(results, result) }) if err != nil { t.Fatal(err) } if len(results) != 1 || results[0].Category != CategoryNotFound { t.Fatalf("results=%+v, want 单条 not_found", results) } if results[0].Message != "类目[限量返场]内未查询到所需物品,继续监控" { t.Fatalf("Message=%q", results[0].Message) } _, exchangeCount, _ := client.counts() if exchangeCount != 0 { t.Fatalf("exchangeCount=%d, want 0", exchangeCount) } } func TestRunTargetsRefreshAfterSuccessEmitsRefreshResults(t *testing.T) { client := &fakeActivityClient{} runner := Runner{Client: client} results := make(chan Result, 8) err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, maxAttempts: 1, StopAccountAfterSuccess: true, RefreshAfterSuccess: true, }, func(result Result) { results <- result }) if err != nil { t.Fatal(err) } seenScore := false seenRecords := false deadline := time.After(time.Second) for !seenScore || !seenRecords { select { case result := <-results: seenScore = seenScore || result.Action == "refresh_score" seenRecords = seenRecords || result.Action == "refresh_records" case <-deadline: t.Fatalf("未收到刷新结果: score=%v records=%v", seenScore, seenRecords) } } } func TestRunTargetsConditionGateKeepsRetrying(t *testing.T) { client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { return &huya.ScoreExchangePrizeResp{ Status: 500, ActPreCondition: huya.ActPreCondition{ConditionType: 3}, }, nil }, } runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, maxAttempts: 3, Delay: 10 * time.Millisecond, ErrorBackoff: time.Millisecond, }, nil) if err != nil { t.Fatal(err) } _, exchangeCount, _ := client.counts() if exchangeCount != 3 { t.Fatalf("exchangeCount=%d, want 3", exchangeCount) } } func TestNormalizeTargetsDropsDuplicatePrizeTargets(t *testing.T) { targets := normalizeTargets([]Target{ {PID: 13409, Name: "幸运币礼包(小)"}, {PID: 13409}, {Name: "幸运币礼包(小)"}, {PID: 12889, Name: "套装-潮萌小黄鸭"}, }) if len(targets) != 2 { t.Fatalf("targets=%d, want 2: %+v", len(targets), targets) } if targets[0].PID != 13409 || targets[1].PID != 12889 { t.Fatalf("unexpected targets: %+v", targets) } } func TestRunTargetsExchangesPrizeOnceWhenTargetsDuplicated(t *testing.T) { client := &fakeActivityClient{} runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{ {PID: 13409, Name: "幸运币礼包(小)"}, {PID: 13409}, }, Config{ SID: 2277, maxAttempts: 3, Delay: time.Millisecond, StopAccountAfterSuccess: true, }, nil) if err != nil { t.Fatal(err) } _, exchangeCount, _ := client.counts() if exchangeCount != 1 { t.Fatalf("exchangeCount=%d, want 1(同一奖品不应重复兑换)", exchangeCount) } } func TestRunTargetsWaitsForRefreshBeforeReturn(t *testing.T) { client := &fakeActivityClient{} runner := Runner{Client: client} var mu sync.Mutex seen := map[string]bool{} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 13409}}, Config{ SID: 2277, maxAttempts: 1, StopAccountAfterSuccess: true, RefreshAfterSuccess: true, }, func(result Result) { mu.Lock() seen[result.Action] = true mu.Unlock() }) if err != nil { t.Fatal(err) } mu.Lock() defer mu.Unlock() if !seen["refresh_score"] || !seen["refresh_records"] { t.Fatalf("RunTargets 返回时异步刷新还没结束: %v", seen) } } func TestRunTargetsDoesNotExchangeSamePrizeTwiceViaNameMatch(t *testing.T) { client := &fakeActivityClient{ listResp: &huya.GetActPrizeListResp{ Status: 200, Prizes: []huya.PrizeInfo{{ID: 13409, Name: "幸运币礼包(小)", NewScore: 20}}, }, } runner := Runner{Client: client} // 两个不同名称的 target 都会匹配到同一个奖品。 err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{ {Name: "幸运币"}, {Name: "礼包"}, }, Config{ SID: 2277, maxAttempts: 2, Delay: time.Millisecond, DirectExchange: false, StopAccountAfterSuccess: true, }, nil) if err != nil { t.Fatal(err) } _, exchangeCount, _ := client.counts() if exchangeCount != 1 { t.Fatalf("exchangeCount=%d, want 1(同一奖品不应重复兑换)", exchangeCount) } } // TestRunTargetsSoldOutKeepsProbingUntilSuccess 验证「已抢完」不再默认终止: // 库存可能因为别人取消 / 锁单过期回流,窗口内要继续探测到成功。 func TestRunTargetsSoldOutKeepsProbingUntilSuccess(t *testing.T) { var mu sync.Mutex calls := 0 client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { mu.Lock() calls++ current := calls mu.Unlock() if current <= 2 { return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "该奖品已抢完"}, nil } return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil }, } runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, maxAttempts: 6, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, StopAccountAfterSuccess: true, }, nil) if err != nil { t.Fatal(err) } _, exchangeCount, _ := client.counts() if exchangeCount != 3 { t.Fatalf("exchangeCount=%d, want 3(售罄后应继续探测直到成功)", exchangeCount) } } // TestRunTargetsStopOnSoldOutTerminatesTarget 验证显式开启 StopOnSoldOut 时售罄即停。 func TestRunTargetsStopOnSoldOutTerminatesTarget(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: 5, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, StopOnSoldOut: true, }, nil) if err != nil { t.Fatal(err) } _, exchangeCount, _ := client.counts() if exchangeCount != 1 { t.Fatalf("exchangeCount=%d, want 1(StopOnSoldOut 时售罄即停)", exchangeCount) } } // TestRunTargetsOffShelfTerminatesTarget 验证下架/不存在仍然直接终止该 target。 func TestRunTargetsOffShelfTerminatesTarget(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: 4, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, }, nil) if err != nil { t.Fatal(err) } _, exchangeCount, _ := client.counts() if exchangeCount != 1 { t.Fatalf("exchangeCount=%d, want 1(下架应终止该 target)", exchangeCount) } } // TestRunTargetsConcurrencyLimitsInFlightRequests 验证并发额度作用在单次请求上: // 在途请求数不会超过 Concurrency,但多个账号确实能同时在途。 func TestRunTargetsConcurrencyLimitsInFlightRequests(t *testing.T) { var inFlight, peak int32 client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { current := atomic.AddInt32(&inFlight, 1) for { observed := atomic.LoadInt32(&peak) if current <= observed || atomic.CompareAndSwapInt32(&peak, observed, current) { break } } time.Sleep(10 * time.Millisecond) atomic.AddInt32(&inFlight, -1) return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "操作频繁,请稍后再试"}, nil }, } runner := Runner{Client: client} accounts := make([]Account, 0, 6) for uid := int64(1); uid <= 6; uid++ { accounts = append(accounts, Account{UID: uid, Cookie: "yyuid=1"}) } err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{ SID: 2203, maxAttempts: 1, Delay: time.Millisecond, Concurrency: 2, TooFrequentBackoff: 10 * time.Millisecond, }, nil) if err != nil { t.Fatal(err) } if got := atomic.LoadInt32(&peak); got != 2 { t.Fatalf("peak in-flight=%d, want 2(并发额度应精确限制在途请求数)", got) } } // TestRunTargetsEmitsSummary 验证任务结束时汇总请求次数与结局分布。 func TestRunTargetsEmitsSummary(t *testing.T) { client := &fakeActivityClient{} var summary RunSummary runner := Runner{ Client: client, OnSummary: func(item RunSummary) { summary = item }, } accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}} // 异步刷新只在有 emit 回调时才跑(没地方上报就不发请求),这里给个空回调。 err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{ SID: 2203, maxAttempts: 3, Delay: time.Millisecond, Concurrency: 2, StopAccountAfterSuccess: true, RefreshAfterSuccess: true, }, func(Result) {}) if err != nil { t.Fatal(err) } if summary.Accounts != len(accounts) || summary.Targets != 1 { t.Fatalf("summary=%+v", summary) } if summary.ExchangeReqs != len(accounts) { t.Fatalf("ExchangeReqs=%d, want %d", summary.ExchangeReqs, len(accounts)) } if summary.ScoreReqs != len(accounts) || summary.RecordReqs != len(accounts) { t.Fatalf("刷新请求未计入汇总: score=%d records=%d", summary.ScoreReqs, summary.RecordReqs) } if summary.SuccessUIDs != len(accounts) || summary.Outcomes[CategorySuccess] != len(accounts) { t.Fatalf("成功统计不正确: %+v", summary) } if summary.Duration <= 0 { t.Fatalf("Duration=%v, want > 0", summary.Duration) } } // TestJitterDurationAddsUpToQuarter 验证抖动只往上加、最多 25%,且不退化为 0。 func TestJitterDurationAddsUpToQuarter(t *testing.T) { base := 400 * time.Millisecond for i := 0; i < 200; i++ { got := jitterDuration(base) if got < base || got > base+base/4 { t.Fatalf("jitterDuration(%v)=%v, want [%v,%v]", base, got, base, base+base/4) } } if got := jitterDuration(0); got != 0 { t.Fatalf("jitterDuration(0)=%v, want 0", got) } if got := jitterDuration(time.Nanosecond); got != time.Nanosecond { t.Fatalf("jitterDuration(1ns)=%v, want 1ns", got) } }