- 并发额度下沉到单次请求:runState.sem 只覆盖一次在途请求(列表/兑换/刷新),账号在退避等待期间释放额度;旧实现把额度给整个账号循环,退避中的账号一直霸占额度,账号多时互相饿死 - 售罄不再默认终止:normalizeConfig 去掉 StopOnSoldOut 默认 true,售罄按退避继续探测到轮次结束(别人取消或锁单过期会让库存回流);CategoryOffShelf 拆成独立分支,下架/不存在仍终止该奖品;删除已无意义的 ContinueOnSoldOut - 退避统一加 0-25% 抖动(jitterDuration,只往上抖,配置值仍是下限),避免多账号在同一时刻整批重试形成周期尖峰 - 新增 Runner.OnSummary/RunSummary:轮次、账号、目标、耗时、列表/兑换/积分/记录请求次数、成功账号数与结局分布(按次数排序),在 refresh.Wait() 之后回调;CLI 与 GUI 各输出一行汇总加逐类结局 - 取消清理:等待额度的账号返回 action=exchange_canceled 且调用方直接返回,停止监控时不再为每个排队账号刷错误结果 - http 传输补连接池参数(MaxConnsPerHost=64、MaxIdleConns=128、MaxIdleConnsPerHost=64、IdleConnTimeout=30s),多账号不再反复重建连接 - CLI/GUI 的 waitUntil 从 200ms 轮询改为一次性定时器,消除 0-200ms 随机延迟(正好错过开抢窗口) - GUI 抽出 runnerConfig(),用 ListBeforeExchange=!DirectExchange 表达界面语义,修复「直接兑换」开关失效(旧实现被引擎强制回落直兑,价格/库存过滤不生效) - 测试:sniper 新增售罄继续探测、StopOnSoldOut 即停、下架终止、在途请求数等于并发数、汇总含刷新请求、抖动区间等用例;GUI 新增 runnerConfig 用例;README 补「抢购调度与退避(2026-10-16 调整)」
664 lines
21 KiB
Go
664 lines
21 KiB
Go
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,
|
||
Loops: 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,
|
||
Loops: 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 TestRunTargetsTooFrequentBackoffSkipsNextLoop(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: 2,
|
||
Delay: time.Millisecond,
|
||
TooFrequentBackoff: 50 * time.Millisecond,
|
||
}, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, exchangeCount, _ := client.counts()
|
||
if exchangeCount != 1 {
|
||
t.Fatalf("exchangeCount=%d, want 1", exchangeCount)
|
||
}
|
||
}
|
||
|
||
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,
|
||
Loops: 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,
|
||
Loops: 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: "限量返场",
|
||
Loops: 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: "限量返场",
|
||
Loops: 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,
|
||
Loops: 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,
|
||
Loops: 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,
|
||
Loops: 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,
|
||
Loops: 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,
|
||
Loops: 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,
|
||
Loops: 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,
|
||
Loops: 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,
|
||
Loops: 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,
|
||
Loops: 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,
|
||
Loops: 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)
|
||
}
|
||
}
|