feat(sniper): 增加1.5s快速超时熔断,重构角色未绑定分类解绑会话失效

- 兑换请求增加独立的 RequestTimeout 上下文超时熔断(默认 1500ms),防止微服务卡死吃掉 5 秒
- 剥离 CategoryRoleUnbound(角色未绑定/抖动),不再计入 sessionStreak 判死阈值,杜绝误杀有效账号
- 将 CategoryRoleUnbound 与 CategoryTooFrequent 纳入 sessionAliveCategory,正常回包可重置会话计数
- 补充单测 TestRunTargetsRoleUnboundDoesNotStopAccount 与 TestExchangeTargetFastTimeout
This commit is contained in:
yml2213
2026-10-18 21:23:49 +08:00
parent 908ceb0b3b
commit 43b3cb351e
9 changed files with 112 additions and 20 deletions
+3 -1
View File
@@ -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"
+3 -1
View File
@@ -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
+3
View File
@@ -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
+11 -3
View File
@@ -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 = "兑换请求失败"
}
}
+66 -4
View File
@@ -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{
+8 -5
View File
@@ -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
+2 -2
View File
@@ -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()
}
+12 -4
View File
@@ -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
+4
View File
@@ -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)
}