1998 lines
62 KiB
Go
1998 lines
62 KiB
Go
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))
|
||
}
|