// 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 }