- 抢购调度重构:参考 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 构建正常,单测覆盖双时间与预热配置
335 lines
13 KiB
Go
335 lines
13 KiB
Go
// 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
|
||
}
|