Files
huya-go/internal/sniper/sniper.go
T
yml2213 6e5beac151 refactor(sniper): 对齐 dygou_go 调度架构,增加双时间日志并解耦预热
- 抢购调度重构:参考 dygou_go 将 sniper 拆分为独立子模块(runner/run_control/retry/exchange/classify/config/summary/category/helpers);移除全局自适应降速与轮次上限,采用账号独立循环、统一账号级退避、单请求并发闸门与首发错峰
- 预热与建连优化:默认关闭预热(避免单账号超时连坐整机开抢),开启时转为后台非阻塞协程;WebSocket 握手超时从 10s 降至 1500ms
- 双时间日志:logger 增加 SetServerOffset,校准后日志前缀自动渲染「本地时间(H虎牙估算时间)」,实现跨机器绝对毫秒对齐
- 默认配置调优:版本提升至 v0.5.0,默认线程调为 2(缓解多实例并发限流),前端自动平滑迁移
- 验证方式:go test ./... -count=1 全通,npm run build 构建正常,单测覆盖双时间与预热配置
2026-10-17 18:29:17 +08:00

335 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package sniper 实现虎牙活动抢购。
//
// 控制流参考 dygou_go,按职责拆分为:
// - runner.go: 任务入口与每账号独立 attempt-loop
// - run_control.go: 窗口、首发错峰、并发闸门与账号状态
// - retry.go: 账号级连续失败计数和统一退避
// - exchange.go: 单次兑换、成功刷新和共享列表
// - classify.go: 响应文案与目标匹配
//
// 每个账号每轮尝试所有未完成目标,结束后只等待一次;不存在 target 级重试定时器,
// 也不再有全局自适应降速器。
package sniper
import (
"context"
"sort"
"sync"
"time"
"huya-go/internal/huya"
)
const (
CategoryReady = "ready"
CategorySuccess = "success"
CategoryNotFound = "not_found"
CategoryNotOpen = "not_open"
CategoryTooFrequent = "too_frequent"
CategoryNotEnough = "not_enough"
CategoryAlreadyDone = "already_done"
CategoryOffShelf = "off_shelf"
CategorySoldOut = "sold_out"
CategoryFailed = "failed"
CategoryError = "error"
// CategoryCondition 表示服务端返回了 precondition 门禁(条件未达成),
// 没有可识别文案,按可重试失败处理但单独分类便于观察。
CategoryCondition = "condition"
// CategoryProtocol 表示服务端回包结构与协议约定不符(缺少 tRsp/tResp、解码失败等)。
//
// 单列一类是为了把「服务端回了看不懂的包」和普通业务失败分开:2026-10-16 pc-3
// 日志里同一账号连续 13 次命中它,而同实例另外 28 个账号全部成功。
CategoryProtocol = "protocol"
// CategorySessionLost 表示会话/身份在服务端被重置(典型文案「账号未绑定!」)。
//
// 实测是瞬时错误——同一账号下一轮就能成功(pc-1 1199665636376:loop4 未绑定、
// loop5 success),所以照常重试,只是单独计数,避免和真失败混在一起。
CategorySessionLost = "session_lost"
// actionExchangeCanceled 表示申请并发额度时任务已被取消:这次请求根本没发出去,
// 不应产出结果,否则停止监控时每个排队账号都会刷一条错误。
actionExchangeCanceled = "exchange_canceled"
// DefaultWindowSeconds 是窗口模式下默认的抢购窗口长度。
//
// 实测 2026-10-16 开抢窗口只有约 9 秒(17:00:00.1 放量 → 17:00:09 售罄),但库存会
// 因为别人锁单未支付 / 订单释放而回流(pc-2 在 17:02 手动补兑仍成功),
// 所以默认给 30 秒:既能覆盖争抢期,又不会无限重试。
DefaultWindowSeconds = 30
// DefaultStartAhead 是窗口循环相对开抢时刻的提前量(先手缓冲),
// 让第一轮请求正好落在开抢瞬间而不是晚一个 RTT。
DefaultStartAhead = 60 * time.Millisecond
// DefaultFirstWaveSpread / DefaultFirstWaveMaxStep 是首发错峰参数:
// 第 0 个账号立即发,其余按 min(spread/(n-1), maxStep) 依次错开,整批最多铺开 spread。
// 同机 152 个账号在同一毫秒齐射会把整机压上服务端令牌桶(pc-1 实测 82% 请求被限流)。
DefaultFirstWaveSpread = 300 * time.Millisecond
DefaultFirstWaveMaxStep = 8 * time.Millisecond
// maxRetryDelay 是任何一次账号退避的上限。
//
// 抢购窗口只有十几秒,库存随时可能回流,等待超过 1.5 秒就可能正好错过它
// (参考 dygou_go:把任何一次等待都压在 2 秒以内)。
maxRetryDelay = 1500 * time.Millisecond
// defaultRetryBackoff 是分类没有给出更具体退避值时的基准。
defaultRetryBackoff = 300 * time.Millisecond
// prefireMinGap 是「还值得单独预打一轮」的最小时间余量:小于它就说明已经在窗口起点附近,
// 直接进入窗口循环即可,避免把首发齐射打两遍。
prefireMinGap = 250 * time.Millisecond
// defaultSessionLostLimit 是「连续会话失效多少次后停止该账号」的默认阈值。
//
// 「账号未绑定!」在开抢洪峰里经常只是服务端限流/路由抖动:2026-10-17 的 24 个
// 任务里,CK 预检全部可用,却出现 372 次该提示。旧阈值 3 会让一个有效 CK 在
// 连续三次洪峰回包后被提前踢出整场抢购,因此默认抬高到 8;显式配置仍可覆盖。
defaultSessionLostLimit = 8
// sharedListTTL 是共享奖品列表快照(SharePrizeList)的存活时间。
//
// 单次列表查询约 100ms,而单账号节奏是 cfg.Delay(默认 150~300ms):TTL 取 1 秒既能让
// 同一波账号复用同一份快照(pc-1 的 152 个账号从 152 次查询降到 1 次),又能在库存
// 变化时及时刷新(实测库存会因别人锁单未支付而回流,快照不能太旧)。
sharedListTTL = 1000 * time.Millisecond
)
type Account struct {
UID int64 `json:"uid"`
Cookie string `json:"-"`
}
type Target struct {
PID int32 `json:"pid"`
Name string `json:"name"`
Auto bool `json:"auto,omitempty"`
MaxPrice int64 `json:"max_price,omitempty"`
MinLeft int64 `json:"min_left,omitempty"`
}
type Config struct {
SID int32
// Category 限定只在某个类目(限量返场/精英专享,或类目 id)里挑选可兑奖品,
// 留空表示不限制。见 huya.GetActPrizeListResp.FilterByCategory。
Category string
// maxAttempts 只供测试和库调用做有限轮次保护;GUI 不再暴露轮次上限,
// 正常抢购始终由 OpenAt + WindowSeconds 的窗口截止时间控制。
maxAttempts int
Delay time.Duration
Concurrency int
StopAfterSuccess bool
StopAccountAfterSuccess bool
Prewarm bool
DirectExchange bool
ListBeforeExchange bool
SharePrizeList bool
RefreshAfterSuccess bool
// StopOnNotEnough 表示「积分不足 / 已兑换」后终止该账号该奖品(默认 true:
// 积分不会自己变多,继续打只是浪费请求)。
StopOnNotEnough bool
ContinueOnNotEnough bool
// StopOnSoldOut 表示「已抢完」后终止该奖品。默认 false:别人锁单未支付或取消
// 会让库存回流,窗口内应当继续低频探测(dygou_go 2026-10-03 实测结论)。
StopOnSoldOut bool
TooFrequentBackoff time.Duration
ErrorBackoff time.Duration
// OpenAt 是本次抢购的开抢时刻(已按服务端时钟对齐的本机时刻)。
//
// 非零即「窗口模式」:终止条件由 OpenAt+WindowSeconds 决定。
OpenAt time.Time
// WindowSeconds 是窗口长度(秒);窗口模式下 <=0 时取 DefaultWindowSeconds。
WindowSeconds int
// StartAhead 是窗口循环相对 OpenAt 的提前量;<=0 时取 DefaultStartAhead。
StartAhead time.Duration
// FirstWaveSpread / FirstWaveMaxStep 是首发错峰参数,参考 dygou_go 的 firstWaveSpread。
// FirstWaveSpread<=0 表示不启用错峰(测试与直接调用 RunTargets 的老行为)。
FirstWaveSpread time.Duration
FirstWaveMaxStep time.Duration
// SessionLostLimit 是同一账号连续多少次「会话失效」后停止该账号;<=0 时取默认值。
SessionLostLimit int
// windowOverride 仅供测试:非零时直接作为窗口长度,避免「秒」粒度把单测拖慢。
windowOverride time.Duration
}
type Result struct {
Loop int `json:"loop"`
UID int64 `json:"uid"`
TargetIndex int `json:"target_index,omitempty"`
Target Target `json:"target"`
Action string `json:"action"`
Category string `json:"category,omitempty"`
Message string `json:"message,omitempty"`
Prize *huya.PrizeInfo `json:"prize,omitempty"`
Exchange *huya.ScoreExchangePrizeResp `json:"exchange,omitempty"`
Score *huya.GetUserScoreResp `json:"score,omitempty"`
Records *huya.GetUserPrizeRecordsResp `json:"records,omitempty"`
Done bool `json:"done,omitempty"`
Error string `json:"error,omitempty"`
}
type Runner struct {
Client huya.ActivityClient
// OnSummary 在任务结束时回调一次汇总(可为 nil)。
// 汇总在成功后的异步刷新全部结束后才输出,因此能带上刷新产生的请求次数。
OnSummary func(RunSummary)
}
// RunSummary 汇总一次抢购任务的请求次数与结局分布,供日志复盘。
// 形状参考 dygou_go 的 logRunSummary:抢购结束后只打一行,替代逐条刷屏。
type RunSummary struct {
Attempts int
Accounts int
Targets int
Duration time.Duration
ListReqs int
ExchangeReqs int
ScoreReqs int
RecordReqs int
SuccessUIDs int
Outcomes map[string]int
// AccountOutcomes 是每账号一行的最终结局(按 uid 升序),供任务结束后逐行复盘。
AccountOutcomes []AccountOutcome
// RequestStats 是按接口类型的请求统计(次数/失败/平均/最慢/最后错误)。
RequestStats []RequestStat
// WindowSeconds / OpenAt 描述本次窗口;非窗口模式时为零值。
WindowSeconds int
OpenAt time.Time
}
// AccountOutcome 是单个账号在一次任务里的最终结局。
type AccountOutcome struct {
UID int64 `json:"uid"`
Category string `json:"category"`
Message string `json:"message,omitempty"`
Attempts int `json:"attempts"`
Done bool `json:"done,omitempty"`
}
// RequestStat 是一种接口的调用统计。
type RequestStat struct {
Kind string `json:"kind"`
Count int `json:"count"`
Failed int `json:"failed"`
Total time.Duration `json:"total"`
Slowest time.Duration `json:"slowest"`
LastErr string `json:"lastErr,omitempty"`
}
// Average 返回平均耗时。
func (s RequestStat) Average() time.Duration {
if s.Count <= 0 {
return 0
}
return s.Total / time.Duration(s.Count)
}
// requestStat 是内部的请求统计累加器。
type requestStat struct {
count int
failed int
total time.Duration
slowest time.Duration
lastErr string
}
func (s *requestStat) observe(cost time.Duration, err error) {
s.count++
s.total += cost
if cost > s.slowest {
s.slowest = cost
}
if err != nil {
s.failed++
s.lastErr = err.Error()
}
}
// Categories 按次数从多到少返回结局分类,次数相同时按名称排序,保证日志稳定。
func (s RunSummary) Categories() []string {
names := make([]string, 0, len(s.Outcomes))
for name := range s.Outcomes {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
if s.Outcomes[names[i]] != s.Outcomes[names[j]] {
return s.Outcomes[names[i]] > s.Outcomes[names[j]]
}
return names[i] < names[j]
})
return names
}
type ActivityPrewarmer interface {
PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error
}
type runState struct {
mu sync.Mutex
accounts map[int64]*accountState
targetDone []bool
// exchanged 记录每个账号已成功兑换的奖品,避免多个 target 命中同一奖品时重复兑换。
exchanged map[int64]map[int64]bool
// duplicateNotified 保证「该奖品已兑换过」只提示一次:窗口模式下每轮都会跳过,
// 不记录会刷屏。
duplicateNotified map[int64]map[int64]bool
// refresh 用于等待成功后的异步刷新,确保 RunTargets 返回前不会提前释放连接。
refresh sync.WaitGroup
// sem 是请求级并发闸门:一个额度对应一次在途请求,账号在退避等待期间
// 不再占用额度(参考 dygou_go runControl.enter)。
sem chan struct{}
// stats 记录请求次数与结局分布,任务结束时汇总成一行日志。
stats runStats
// 窗口模式:deadline 非零表示到点即收工;openAt 用于「开抢前只预打一轮」。
startedAt time.Time
openAt time.Time
deadline time.Time
windowLen time.Duration
startAhead time.Duration
prefireDone bool
// 首发错峰:第 0 个账号立即发,其余按 step 依次错开,整批最多铺开 spread。
firstWaveSpread time.Duration
firstWaveStep time.Duration
firstWaveUsed int
firstWaveSent map[int64]bool
// maxAttemptCount 记录所有账号里跑过的最大实际尝试次数,供汇总日志使用。
maxAttemptCount int
// listMu 保护共享列表快照(SharePrizeList 模式):listInflight 非空表示
// 已有账号在查询,其余账号等它完成后复用结果。
listMu sync.Mutex
listSnapshot *listSnapshot
listCachedAt time.Time
listInflight chan struct{}
}
// runStats 是一次任务的运行统计。
type runStats struct {
listReqs int
exchangeReqs int
scoreReqs int
recordReqs int
outcomes map[string]int
successUIDs map[int64]bool
// requests 按接口类型记录次数/失败/耗时,任务结束时输出接口统计行。
requests map[requestKind]*requestStat
// accounts 是每账号的最终结局,任务结束时逐行输出。
accounts map[int64]*AccountOutcome
}
type targetState struct {
Done bool
}
// accountState 只保存账号级状态。退避不再按 target 分别登记:
// 一个账号一轮的所有失败合并成一次连续失败计数,等待也只有一个账号级时长。
type accountState struct {
targets []targetState
done bool
failStreak int
sessionStreak int
}
type listSnapshot struct {
resp *huya.GetActPrizeListResp
err error
}