Files
huya-go/internal/sniper/sniper.go
T

1998 lines
62 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
import (
"context"
"errors"
"fmt"
"math/rand"
"sort"
"strconv"
"strings"
"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
// governorMinRequests / governorStallWindow / governorMaxFactor 控制全局自适应降速。
//
// 触发信号刻意选「无进展」而不是「限流占比」:2026-10-16 的日志显示,即使成功率
// 97.6% 的那一次,被限流的请求也占 67%,用占比阈值会把正常运行一起拖慢。
// 真正的危险信号是「发了一堆请求、连一个业务响应都拿不到」,说明整机已经压在
// 服务端令牌桶上(pc-1 峰值 122 req/s)。此时按 1.5 倍逐级降速,一旦恢复进展就快速回退。
governorMinRequests = 12
governorStallWindow = 800 * time.Millisecond
governorMaxFactor = 4.0
governorStep = 1.5
// defaultRetryBackoff 是配置缺失时的退避基准(界面默认值同样是 300ms)。
defaultRetryBackoff = 300 * time.Millisecond
// prefireMinGap 是「还值得单独预打一轮」的最小时间余量:小于它就说明已经在窗口起点附近,
// 直接进入窗口循环即可,避免把首发齐射打两遍。
prefireMinGap = 250 * time.Millisecond
// defaultSessionLostLimit 是「连续会话失效多少次后停止该账号」的默认阈值。
// 实测同一账号下一轮就能成功,所以阈值不能太小(3 次足以排除偶发抖动)。
defaultSessionLostLimit = 3
// sharedListTTL 是共享奖品列表快照(SharePrizeList)的存活时间。
//
// 单次列表查询约 100ms,而单账号节奏是 cfg.Delay(默认 150~300ms):TTL 取 1 秒既能让
// 同一波账号复用同一份快照(pc-1 的 152 个账号从 152 次查询降到 1 次),又能在库存
// 变化时及时刷新(实测库存会因别人锁单未支付而回流,快照不能太旧)。
sharedListTTL = 1000 * time.Millisecond
)
// TargetSummary 把目标列表渲染成「13437(冠军之刃)」这样的单行摘要,供启动日志使用。
//
// 2026-10-16 的复盘正卡在「日志里没有目标 pid」:三台机器都只记了 sid 与 targets=1,
// 看不出各自打的是哪个奖品,也就无法解释为什么一台售罄、另一台库存完好。
func TargetSummary(targets []Target) string {
parts := make([]string, 0, len(targets))
for _, target := range targets {
name := strings.TrimSpace(target.Name)
switch {
case target.PID > 0 && name != "":
parts = append(parts, fmt.Sprintf("%d(%s)", target.PID, name))
case target.PID > 0:
parts = append(parts, strconv.FormatInt(int64(target.PID), 10))
case name != "":
parts = append(parts, name)
case target.Auto:
parts = append(parts, "自动选择")
}
}
if len(parts) == 0 {
return "未指定"
}
return strings.Join(parts, ",")
}
// CategoryLabel 返回结局分类的中文短标签,供任务结束时的结局日志使用。
func CategoryLabel(category string) string {
switch category {
case CategorySuccess:
return "已抢到"
case CategorySoldOut:
return "售罄"
case CategoryTooFrequent:
return "被限流"
case CategoryNotOpen:
return "未开放"
case CategoryNotEnough:
return "积分不足"
case CategoryAlreadyDone:
return "已兑换过"
case CategoryOffShelf:
return "已下架"
case CategorySessionLost:
return "会话失效"
case CategoryProtocol:
return "协议异常"
case CategoryCondition:
return "条件未达成"
case CategoryNotFound:
return "未匹配到奖品"
case CategoryError:
return "请求异常"
default:
return "失败"
}
}
// CategoryText 把类目限制渲染成日志文案(空串表示不限类目)。
func CategoryText(category string) string {
key := strings.TrimSpace(category)
if key == "" {
return "不限"
}
return key
}
// LogLevelForCategory 把结果分类映射成日志等级,供界面按等级高亮与筛选。
func LogLevelForCategory(category string) string {
switch category {
case CategorySuccess:
return "success"
case CategoryError, CategoryProtocol:
return "error"
case CategoryFailed, CategoryTooFrequent, CategoryCondition, CategorySessionLost:
return "warn"
default:
return "info"
}
}
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
Loops 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 决定,Loops 只作为可选的
// 轮次上限。这样账号少时也不会像旧实现那样「20 轮 × 150ms = 3 秒」在开抢前把预算烧光
// (2026-10-16 pc-3 instance-4 只有一个账号,20 轮全部打在开抢前 0.3 秒,0 成功)。
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
// DisableAdaptiveThrottle 关闭全局自适应降速。
//
// 默认开启(Go 零值无法表达「默认开」,所以这里用反向开关):产品入口(GUI/CLI)
// 默认不设置它,也就是开启;只有显式配置才关闭。
DisableAdaptiveThrottle bool
// SessionLostLimit 是同一账号连续多少次「会话失效」后停止该账号;<=0 时取默认值 3。
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"`
RetryAfter int64 `json:"retry_after,omitempty"`
Error string `json:"error,omitempty"`
}
type Runner struct {
Client huya.ActivityClient
// OnSummary 在任务结束时回调一次汇总(可为 nil)。
// 汇总在成功后的异步刷新全部结束后才输出,因此能带上刷新产生的请求次数。
OnSummary func(RunSummary)
}
// RunSummary 汇总一次抢购任务的请求次数与结局分布,供日志复盘。
// 形状参考 dygou_go 的 logRunSummary:抢购结束后只打一行,替代逐条刷屏。
type RunSummary struct {
Loops 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
// ThrottleFactor 是自适应降速用到的最大倍率(1 表示全程没有触发降速)。
ThrottleFactor float64
}
// 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][]targetState
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
// governor 是全局自适应降速器;DisableAdaptiveThrottle 时为 nil(空操作)。
governor *throttleGovernor
// rounds 记录所有账号里跑过的最大轮次,供汇总日志使用。
rounds 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
RetryAfter time.Time
// FailStreak 是连续可重试失败的次数,用于放大退避(成功/正常等待类结局会清零)。
FailStreak int
// SessionStreak 是连续「会话失效」次数,达到阈值即停止该账号该奖品。
SessionStreak int
}
type listSnapshot struct {
resp *huya.GetActPrizeListResp
err error
}
func (r *Runner) Run(ctx context.Context, accounts []Account, target Target, cfg Config, emit func(Result)) error {
return r.RunTargets(ctx, accounts, []Target{target}, cfg, emit)
}
func (r *Runner) RunTargets(ctx context.Context, accounts []Account, targets []Target, cfg Config, emit func(Result)) error {
cfg = normalizeConfig(cfg)
targets = normalizeTargets(targets)
if len(accounts) == 0 {
return errors.New("账号不能为空")
}
if len(targets) == 0 {
return errors.New("目标奖品不能为空")
}
client := r.client()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if cfg.Prewarm {
if err := r.Prewarm(ctx, accounts, cfg, emit); err != nil {
return err
}
}
state := newRunState(accounts, len(targets), cfg.Concurrency, cfg)
startedAt := time.Now()
// emit 包装一层,顺手记录每账号最终结局;emit 为 nil 时保持 nil,
// 否则成功后的异步刷新会在没有上报通道时也发请求(旧行为不允许)。
emitResult := emit
if emit != nil {
emitResult = func(result Result) {
state.noteOutcome(result)
emit(result)
}
}
// 成功后的刷新是异步的,等它们结束再返回,避免调用方提前关闭连接;
// 汇总放在最后,才能把刷新产生的请求次数一起带上。
defer func() {
state.refresh.Wait()
r.emitSummary(state, len(accounts), len(targets), state.maxRound(), time.Since(startedAt))
}()
runWave := func(loop int) {
var wg sync.WaitGroup
for _, account := range accounts {
account := account
wg.Add(1)
go func() {
defer wg.Done()
// 并发额度由每次请求自己申请(见 runState.acquire),
// 账号在退避等待期间不占额度。
r.runAccountRound(ctx, client, state, account, targets, cfg, loop, emitResult, cancel)
}()
}
wg.Wait()
}
// 开抢前只预打一轮(探是否已开放、顺带验证通道),随后静默等到窗口起点。
// 旧实现把提前量整段都用来循环重试,代价是先把服务端令牌桶烧掉:
// 2026-10-16 pc-1 实测 1421 次请求里 82% 被限流挡回。
if state.needsPrefire() {
state.markPrefire()
runWave(1)
if ctx.Err() != nil {
return ctx.Err()
}
if !state.windowExpired() {
if err := sleepContext(ctx, time.Until(state.windowStart())); err != nil {
return err
}
}
}
// 正式窗口:每个账号在自己的 goroutine 里独立循环到窗口结束(或自己完成),
// 互不等待、互不阻塞。旧实现按「一轮打完等所有人」(wg.Wait)同步,某个账号卡在
// 一次慢请求上时,其余账号这一轮只能干等(账号少的实例尤其吃亏)。
// 请求速率由请求级并发闸门兜底:在途请求数被限制在 cfg.Concurrency。
var wg sync.WaitGroup
for _, account := range accounts {
account := account
wg.Add(1)
go func() {
defer wg.Done()
r.runAccountLoop(ctx, client, state, account, targets, cfg, emitResult, cancel)
}()
}
wg.Wait()
if ctx.Err() != nil {
return ctx.Err()
}
return nil
}
func (r *Runner) Prewarm(ctx context.Context, accounts []Account, cfg Config, emit func(Result)) error {
cfg = normalizeConfig(cfg)
if len(accounts) == 0 {
return errors.New("账号不能为空")
}
client := r.client()
prewarmer, ok := client.(ActivityPrewarmer)
if !ok {
return nil
}
sem := make(chan struct{}, cfg.Concurrency)
var wg sync.WaitGroup
for _, account := range accounts {
account := account
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
err := prewarmer.PrewarmActivity(ctx, account.UID, account.Cookie, cfg.SID)
result := Result{
UID: account.UID,
Action: "prewarm",
Category: CategoryReady,
Message: "连接已预热",
}
if err != nil {
result.Category = CategoryError
result.Error = err.Error()
result.Message = "连接预热失败"
}
if emit != nil {
emit(result)
}
}()
}
wg.Wait()
return ctx.Err()
}
// runAccountLoop 是单个账号的独立抢购循环:按自己的节奏重试,直到窗口结束、
// 自己完成或任务被停止。账号之间不互相等待,也不共享节奏。
func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, emit func(Result), cancel context.CancelFunc) {
// 首发错峰:第 0 个账号立即发,其余按小步长依次错开,避免同机上百个账号
// 在同一毫秒齐射把整机压上服务端令牌桶。
if wait := state.takeFirstWaveDelay(account.UID); wait > 0 {
if err := sleepContext(ctx, wait); err != nil {
return
}
}
for round := 1; ; round++ {
if ctx.Err() != nil {
return
}
// Loops>0 是显式轮次上限(非窗口模式与测试用);窗口模式下留空表示打到窗口结束。
if cfg.Loops > 0 && round > cfg.Loops {
return
}
if state.allDone() || state.windowExpired() || state.accountFinished(account.UID, len(targets)) {
return
}
state.noteRound(round)
r.runAccountRound(ctx, client, state, account, targets, cfg, round, emit, cancel)
if ctx.Err() != nil || state.allDone() || state.windowExpired() {
return
}
// 本账号自己的节奏:固定等 cfg.Delay。退避中的目标会在下一轮被 activeTargets 跳过,
// 所以这里不需要额外等待退避时间(等待本身也会被窗口末尾裁剪)。
if wait := state.clipToDeadline(cfg.Delay); wait > 0 {
if err := sleepContext(ctx, wait); err != nil {
return
}
}
}
}
// runAccountRound 是单账号的一轮尝试:按目标做一次兑换(或多个目标各一次)。
func (r *Runner) runAccountRound(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, loop int, emit func(Result), cancel context.CancelFunc) {
active := state.activeTargets(account.UID, len(targets), time.Now())
if len(active) == 0 {
return
}
listTargets := make([]int, 0, len(active))
for _, index := range active {
target := targets[index]
if shouldDirectExchange(target, cfg) {
// 直兑路径没有奖品列表,用 target.PID 判断是否已经兑换过同一奖品。
if state.prizeExchanged(account.UID, int64(target.PID)) {
state.emitSkippedDuplicate(emit, loop, account.UID, index, int64(target.PID), target)
continue
}
result := r.exchangeTarget(ctx, client, state, account, target, index, loop, cfg.SID, nil)
if result.Action == actionExchangeCanceled {
return
}
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
if result.Category == CategorySuccess {
state.markPrizeExchanged(account.UID, int64(target.PID))
}
if emit != nil {
emit(result)
}
r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit)
if ctx.Err() != nil {
return
}
continue
}
listTargets = append(listTargets, index)
}
if len(listTargets) == 0 || ctx.Err() != nil {
return
}
// SharePrizeList 打开时,多账号复用同一份列表快照(带 TTL 的单飞缓存),
// 避免每个账号每一轮都各查一次列表。
list := r.listForAccount(ctx, client, account, cfg, state)
if list.err != nil || list.resp == nil {
errText := "奖品列表响应为空"
if list.err != nil {
errText = list.err.Error()
}
for _, index := range listTargets {
retryAt := time.Now().Add(jitterDuration(cfg.ErrorBackoff))
state.setRetryAfter(account.UID, index, retryAt)
state.recordOutcome(CategoryError)
result := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: targets[index],
Action: "list_error",
Category: CategoryError,
Message: "查询奖品列表失败",
RetryAfter: retryAt.UnixMilli(),
Error: errText,
}
if emit != nil {
emit(result)
}
}
return
}
// 只按目标类目匹配:类目为空表示不限制(限量返场/精英专享都参与)。
prizes := list.resp.FilterByCategory(cfg.Category)
for _, index := range listTargets {
target := targets[index]
prize := matchPrize(prizes, target)
if prize == nil {
state.recordOutcome(CategoryNotFound)
result := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: target,
Action: "not_found",
Category: CategoryNotFound,
Message: notFoundMessage(cfg.Category),
}
if emit != nil {
emit(result)
}
continue
}
// 多个 target 可能命中同一个奖品(例如按不同名称匹配),已成功兑换过就跳过。
if state.prizeExchanged(account.UID, prize.ID) {
state.emitSkippedDuplicate(emit, loop, account.UID, index, prize.ID, target)
continue
}
result := r.exchangeTarget(ctx, client, state, account, target, index, loop, cfg.SID, prize)
if result.Action == actionExchangeCanceled {
return
}
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
if result.Category == CategorySuccess {
state.markPrizeExchanged(account.UID, prize.ID)
}
if emit != nil {
emit(result)
}
r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit)
if ctx.Err() != nil {
return
}
}
}
// emitSkippedDuplicate 在跳过同一奖品的重复兑换时给出明确结果,便于界面观察。
//
// 每个「账号 + 奖品」只提示一次:窗口模式下每轮都会跳过同一个奖品,
// 不记录会刷出一整个窗口的重复提示(但请求数不会增加)。
func (s *runState) emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, prizeID int64, target Target) {
if s.markDuplicateNotified(uid, prizeID) {
s.recordOutcome(CategoryAlreadyDone)
}
if emit == nil {
return
}
emit(Result{
Loop: loop,
UID: uid,
TargetIndex: index,
Target: target,
Action: "skip_duplicate",
Category: CategoryAlreadyDone,
Message: "该奖品本账号已兑换成功,跳过重复兑换",
})
}
// markDuplicateNotified 记录并返回是否是首次提示。
func (s *runState) markDuplicateNotified(uid, prizeID int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.duplicateNotified[uid] == nil {
s.duplicateNotified[uid] = map[int64]bool{}
}
if s.duplicateNotified[uid][prizeID] {
return false
}
s.duplicateNotified[uid][prizeID] = true
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 {
pid := target.PID
if prize != nil {
pid = int32(prize.ID)
}
result := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: target,
Action: "exchange",
Prize: prize,
}
if !state.acquire(ctx) {
result.Action = actionExchangeCanceled
result.Category = CategoryError
result.Message = "任务已取消,未发起兑换"
result.Error = ctx.Err().Error()
return result
}
started := time.Now()
exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid)
state.release()
state.recordRequest(reqExchange, time.Since(started), err)
result.Exchange = exchange
result.Category = ClassifyExchange(exchange, err)
result.Message = ExchangeMessage(exchange)
if err != nil {
result.Action = "exchange_error"
result.Error = err.Error()
if result.Message == "" {
result.Message = "兑换请求失败"
}
}
return result
}
func (r *Runner) applyExchangeDecision(state *runState, account Account, index int, cfg Config, result Result, cancel context.CancelFunc) Result {
state.recordOutcome(result.Category)
state.observeThrottle(result.Category)
if result.Category == CategorySuccess {
state.markSuccessUID(account.UID)
}
switch result.Category {
case CategorySuccess:
if cfg.StopAccountAfterSuccess {
state.markDone(account.UID, index)
result.Done = true
}
if cfg.StopAfterSuccess {
cancel()
}
case CategoryNotEnough, CategoryAlreadyDone:
if cfg.StopOnNotEnough {
state.markDone(account.UID, index)
result.Done = true
}
case CategoryOffShelf:
// 已下架 / 奖品不存在:本窗口内不会再出现,直接终止该 target。
state.markTargetDone(index)
result.Done = true
case CategorySessionLost:
// 会话失效实测是瞬时的(下一轮即可成功),所以先照常重试;
// 只有连续多次才停止该账号,避免真的失效后白打一整个窗口的请求。
streak := state.bumpSessionStreak(account.UID, index)
if limit := sessionLostLimit(cfg); streak >= limit {
state.markDone(account.UID, index)
result.Done = true
result.Message = fmt.Sprintf("会话连续失效 %d 次,停止该账号", streak)
} else {
result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category)
}
case CategorySoldOut:
if cfg.StopOnSoldOut {
state.markTargetDone(index)
result.Done = true
} else {
// 别人锁单未支付或取消会让库存回流,窗口内继续探测(只是放慢节奏)。
result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category)
}
case CategoryTooFrequent:
result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category)
case CategoryError, CategoryProtocol, CategoryFailed, CategoryCondition:
result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category)
}
return result
}
// sessionLostLimit 返回「连续会话失效多少次后停止该账号」的阈值。
func sessionLostLimit(cfg Config) int {
if cfg.SessionLostLimit > 0 {
return cfg.SessionLostLimit
}
return defaultSessionLostLimit
}
// scheduleRetry 计算并登记某个账号下一次尝试的时间,返回毫秒时间戳供日志展示。
func (s *runState) scheduleRetry(uid int64, index int, cfg Config, category string) int64 {
delay := s.retryDelay(uid, index, cfg, category)
// 窗口模式下退避不能跨过窗口结束,否则这段等待纯属浪费。
if !s.deadline.IsZero() {
remaining := time.Until(s.deadline)
if remaining <= 0 {
delay = 0
} else if delay > remaining {
delay = remaining
}
}
when := time.Now().Add(delay)
s.setRetryAfter(uid, index, when)
return when.UnixMilli()
}
// retryDelay 计算账号下一次尝试前的退避时间。
//
// 阶梯参考 dygou_go 的 retryDelay:
// 1. 基础值来自界面配置(ErrorBackoff / TooFrequentBackoff,默认 300ms);
// 2. 按「已抢购时长」分档放大:开抢瞬间要快,窗口后段自然放慢(库存回流概率低);
// 3. 同一账号连续失败再翻倍(上限 ×4),避免单个账号被风控盯上;
// 4. 乘全局自适应降速倍率(限流风暴时整体降速);
// 5. 叠加 0-25% 抖动,避免整批账号同一时刻重试形成尖峰;
// 6. 任何一次等待都不超过 maxRetryDelay——超过 1.5 秒就可能正好错过回流库存。
func (s *runState) retryDelay(uid int64, index int, cfg Config, category string) time.Duration {
base := cfg.ErrorBackoff
if category == CategoryTooFrequent {
base = cfg.TooFrequentBackoff
}
if base <= 0 {
base = defaultRetryBackoff
}
streak := s.bumpFailStreak(uid, index, category)
delay := time.Duration(stageMultiplier(s.elapsed(), category)) * base
if mult := streakMultiplier(streak); mult > 1 {
delay *= time.Duration(mult)
}
if delay > maxRetryDelay {
delay = maxRetryDelay
}
if factor := s.throttleFactor(); factor > 1 {
delay = time.Duration(float64(delay) * factor)
}
return jitterDuration(delay)
}
// elapsed 返回任务开始至今的时长,用于按阶段取退避节奏。
func (s *runState) elapsed() time.Duration {
if s.startedAt.IsZero() {
return 0
}
return time.Since(s.startedAt)
}
// stageMultiplier 按已抢购时长返回退避倍率:开抢头 2 秒最激进,5 秒后放慢到 3 倍。
func stageMultiplier(elapsed time.Duration, category string) int {
if escalatingCategory(category) {
switch {
case elapsed < 2*time.Second:
return 1
case elapsed < 5*time.Second:
return 2
default:
return 3
}
}
// 售罄 / 未开放这类「正常等待」也随窗口放慢,但基数保持 1:库存回流仍要探到。
if elapsed >= 5*time.Second {
return 2
}
return 1
}
// escalatingCategory 返回该分类是否参与「连续失败翻倍」的统计。
func escalatingCategory(category string) bool {
switch category {
case CategoryFailed, CategoryTooFrequent, CategoryError, CategoryProtocol, CategorySessionLost, CategoryCondition:
return true
default:
return false
}
}
// streakMultiplier 把连续失败次数换算成翻倍系数:第 3 次起 ×2,第 4 次起 ×4。
//
// 刻意从第 3 次才翻倍:前两次保持配置里的最快节奏,抢购前期不该因为一次失败就变慢。
func streakMultiplier(streak int) int {
if streak <= 2 {
return 1
}
mult := 1
for i := 2; i < streak && mult < 4; i++ {
mult <<= 1
}
return mult
}
// bumpFailStreak 累加账号的连续失败次数;非失败类结局会把它清零。
func (s *runState) bumpFailStreak(uid int64, index int, category string) int {
s.mu.Lock()
defer s.mu.Unlock()
items := s.accounts[uid]
if index < 0 || index >= len(items) {
return 1
}
if !escalatingCategory(category) {
items[index].FailStreak = 0
s.accounts[uid] = items
return 1
}
items[index].FailStreak++
streak := items[index].FailStreak
s.accounts[uid] = items
return streak
}
// bumpSessionStreak 累加账号的连续会话失效次数。
func (s *runState) bumpSessionStreak(uid int64, index int) int {
s.mu.Lock()
defer s.mu.Unlock()
items := s.accounts[uid]
if index < 0 || index >= len(items) {
return 0
}
items[index].SessionStreak++
streak := items[index].SessionStreak
s.accounts[uid] = items
return streak
}
// observeThrottle 把结局交给全局降速器。会话失效等其它结局会重置「无进展」计数。
func (s *runState) observeThrottle(category string) {
if s.governor == nil {
return
}
if category != CategoryTooFrequent {
s.clearSessionStreak(category)
}
s.governor.observe(category)
}
// clearSessionStreak 在拿到任何非会话失效结局后清空连续失效计数。
func (s *runState) clearSessionStreak(category string) {
if category == CategorySessionLost {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for uid, items := range s.accounts {
changed := false
for index := range items {
if items[index].SessionStreak != 0 {
items[index].SessionStreak = 0
changed = true
}
}
if changed {
s.accounts[uid] = items
}
}
}
// throttleFactor 返回当前的自适应降速倍率(未启用时恒为 1)。
func (s *runState) throttleFactor() float64 {
if s.governor == nil {
return 1
}
return s.governor.current()
}
// throttleGovernor 是全局自适应降速器。
//
// 触发信号刻意选「无进展」而不是「限流占比」:2026-10-16 实测,即使成功率 97.6% 的 pc-3
// 也有 67% 的请求被限流,用占比阈值会把正常运行的机器一起拖慢。真正危险的是
// 「连着十几个请求、连一个业务响应都拿不到」——说明整机已经压在服务端令牌桶上
// (pc-1 峰值 122 req/s、82% 被限流)。命中后每 800ms 按 1.5 倍逐级降速(上限 4 倍),
// 一旦恢复进展就立刻回退,避免错过库存回流。
type throttleGovernor struct {
mu sync.Mutex
factor float64
maxSeen float64
stall int
lastProgress time.Time
lastEscalate time.Time
// 阈值做成字段是为了让单测能把时间窗口压到毫秒级。
minRequests int
stallWindow time.Duration
maxFactor float64
}
func newThrottleGovernor() *throttleGovernor {
now := time.Now()
return &throttleGovernor{
factor: 1,
maxSeen: 1,
minRequests: governorMinRequests,
stallWindow: governorStallWindow,
maxFactor: governorMaxFactor,
lastProgress: now,
lastEscalate: now,
}
}
func (g *throttleGovernor) observe(category string) {
g.mu.Lock()
defer g.mu.Unlock()
now := time.Now()
if category != CategoryTooFrequent {
// 只要服务端确实处理了请求(哪怕是售罄/未开放/协议错误),就算有进展。
g.stall = 0
g.lastProgress = now
if g.factor > 1 {
g.factor /= governorStep
if g.factor < 1 {
g.factor = 1
}
}
return
}
g.stall++
if g.stall < g.minRequests || now.Sub(g.lastProgress) < g.stallWindow {
return
}
if now.Sub(g.lastEscalate) < g.stallWindow {
return
}
g.lastEscalate = now
g.factor *= governorStep
if g.factor > g.maxFactor {
g.factor = g.maxFactor
}
if g.factor > g.maxSeen {
g.maxSeen = g.factor
}
}
func (g *throttleGovernor) current() float64 {
g.mu.Lock()
defer g.mu.Unlock()
return g.factor
}
// peak 返回本次任务用过的最大降速倍率,供汇总日志展示。
func (g *throttleGovernor) peak() float64 {
g.mu.Lock()
defer g.mu.Unlock()
return g.maxSeen
}
func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient, account Account, target Target, index int, loop int, cfg Config, result Result, emit func(Result)) {
if !cfg.RefreshAfterSuccess || emit == nil || result.Category != CategorySuccess {
return
}
state.refresh.Add(1)
go func() {
defer state.refresh.Done()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 刷新请求同样走并发闸门,但用拥有 5 秒预算的独立 ctx。
score, err := func() (*huya.GetUserScoreResp, error) {
if !state.acquire(ctx) {
return nil, ctx.Err()
}
defer state.release()
started := time.Now()
resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID)
state.recordRequest(reqScore, time.Since(started), err)
return resp, err
}()
scoreResult := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: target,
Action: "refresh_score",
Category: CategoryReady,
Message: "刷新积分完成",
Score: score,
}
if err != nil {
scoreResult.Category = CategoryError
scoreResult.Message = "刷新积分失败"
scoreResult.Error = err.Error()
} else if score != nil {
scoreResult.Message = fmt.Sprintf("剩余积分: %d", score.AvailableScore())
}
emit(scoreResult)
records, err := func() (*huya.GetUserPrizeRecordsResp, error) {
if !state.acquire(ctx) {
return nil, ctx.Err()
}
defer state.release()
started := time.Now()
resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID)
state.recordRequest(reqRecord, time.Since(started), err)
return resp, err
}()
recordResult := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: target,
Action: "refresh_records",
Category: CategoryReady,
Message: "刷新兑换记录完成",
Records: records,
}
if err != nil {
recordResult.Category = CategoryError
recordResult.Message = "刷新兑换记录失败"
recordResult.Error = err.Error()
}
emit(recordResult)
}()
}
// listForAccount 返回本账号本轮要用的奖品列表快照。
//
// SharePrizeList 打开时多账号复用同一份快照:TTL 内第一个账号查完,其余账号直接复用
// (单飞:同一时刻只有一个账号在查,避免并发重复查询)。关闭时各查各的。
func (r *Runner) listForAccount(ctx context.Context, client huya.ActivityClient, account Account, cfg Config, state *runState) *listSnapshot {
fetch := func() *listSnapshot {
if !state.acquire(ctx) {
return &listSnapshot{err: ctx.Err()}
}
started := time.Now()
resp, err := client.GetActPrizeList(ctx, account.UID, account.Cookie, cfg.SID)
state.release()
state.recordRequest(reqList, time.Since(started), err)
return &listSnapshot{resp: resp, err: err}
}
if !cfg.SharePrizeList {
return fetch()
}
return state.sharedListOnce(ctx, fetch)
}
func (r *Runner) client() huya.ActivityClient {
if r.Client != nil {
return r.Client
}
return huya.NewHTTPClient(nil)
}
func newRunState(accounts []Account, targetCount, concurrency int, cfg Config) *runState {
if concurrency <= 0 {
concurrency = 1
}
state := &runState{
startedAt: time.Now(),
accounts: make(map[int64][]targetState, len(accounts)),
targetDone: make([]bool, targetCount),
exchanged: make(map[int64]map[int64]bool, len(accounts)),
duplicateNotified: make(map[int64]map[int64]bool, len(accounts)),
firstWaveSent: make(map[int64]bool, len(accounts)),
sem: make(chan struct{}, concurrency),
stats: runStats{
outcomes: map[string]int{},
successUIDs: map[int64]bool{},
requests: map[requestKind]*requestStat{},
accounts: map[int64]*AccountOutcome{},
},
}
for _, account := range accounts {
state.accounts[account.UID] = make([]targetState, targetCount)
}
state.configureWindow(cfg, len(accounts))
return state
}
// configureWindow 按配置初始化窗口与首发错峰参数。
//
// 只有 OpenAt 非零才是窗口模式:测试与直接调用 RunTargets 的老用法(只给 Loops)
// 保持原来的轮次语义,不会被 30 秒窗口拖住。
func (s *runState) configureWindow(cfg Config, accountCount int) {
if cfg.OpenAt.IsZero() {
return
}
window := time.Duration(cfg.WindowSeconds) * time.Second
if cfg.WindowSeconds <= 0 {
window = DefaultWindowSeconds * time.Second
}
if cfg.windowOverride > 0 {
window = cfg.windowOverride
}
startAhead := cfg.StartAhead
if startAhead <= 0 {
startAhead = DefaultStartAhead
}
s.openAt = cfg.OpenAt
s.startAhead = startAhead
s.windowLen = window
s.deadline = cfg.OpenAt.Add(window)
if cfg.FirstWaveSpread > 0 {
step := cfg.FirstWaveSpread / time.Duration(maxInt(accountCount-1, 1))
maxStep := cfg.FirstWaveMaxStep
if maxStep <= 0 {
maxStep = DefaultFirstWaveMaxStep
}
if step > maxStep {
step = maxStep
}
if step < 0 {
step = 0
}
s.firstWaveSpread = cfg.FirstWaveSpread
s.firstWaveStep = step
}
if !cfg.DisableAdaptiveThrottle {
s.governor = newThrottleGovernor()
}
}
// windowed 返回是否处于窗口模式。
func (s *runState) windowed() bool {
return !s.deadline.IsZero()
}
// windowExpired 返回窗口是否已经结束(非窗口模式永远为 false)。
func (s *runState) windowExpired() bool {
if s.deadline.IsZero() {
return false
}
return !time.Now().Before(s.deadline)
}
// windowStart 返回窗口循环应当开始发请求的时刻(开抢时刻减去先手缓冲)。
func (s *runState) windowStart() time.Time {
return s.openAt.Add(-s.startAhead)
}
// needsPrefire 返回是否还欠一轮「开抢前预打」。
//
// 只有距离窗口起点还有一段时间才值得单独预打一轮:正常情况下触发时刻(开抢 − advance)
// 与窗口起点(开抢 − StartAhead)几乎重合,此时再预打一轮就等于把首发齐射打两遍。
func (s *runState) needsPrefire() bool {
if s.openAt.IsZero() || s.prefireDone {
return false
}
return time.Now().Add(prefireMinGap).Before(s.windowStart())
}
func (s *runState) markPrefire() {
s.prefireDone = true
}
// clipToDeadline 把等待时间裁剪到窗口结束,保证不会睡过窗口。
func (s *runState) clipToDeadline(delay time.Duration) time.Duration {
if s.deadline.IsZero() {
return delay
}
remaining := time.Until(s.deadline)
if remaining <= 0 {
return 0
}
if delay > remaining {
return remaining
}
return delay
}
// noteRound 记录所有账号里跑过的最大轮次(用于汇总日志)。
func (s *runState) noteRound(round int) {
s.mu.Lock()
defer s.mu.Unlock()
if round > s.rounds {
s.rounds = round
}
}
// maxRound 返回所有账号里跑过的最大轮次。
func (s *runState) maxRound() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.rounds
}
// accountFinished 返回该账号是否已经没有可尝试的目标(全部成功或进入终态)。
func (s *runState) accountFinished(uid int64, targetCount int) bool {
s.mu.Lock()
defer s.mu.Unlock()
items := s.accounts[uid]
for index := 0; index < targetCount; index++ {
if index < len(s.targetDone) && s.targetDone[index] {
continue
}
if index < len(items) && !items[index].Done {
return false
}
}
return true
}
// sharedListOnce 保证同一时刻只有一个账号去查询奖品列表,其余账号等它完成后复用结果。
//
// 只缓存成功的快照:失败结果不缓存,否则一次抖动会让所有账号一起失败。
func (s *runState) sharedListOnce(ctx context.Context, fetch func() *listSnapshot) *listSnapshot {
s.listMu.Lock()
if s.listSnapshot != nil && time.Since(s.listCachedAt) <= sharedListTTL {
snapshot := s.listSnapshot
s.listMu.Unlock()
return snapshot
}
if inflight := s.listInflight; inflight != nil {
s.listMu.Unlock()
select {
case <-inflight:
case <-ctx.Done():
return &listSnapshot{err: ctx.Err()}
}
s.listMu.Lock()
snapshot := s.listSnapshot
s.listMu.Unlock()
if snapshot != nil {
return snapshot
}
return &listSnapshot{err: errors.New("奖品列表响应为空")}
}
inflight := make(chan struct{})
s.listInflight = inflight
s.listMu.Unlock()
snapshot := fetch()
s.listMu.Lock()
if snapshot != nil && snapshot.err == nil && snapshot.resp != nil {
s.listSnapshot = snapshot
s.listCachedAt = time.Now()
}
s.listInflight = nil
s.listMu.Unlock()
close(inflight)
return snapshot
}
// takeFirstWaveDelay 领取本账号的首发错峰等待时间,每个账号只生效一次。
func (s *runState) takeFirstWaveDelay(uid int64) time.Duration {
if s.firstWaveStep <= 0 {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
if s.firstWaveSent[uid] {
return 0
}
s.firstWaveSent[uid] = true
slot := s.firstWaveUsed
s.firstWaveUsed++
return time.Duration(slot) * s.firstWaveStep
}
// noteOutcome 记录一个账号最近一次兑换类结果,供任务结束时的逐账号结局输出。
func (s *runState) noteOutcome(result Result) {
switch result.Action {
case "exchange", "exchange_error", "skip_duplicate", "not_found":
default:
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.stats.accounts == nil {
s.stats.accounts = map[int64]*AccountOutcome{}
}
outcome := s.stats.accounts[result.UID]
if outcome == nil {
outcome = &AccountOutcome{UID: result.UID}
s.stats.accounts[result.UID] = outcome
}
if result.Action != "skip_duplicate" {
outcome.Attempts++
}
outcome.Category = result.Category
outcome.Message = result.Message
if result.Category == CategorySuccess || result.Done {
outcome.Done = true
}
}
// requestKind 标识一次在途请求的类型,用于汇总日志里的请求次数。
type requestKind int
const (
reqList requestKind = iota
reqExchange
reqScore
reqRecord
)
// String 返回接口类型的中文名,用于「接口统计」日志。
func (k requestKind) String() string {
switch k {
case reqList:
return "奖品列表"
case reqExchange:
return "兑换"
case reqScore:
return "积分"
case reqRecord:
return "兑换记录"
default:
return "未知"
}
}
// acquire 申请一个请求额度;ctx 结束时会返回 false,调用方应直接放弃这次请求。
func (s *runState) acquire(ctx context.Context) bool {
if s.sem == nil {
return ctx.Err() == nil
}
select {
case s.sem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
func (s *runState) release() {
if s.sem == nil {
return
}
<-s.sem
}
// recordRequest 记录一次请求的次数、耗时与错误。
//
// 除了原来的分类计数,还累计耗时/失败/最后错误,任务结束时输出「接口统计」行,
// 这样复盘时能一眼看出是「请求打不出去(慢/超时)」还是「打出去被拒(限流)」。
func (s *runState) recordRequest(kind requestKind, cost time.Duration, err error) {
s.mu.Lock()
defer s.mu.Unlock()
switch kind {
case reqList:
s.stats.listReqs++
case reqExchange:
s.stats.exchangeReqs++
case reqScore:
s.stats.scoreReqs++
case reqRecord:
s.stats.recordReqs++
}
if s.stats.requests == nil {
s.stats.requests = map[requestKind]*requestStat{}
}
stat := s.stats.requests[kind]
if stat == nil {
stat = &requestStat{}
s.stats.requests[kind] = stat
}
stat.observe(cost, err)
}
// recordOutcome 累计一条结局分类,供任务结束时的汇总日志使用。
func (s *runState) recordOutcome(category string) {
if category == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.stats.outcomes == nil {
s.stats.outcomes = map[string]int{}
}
s.stats.outcomes[category]++
}
func (s *runState) markSuccessUID(uid int64) {
s.mu.Lock()
defer s.mu.Unlock()
if s.stats.successUIDs == nil {
s.stats.successUIDs = map[int64]bool{}
}
s.stats.successUIDs[uid] = true
}
func (s *runState) snapshotStats() runStats {
s.mu.Lock()
defer s.mu.Unlock()
out := runStats{
listReqs: s.stats.listReqs,
exchangeReqs: s.stats.exchangeReqs,
scoreReqs: s.stats.scoreReqs,
recordReqs: s.stats.recordReqs,
outcomes: make(map[string]int, len(s.stats.outcomes)),
successUIDs: make(map[int64]bool, len(s.stats.successUIDs)),
requests: make(map[requestKind]*requestStat, len(s.stats.requests)),
accounts: make(map[int64]*AccountOutcome, len(s.stats.accounts)),
}
for key, value := range s.stats.outcomes {
out.outcomes[key] = value
}
for uid := range s.stats.successUIDs {
out.successUIDs[uid] = true
}
for kind, stat := range s.stats.requests {
if stat == nil {
continue
}
copied := *stat
out.requests[kind] = &copied
}
for uid, outcome := range s.stats.accounts {
if outcome == nil {
continue
}
copied := *outcome
out.accounts[uid] = &copied
}
return out
}
// emitSummary 在任务结束时回调一次汇总;OnSummary 为 nil 时不做任何事。
func (r *Runner) emitSummary(state *runState, accounts, targets, loops int, cost time.Duration) {
if r.OnSummary == nil {
return
}
stats := state.snapshotStats()
summary := RunSummary{
Loops: loops,
Accounts: accounts,
Targets: targets,
Duration: cost,
ListReqs: stats.listReqs,
ExchangeReqs: stats.exchangeReqs,
ScoreReqs: stats.scoreReqs,
RecordReqs: stats.recordReqs,
SuccessUIDs: len(stats.successUIDs),
Outcomes: stats.outcomes,
}
if state.windowed() {
summary.OpenAt = state.openAt
summary.WindowSeconds = int(state.windowLen / time.Second)
}
if state.governor != nil {
summary.ThrottleFactor = state.governor.peak()
}
for _, kind := range []requestKind{reqList, reqExchange, reqScore, reqRecord} {
stat := stats.requests[kind]
if stat == nil || stat.count == 0 {
continue
}
summary.RequestStats = append(summary.RequestStats, RequestStat{
Kind: kind.String(),
Count: stat.count,
Failed: stat.failed,
Total: stat.total,
Slowest: stat.slowest,
LastErr: stat.lastErr,
})
}
uids := make([]int64, 0, len(stats.accounts))
for uid := range stats.accounts {
uids = append(uids, uid)
}
sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] })
for _, uid := range uids {
summary.AccountOutcomes = append(summary.AccountOutcomes, *stats.accounts[uid])
}
r.OnSummary(summary)
}
// prizeExchanged 判断该账号是否已经成功兑换过这个奖品。
func (s *runState) prizeExchanged(uid, prizeID int64) bool {
if prizeID <= 0 {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return s.exchanged[uid][prizeID]
}
func (s *runState) markPrizeExchanged(uid, prizeID int64) {
if prizeID <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.exchanged[uid] == nil {
s.exchanged[uid] = map[int64]bool{}
}
s.exchanged[uid][prizeID] = true
}
func (s *runState) activeTargets(uid int64, targetCount int, now time.Time) []int {
s.mu.Lock()
defer s.mu.Unlock()
items := s.accounts[uid]
active := make([]int, 0, targetCount)
for i := 0; i < targetCount; i++ {
if i < len(s.targetDone) && s.targetDone[i] {
continue
}
if i >= len(items) || items[i].Done {
continue
}
if !items[i].RetryAfter.IsZero() && items[i].RetryAfter.After(now) {
continue
}
active = append(active, i)
}
return active
}
func (s *runState) markDone(uid int64, index int) {
s.mu.Lock()
defer s.mu.Unlock()
if items := s.accounts[uid]; index >= 0 && index < len(items) {
items[index].Done = true
items[index].RetryAfter = time.Time{}
s.accounts[uid] = items
}
}
func (s *runState) markTargetDone(index int) {
s.mu.Lock()
defer s.mu.Unlock()
if index >= 0 && index < len(s.targetDone) {
s.targetDone[index] = true
}
}
func (s *runState) setRetryAfter(uid int64, index int, when time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if items := s.accounts[uid]; index >= 0 && index < len(items) {
items[index].RetryAfter = when
s.accounts[uid] = items
}
}
func (s *runState) allDone() bool {
s.mu.Lock()
defer s.mu.Unlock()
for index, globallyDone := range s.targetDone {
if globallyDone {
continue
}
for _, items := range s.accounts {
if index < len(items) && !items[index].Done {
return false
}
}
}
return true
}
func normalizeConfig(cfg Config) Config {
cfg.Category = strings.TrimSpace(cfg.Category)
if cfg.Loops <= 0 && cfg.OpenAt.IsZero() {
// 非窗口模式必须有个有限轮次,否则会变成死循环。
cfg.Loops = 1
}
// 窗口模式下 Loops<=0 表示「一直打到窗口结束」,由 deadline 兜底,这里不做限制。
if cfg.Delay <= 0 {
cfg.Delay = 150 * time.Millisecond
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 1
}
if cfg.TooFrequentBackoff <= 0 {
cfg.TooFrequentBackoff = maxDuration(cfg.Delay*2, defaultRetryBackoff)
}
if cfg.ErrorBackoff <= 0 {
cfg.ErrorBackoff = maxDuration(cfg.Delay, defaultRetryBackoff)
}
// 首发错峰给了 spread 就至少要有 1ms 步长,否则等于没启用。
if cfg.FirstWaveSpread > 0 && cfg.FirstWaveMaxStep <= 0 {
cfg.FirstWaveMaxStep = DefaultFirstWaveMaxStep
}
// 积分不足是终态:积分不会自己变多,继续打只是浪费请求,默认停手。
if !cfg.ContinueOnNotEnough {
cfg.StopOnNotEnough = true
}
// 售罄刻意不给默认值:库存可能因别人取消 / 锁单过期而回流,默认继续探测到
// 窗口结束;需要「售罄即停」时显式设置 StopOnSoldOut。
if !cfg.ListBeforeExchange {
cfg.DirectExchange = true
}
return cfg
}
// normalizeTargets 去掉空目标与重复目标。
//
// 除了完全相同的 target,还按 pid / 名称去重:界面选品会同时提交 pid、名称,
// 以及 targetText 里的同一个 pid,不去重就会对同一奖品重复兑换(实测踩过)。
func normalizeTargets(targets []Target) []Target {
out := make([]Target, 0, len(targets))
seenKey := map[string]bool{}
seenPID := map[int32]bool{}
seenName := map[string]bool{}
for _, target := range targets {
target.Name = strings.TrimSpace(target.Name)
if target.PID <= 0 && target.Name == "" && !target.Auto {
continue
}
if target.PID > 0 && seenPID[target.PID] {
continue
}
if target.Name != "" && seenName[target.Name] {
continue
}
key := targetKey(target)
if seenKey[key] {
continue
}
seenKey[key] = true
if target.PID > 0 {
seenPID[target.PID] = true
}
if target.Name != "" {
seenName[target.Name] = true
}
out = append(out, target)
}
return out
}
func shouldDirectExchange(target Target, cfg Config) bool {
return cfg.DirectExchange && !cfg.ListBeforeExchange && target.PID > 0 && !target.Auto
}
// notFoundMessage 在未命中时把类目限制写进提示,便于区分「被类目过滤掉」与「列表里确实没有」。
func notFoundMessage(category string) string {
if strings.TrimSpace(category) == "" {
return "未查询到所需物品,继续监控"
}
return fmt.Sprintf("类目[%s]内未查询到所需物品,继续监控", strings.TrimSpace(category))
}
func matchPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo {
if target.Auto {
return autoPrize(prizes, target)
}
for i := range prizes {
if target.PID > 0 && int32(prizes[i].ID) != target.PID {
continue
}
if target.PID == 0 && target.Name != "" && !strings.Contains(prizes[i].Name, target.Name) {
continue
}
if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice {
continue
}
if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft {
continue
}
return &prizes[i]
}
return nil
}
func autoPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo {
var fallback *huya.PrizeInfo
for i := range prizes {
if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice {
continue
}
if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft {
continue
}
if fallback == nil {
fallback = &prizes[i]
}
if prizes[i].EstimatedLeft() > 0 {
return &prizes[i]
}
}
return fallback
}
// exchangeSignals 汇总一次兑换响应里可用于判定的信号。
//
// 虎牙把失败原因分散在 msg / actPreCondition(Tip/ButtonDesc/ConditionParams)
// 和 exchangeInfo.desc 里,status 除 200 外没有可依据的公开语义,
// 所以判定顺序是:文案 -> 成功结构(status=200 且有 orderId)-> 结构门禁 -> 兜底失败。
type exchangeSignals struct {
text string
status int32
hasOrderID bool
conditionType int32
}
func buildExchangeSignals(resp *huya.ScoreExchangePrizeResp) exchangeSignals {
if resp == nil {
return exchangeSignals{}
}
parts := []string{
resp.Msg,
resp.ActPreCondition.Tip,
resp.ActPreCondition.ButtonDesc,
resp.ExchangeInfo.Desc,
}
for _, value := range resp.ActPreCondition.ConditionParams {
parts = append(parts, value)
}
texts := make([]string, 0, len(parts))
for _, part := range parts {
if item := strings.TrimSpace(part); item != "" {
texts = append(texts, item)
}
}
return exchangeSignals{
text: strings.Join(texts, " "),
status: resp.Status,
hasOrderID: strings.TrimSpace(resp.OrderID) != "",
conditionType: resp.ActPreCondition.ConditionType,
}
}
// classifyExchangeText 只依据文案判定失败原因,命中时返回分类。
//
// 判定顺序固定:未开放 → 售罄 → 货币/次数 → 会话失效 → 下架 → 限流。
// 顺序很关键:限流关键词里有「稍后重试」「稍后」,如果排在前面会把更具体的原因吃掉。
// 2026-10-16 的日志里 244 条「领取频率限制,请稍后重试!!」和 917 条「活动太火爆了,
// 请稍后重试」过去全落到 failed:既看不出是被限流,也享受不到限流该有的退避语义。
func classifyExchangeText(text string) (string, bool) {
switch {
case containsAny(text, "暂未开放", "未开放", "未开始", "活动未开始"):
return CategoryNotOpen, true
case containsAny(text, "已抢完", "已抢光", "已兑完", "已售完", "库存不足", "没有库存", "领完了", "库存已耗完"):
return CategorySoldOut, true
case containsAny(text, "货币不足", "积分不足", "余额不足", "点券不足", "不足"):
return CategoryNotEnough, true
case containsAny(text, "已兑换", "重复兑换", "已领取", "超过兑换次数", "达到上限", "限购"):
return CategoryAlreadyDone, true
case containsAny(text, "账号未绑定", "未绑定账号", "未绑定手机", "登录态", "登录失效", "请先登录", "凭证失效", "token 失效"):
return CategorySessionLost, true
case containsAny(text, "奖品已下架", "已下架", "奖品不存在", "活动不存在"):
return CategoryOffShelf, true
case containsAny(text, "操作频繁", "太频繁", "稍后再试", "稍后重试", "火爆", "频繁", "频率", "限流", "繁忙", "人多", "排队", "手慢", "点击太快", "请求过多", "行为异常"):
return CategoryTooFrequent, true
}
return "", false
}
func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string {
if err != nil {
// 协议层错误(缺少 tRsp/tResp、解码失败)单独归类:它不是业务失败,
// 通常是服务端在高并发下回了错误信封,需要单独统计与排查。
if errors.Is(err, huya.ErrProtocolResponse) {
return CategoryProtocol
}
return CategoryError
}
if resp == nil {
return CategoryFailed
}
if resp.Success() {
return CategorySuccess
}
signals := buildExchangeSignals(resp)
if category, ok := classifyExchangeText(signals.text); ok {
return category
}
// 成功回包也可能不带中文文案:status=200 且有 orderId 视为成功。
if signals.status == 200 && signals.hasOrderID {
return CategorySuccess
}
// 没有文案但服务端回了 precondition 门禁,归为条件未达成,按可重试失败处理。
if signals.conditionType != 0 {
return CategoryCondition
}
return CategoryFailed
}
// ExchangeMessage 返回响应里最有代表性的提示文案,供日志与 GUI 展示。
func ExchangeMessage(resp *huya.ScoreExchangePrizeResp) string {
if resp == nil {
return ""
}
for _, text := range []string{
resp.Msg,
resp.ActPreCondition.Tip,
resp.ActPreCondition.ButtonDesc,
resp.ExchangeInfo.Desc,
} {
if strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func containsAny(text string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(text, needle) {
return true
}
}
return false
}
func targetKey(target Target) string {
return strings.Join([]string{
strings.TrimSpace(target.Name),
int32String(target.PID),
strconv.FormatBool(target.Auto),
int64String(target.MaxPrice),
int64String(target.MinLeft),
}, "\x00")
}
func int32String(value int32) string {
return strconv.FormatInt(int64(value), 10)
}
func int64String(value int64) string {
return strconv.FormatInt(value, 10)
}
func sleepContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func maxDuration(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// jitterDuration 在基准退避上叠加 0-25% 的随机抖动。
//
// 多个账号同时失败时,固定退避会让它们在同一个时刻整批重试,形成周期性尖峰,
// 更容易撞上限频(参考 dygou_go 的 jitterFraction)。这里只往长了抖,不缩短,
// 保证配置值始终是退避下限。
func jitterDuration(base time.Duration) time.Duration {
if base <= 0 {
return base
}
span := int64(base) / 4
if span <= 0 {
return base
}
return base + time.Duration(rand.Int63n(span+1))
}