feat(sniper): 增加1.5s快速超时熔断,重构角色未绑定分类解绑会话失效
- 兑换请求增加独立的 RequestTimeout 上下文超时熔断(默认 1500ms),防止微服务卡死吃掉 5 秒 - 剥离 CategoryRoleUnbound(角色未绑定/抖动),不再计入 sessionStreak 判死阈值,杜绝误杀有效账号 - 将 CategoryRoleUnbound 与 CategoryTooFrequent 纳入 sessionAliveCategory,正常回包可重置会话计数 - 补充单测 TestRunTargetsRoleUnboundDoesNotStopAccount 与 TestExchangeTargetFastTimeout
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "兑换请求失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user