From 43b3cb351e7d5608894500bd1c14a331179b4ac1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 18 Oct 2026 21:23:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(sniper):=20=E5=A2=9E=E5=8A=A01.5s=E5=BF=AB?= =?UTF-8?q?=E9=80=9F=E8=B6=85=E6=97=B6=E7=86=94=E6=96=AD=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E8=A7=92=E8=89=B2=E6=9C=AA=E7=BB=91=E5=AE=9A=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E8=A7=A3=E7=BB=91=E4=BC=9A=E8=AF=9D=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 兑换请求增加独立的 RequestTimeout 上下文超时熔断(默认 1500ms),防止微服务卡死吃掉 5 秒 - 剥离 CategoryRoleUnbound(角色未绑定/抖动),不再计入 sessionStreak 判死阈值,杜绝误杀有效账号 - 将 CategoryRoleUnbound 与 CategoryTooFrequent 纳入 sessionAliveCategory,正常回包可重置会话计数 - 补充单测 TestRunTargetsRoleUnboundDoesNotStopAccount 与 TestExchangeTargetFastTimeout --- internal/sniper/category.go | 4 +- internal/sniper/classify.go | 4 +- internal/sniper/config.go | 3 ++ internal/sniper/exchange.go | 14 ++++-- internal/sniper/optimization_test.go | 70 ++++++++++++++++++++++++++-- internal/sniper/retry.go | 13 ++++-- internal/sniper/runner.go | 4 +- internal/sniper/sniper.go | 16 +++++-- internal/sniper/sniper_test.go | 4 ++ 9 files changed, 112 insertions(+), 20 deletions(-) diff --git a/internal/sniper/category.go b/internal/sniper/category.go index 59600fa..843e29e 100644 --- a/internal/sniper/category.go +++ b/internal/sniper/category.go @@ -50,6 +50,8 @@ func CategoryLabel(category string) string { return "已下架" case CategorySessionLost: return "会话失效" + case CategoryRoleUnbound: + return "角色未绑定/抖动" case CategoryProtocol: return "协议异常" case CategoryCondition: @@ -79,7 +81,7 @@ func LogLevelForCategory(category string) string { return "success" case CategoryError, CategoryProtocol: return "error" - case CategoryFailed, CategoryTooFrequent, CategoryCondition, CategorySessionLost: + case CategoryFailed, CategoryTooFrequent, CategoryCondition, CategoryRoleUnbound, CategorySessionLost: return "warn" default: return "info" diff --git a/internal/sniper/classify.go b/internal/sniper/classify.go index be613cc..06e0cfc 100644 --- a/internal/sniper/classify.go +++ b/internal/sniper/classify.go @@ -113,7 +113,9 @@ func classifyExchangeText(text string) (string, bool) { return CategoryNotEnough, true case containsAny(text, "已兑换", "重复兑换", "已领取", "超过兑换次数", "达到上限", "限购"): return CategoryAlreadyDone, true - case containsAny(text, "账号未绑定", "未绑定账号", "未绑定手机", "登录态", "登录失效", "请先登录", "凭证失效", "token 失效"): + case containsAny(text, "账号未绑定", "未绑定账号", "未绑定角色", "未绑定游戏角色"): + return CategoryRoleUnbound, true + case containsAny(text, "未绑定手机", "登录态", "登录失效", "请先登录", "凭证失效", "token 失效"): return CategorySessionLost, true case containsAny(text, "奖品已下架", "已下架", "奖品不存在", "活动不存在"): return CategoryOffShelf, true diff --git a/internal/sniper/config.go b/internal/sniper/config.go index 583ac75..283fdc5 100644 --- a/internal/sniper/config.go +++ b/internal/sniper/config.go @@ -28,6 +28,9 @@ func normalizeConfig(cfg Config) Config { if cfg.FirstWaveSpread > 0 && cfg.FirstWaveMaxStep <= 0 { cfg.FirstWaveMaxStep = DefaultFirstWaveMaxStep } + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = DefaultRequestTimeout + } // 积分不足是终态:积分不会自己变多,继续打只是浪费请求,默认停手。 if !cfg.ContinueOnNotEnough { cfg.StopOnNotEnough = true diff --git a/internal/sniper/exchange.go b/internal/sniper/exchange.go index 5fbae1b..1110cd5 100644 --- a/internal/sniper/exchange.go +++ b/internal/sniper/exchange.go @@ -48,7 +48,7 @@ func (s *runState) markDuplicateNotified(uid, prizeID int64) bool { return true } -func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, state *runState, account Account, target Target, index int, loop int, sid int32, prize *huya.PrizeInfo) Result { +func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, state *runState, account Account, target Target, index int, loop int, sid int32, prize *huya.PrizeInfo, reqTimeout time.Duration) Result { pid := target.PID if prize != nil { pid = int32(prize.ID) @@ -77,7 +77,13 @@ func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, return result } started := time.Now() - exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid) + timeout := reqTimeout + if timeout <= 0 { + timeout = DefaultRequestTimeout + } + reqCtx, reqCancel := context.WithTimeout(ctx, timeout) + exchange, err := client.ScoreExchangePrize(reqCtx, account.UID, account.Cookie, sid, pid) + reqCancel() state.release() state.recordRequest(reqExchange, time.Since(started), err) result.Exchange = exchange @@ -86,7 +92,9 @@ func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, if err != nil { result.Action = "exchange_error" result.Error = err.Error() - if result.Message == "" { + if errors.Is(err, context.DeadlineExceeded) { + result.Message = "兑换请求超时(熔断)" + } else if result.Message == "" { result.Message = "兑换请求失败" } } diff --git a/internal/sniper/optimization_test.go b/internal/sniper/optimization_test.go index ddd21d4..14f2b0a 100644 --- a/internal/sniper/optimization_test.go +++ b/internal/sniper/optimization_test.go @@ -44,7 +44,7 @@ func TestClassifyExchangeOrderPrefersSpecificReason(t *testing.T) { {text: "该奖品已抢完", want: CategorySoldOut}, {text: "兑换货币不足,请参与活动获得兑换货币", want: CategoryNotEnough}, {text: "奖品暂未开放兑换", want: CategoryNotOpen}, - {text: "账号未绑定!", want: CategorySessionLost}, + {text: "账号未绑定!", want: CategoryRoleUnbound}, {text: "登录态已失效,请重新登录", want: CategorySessionLost}, {text: "奖品已下架", want: CategoryOffShelf}, } @@ -72,9 +72,15 @@ func TestCategoryLabelAndLogLevel(t *testing.T) { if got := CategoryLabel(CategoryProtocol); got != "协议异常" { t.Fatalf("CategoryLabel(protocol)=%q", got) } + if got := CategoryLabel(CategoryRoleUnbound); got != "角色未绑定/抖动" { + t.Fatalf("CategoryLabel(role_unbound)=%q", got) + } if got := LogLevelForCategory(CategoryProtocol); got != "error" { t.Fatalf("协议异常应为 error 级,实际 %s", got) } + if got := LogLevelForCategory(CategoryRoleUnbound); got != "warn" { + t.Fatalf("角色未绑定应为 warn 级,实际 %s", got) + } if got := LogLevelForCategory(CategorySessionLost); got != "warn" { t.Fatalf("会话失效应为 warn 级,实际 %s", got) } @@ -314,7 +320,7 @@ func TestRunTargetsStopAccountAfterSuccessStopsAllTargets(t *testing.T) { func TestRunTargetsSessionLostStopsWholeAccount(t *testing.T) { client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { - return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "账号未绑定!"}, nil + return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "登录态已失效,请重新登录"}, nil }, } runner := Runner{Client: client} @@ -417,7 +423,7 @@ func TestExchangeTargetSkipsAfterWindow(t *testing.T) { WindowSeconds: 1, }) result := (&Runner{Client: client}).exchangeTarget( - context.Background(), client, state, account, Target{PID: 1}, 0, 1, 2203, nil, + context.Background(), client, state, account, Target{PID: 1}, 0, 1, 2203, nil, 0, ) if result.Action != actionExchangeCanceled { t.Fatalf("action=%q, want %q", result.Action, actionExchangeCanceled) @@ -641,7 +647,7 @@ func TestRunTargetsWindowAlreadyExpiredSkips(t *testing.T) { func TestRunTargetsSessionLostStopsAfterLimit(t *testing.T) { client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { - return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "账号未绑定!"}, nil + return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "登录态已失效,请重新登录"}, nil }, } runner := Runner{Client: client} @@ -661,6 +667,62 @@ func TestRunTargetsSessionLostStopsAfterLimit(t *testing.T) { } } +// TestRunTargetsRoleUnboundDoesNotStopAccount 验证「账号未绑定!」属于角色微服务抖动, +// 绝不会作为会话失效而将账号停止,账号会打满 maxAttempts。 +func TestRunTargetsRoleUnboundDoesNotStopAccount(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: 6, + Delay: 2 * time.Millisecond, + ErrorBackoff: time.Millisecond, + SessionLostLimit: 3, // 即使 SessionLostLimit 是 3,未绑定也不会被当作 session_lost 停止 + }, nil) + if err != nil { + t.Fatal(err) + } + _, exchangeCount, _ := client.counts() + if exchangeCount != 6 { + t.Fatalf("角色未绑定不应停止账号,应打满 6 次,实际请求 %d 次", exchangeCount) + } +} + +// TestExchangeTargetFastTimeout 验证单次兑换请求受到紧凑超时保护,服务端挂起时能迅速熔断并返回超时错误。 +func TestExchangeTargetFastTimeout(t *testing.T) { + client := &fakeActivityClient{ + exchangeCtxFn: func(ctx context.Context, uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { + select { + case <-time.After(150 * time.Millisecond): + return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + } + account := Account{UID: 1, Cookie: "yyuid=1"} + state := newRunState([]Account{account}, 1, 1, Config{}) + started := time.Now() + // 设置 20ms 超时,模拟服务端卡住 + result := (&Runner{Client: client}).exchangeTarget( + context.Background(), client, state, account, Target{PID: 1}, 0, 1, 2203, nil, 20*time.Millisecond, + ) + cost := time.Since(started) + if cost > 80*time.Millisecond { + t.Fatalf("超时熔断过慢,耗时 %v", cost) + } + if result.Category != CategoryError { + t.Fatalf("超时应归为 CategoryError,实际 %s", result.Category) + } + if result.Message != "兑换请求超时(熔断)" { + t.Fatalf("message=%q, want '兑换请求超时(熔断)'", result.Message) + } +} + // TestRunTargetsSummaryIncludesRequestAndAccountStats 验证接口统计与逐账号结局。 func TestRunTargetsSummaryIncludesRequestAndAccountStats(t *testing.T) { client := &fakeActivityClient{ diff --git a/internal/sniper/retry.go b/internal/sniper/retry.go index 7f84b29..89b8dad 100644 --- a/internal/sniper/retry.go +++ b/internal/sniper/retry.go @@ -51,10 +51,10 @@ func (s *runState) completeRound(uid int64, categories []string, sessionLimit in if hasRetry { account.failStreak++ } - if hasSessionAlive { - account.sessionStreak = 0 - } else if hasSessionLost { + if hasSessionLost { account.sessionStreak++ + } else if hasSessionAlive { + account.sessionStreak = 0 } if sessionLimit > 0 && account.sessionStreak >= sessionLimit { account.done = true @@ -101,6 +101,8 @@ func computeRetryDelay(elapsed time.Duration, failStreak int, cfg Config, catego base = cfg.TooFrequentBackoff case CategoryNotOpen: base = maxDuration(cfg.Delay, 50*time.Millisecond) + case CategoryRoleUnbound: + base = maxDuration(cfg.Delay, 200*time.Millisecond) } if base <= 0 { base = defaultRetryBackoff @@ -175,11 +177,12 @@ func streakMultiplier(streak int) int { } // sessionAliveCategory 返回该结局是否能证明当前账号的登录态仍然有效。 -// 网络错误、协议异常和限流都不算。 +// 限流、角色服务抖动以及普通业务明确回包均可证明会话存活。 func sessionAliveCategory(category string) bool { switch category { case CategorySuccess, CategoryNotOpen, CategorySoldOut, CategoryNotEnough, - CategoryAlreadyDone, CategoryOffShelf, CategoryCondition, CategoryFailed: + CategoryAlreadyDone, CategoryOffShelf, CategoryCondition, CategoryFailed, + CategoryRoleUnbound, CategoryTooFrequent: return true default: return false diff --git a/internal/sniper/runner.go b/internal/sniper/runner.go index 1df437f..802926e 100644 --- a/internal/sniper/runner.go +++ b/internal/sniper/runner.go @@ -233,7 +233,7 @@ func (r *Runner) runAccountRound(ctx context.Context, client huya.ActivityClient state.emitSkippedDuplicate(emit, round, account.UID, index, int64(target.PID), target) continue } - result := r.exchangeTarget(ctx, client, state, account, target, index, round, cfg.SID, nil) + result := r.exchangeTarget(ctx, client, state, account, target, index, round, cfg.SID, nil, cfg.RequestTimeout) if result.Action == actionExchangeCanceled { return finish() } @@ -309,7 +309,7 @@ func (r *Runner) runAccountRound(ctx context.Context, client huya.ActivityClient state.emitSkippedDuplicate(emit, round, account.UID, index, prize.ID, target) continue } - result := r.exchangeTarget(ctx, client, state, account, target, index, round, cfg.SID, prize) + result := r.exchangeTarget(ctx, client, state, account, target, index, round, cfg.SID, prize, cfg.RequestTimeout) if result.Action == actionExchangeCanceled { return finish() } diff --git a/internal/sniper/sniper.go b/internal/sniper/sniper.go index 01c769e..331c49f 100644 --- a/internal/sniper/sniper.go +++ b/internal/sniper/sniper.go @@ -40,16 +40,21 @@ const ( // 单列一类是为了把「服务端回了看不懂的包」和普通业务失败分开:2026-10-16 pc-3 // 日志里同一账号连续 13 次命中它,而同实例另外 28 个账号全部成功。 CategoryProtocol = "protocol" - // CategorySessionLost 表示会话/身份在服务端被重置(典型文案「账号未绑定!」)。 - // - // 实测是瞬时错误——同一账号下一轮就能成功(pc-1 1199665636376:loop4 未绑定、 - // loop5 success),所以照常重试,只是单独计数,避免和真失败混在一起。 + // CategorySessionLost 表示登录凭证/会话彻底失效(如「请先登录」「登录失效」「token失效」)。 CategorySessionLost = "session_lost" + // CategoryRoleUnbound 表示账号未绑定游戏角色或秒杀洪峰时下游和平精英角色服务抖动/超时。 + // 与真正的登录态失效不同,该状态不属于凭证失效,不计入判死计数器,仅作短退避重试。 + CategoryRoleUnbound = "role_unbound" // actionExchangeCanceled 表示申请并发额度时任务已被取消:这次请求根本没发出去, // 不应产出结果,否则停止监控时每个排队账号都会刷一条错误。 actionExchangeCanceled = "exchange_canceled" + // DefaultRequestTimeout 是单次秒杀兑换请求的最大等待时间(超时熔断)。 + // 2026-10-18 实测服务端微服务故障时单请求会 hang 5 秒,设置 1500ms 紧凑超时 + // 能在服务端下游微服务超时挂起时迅速熔断并进入下一轮重试。 + DefaultRequestTimeout = 1500 * time.Millisecond + // DefaultWindowSeconds 是窗口模式下默认的抢购窗口长度。 // // 实测 2026-10-16 开抢窗口只有约 9 秒(17:00:00.1 放量 → 17:00:09 售罄),但库存会 @@ -145,6 +150,9 @@ type Config struct { FirstWaveMaxStep time.Duration // SessionLostLimit 是同一账号连续多少次「会话失效」后停止该账号;<=0 时取默认值。 SessionLostLimit int + // RequestTimeout 是单次秒杀兑换请求的最大等待时间(超时熔断)。 + // <=0 时自动采用 DefaultRequestTimeout(1500ms)。 + RequestTimeout time.Duration // windowOverride 仅供测试:非零时直接作为窗口长度,避免「秒」粒度把单测拖慢。 windowOverride time.Duration diff --git a/internal/sniper/sniper_test.go b/internal/sniper/sniper_test.go index a439b0c..1ea3824 100644 --- a/internal/sniper/sniper_test.go +++ b/internal/sniper/sniper_test.go @@ -20,6 +20,7 @@ type fakeActivityClient struct { recordCount int listResp *huya.GetActPrizeListResp exchangeFn func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) + exchangeCtxFn func(ctx context.Context, uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) } func (f *fakeActivityClient) GetActPrizeList(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetActPrizeListResp, error) { @@ -50,6 +51,9 @@ func (f *fakeActivityClient) ScoreExchangePrize(ctx context.Context, uid int64, f.mu.Lock() f.exchangeCount++ f.mu.Unlock() + if f.exchangeCtxFn != nil { + return f.exchangeCtxFn(ctx, uid, sid, pid) + } if f.exchangeFn != nil { return f.exchangeFn(uid, sid, pid) }