- 抢购调度重构:参考 dygou_go 将 sniper 拆分为独立子模块(runner/run_control/retry/exchange/classify/config/summary/category/helpers);移除全局自适应降速与轮次上限,采用账号独立循环、统一账号级退避、单请求并发闸门与首发错峰 - 预热与建连优化:默认关闭预热(避免单账号超时连坐整机开抢),开启时转为后台非阻塞协程;WebSocket 握手超时从 10s 降至 1500ms - 双时间日志:logger 增加 SetServerOffset,校准后日志前缀自动渲染「本地时间(H虎牙估算时间)」,实现跨机器绝对毫秒对齐 - 默认配置调优:版本提升至 v0.5.0,默认线程调为 2(缓解多实例并发限流),前端自动平滑迁移 - 验证方式:go test ./... -count=1 全通,npm run build 构建正常,单测覆盖双时间与预热配置
369 lines
11 KiB
Go
369 lines
11 KiB
Go
package sniper
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync"
|
||
"time"
|
||
|
||
"huya-go/internal/huya"
|
||
)
|
||
|
||
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.maxAttempt(), time.Since(startedAt))
|
||
}()
|
||
|
||
runWave := func(loop int, waveAccounts []Account) {
|
||
var wg sync.WaitGroup
|
||
for _, account := range waveAccounts {
|
||
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()
|
||
// 预打只需要探活,不需要把所有账号都打一遍:账号多且网络慢时,
|
||
// 全账号预打可能一直打到开抢之后,反而阻塞正式窗口。
|
||
probeAccounts := accounts
|
||
if limit := prefireProbeCount(cfg.Concurrency, len(probeAccounts)); limit < len(probeAccounts) {
|
||
probeAccounts = probeAccounts[:limit]
|
||
}
|
||
runWave(1, probeAccounts)
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
}
|
||
|
||
// 无论是否预打,正式请求都不能早于「开抢 − StartAhead」。
|
||
// 非预打分支过去直接进入循环,固定提前量在 250~310ms 时会产生窗口外请求。
|
||
if !state.windowed() || !state.windowExpired() {
|
||
if wait := time.Until(state.windowStart()); wait > 0 {
|
||
if err := sleepContext(ctx, wait); 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{}, prewarmConcurrency(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 是单个账号的独立抢购循环:每轮尝试所有未完成目标,
|
||
// 结束后只按账号级退避等待。控制流与 dygou_go 的 runAccountLoop 保持一致:
|
||
// 窗口截止、账号完成、显式停止三种条件之外不再维护 target 级定时器。
|
||
func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, emit func(Result), cancel context.CancelFunc) {
|
||
if wait := state.takeFirstWaveDelay(account.UID); wait > 0 {
|
||
if err := sleepContext(ctx, wait); err != nil {
|
||
return
|
||
}
|
||
}
|
||
|
||
attempts := 0
|
||
for {
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
if cfg.maxAttempts > 0 && attempts >= cfg.maxAttempts {
|
||
return
|
||
}
|
||
if state.allDone() || state.windowExpired() || state.accountFinished(account.UID, len(targets)) {
|
||
return
|
||
}
|
||
|
||
outcome := r.runAccountRound(ctx, client, state, account, targets, cfg, attempts+1, emit, cancel)
|
||
if outcome.attempted {
|
||
attempts++
|
||
state.noteAttempt(attempts)
|
||
}
|
||
if outcome.accountDone || ctx.Err() != nil || state.allDone() || state.windowExpired() {
|
||
return
|
||
}
|
||
if wait := state.clipToDeadline(outcome.delay); wait > 0 {
|
||
if err := sleepContext(ctx, wait); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
type roundOutcome struct {
|
||
attempted bool
|
||
delay time.Duration
|
||
accountDone bool
|
||
}
|
||
|
||
// runAccountRound 是单账号的一轮尝试:不区分 target 的冷却时间,
|
||
// 每次把所有未完成 target 各尝试一次,最后统一计算一次账号级退避。
|
||
func (r *Runner) runAccountRound(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, round int, emit func(Result), cancel context.CancelFunc) roundOutcome {
|
||
active := state.activeTargets(account.UID, len(targets))
|
||
if len(active) == 0 {
|
||
return roundOutcome{}
|
||
}
|
||
attempted := false
|
||
categories := make([]string, 0, len(active))
|
||
listTargets := make([]int, 0, len(active))
|
||
|
||
finish := func() roundOutcome {
|
||
if attempted {
|
||
state.completeRound(account.UID, categories, sessionLostLimit(cfg))
|
||
}
|
||
return roundOutcome{
|
||
attempted: attempted,
|
||
delay: state.roundDelay(account.UID, cfg, categories),
|
||
accountDone: state.accountFinished(account.UID, len(targets)) || ctx.Err() != nil,
|
||
}
|
||
}
|
||
|
||
for _, index := range active {
|
||
target := targets[index]
|
||
if !shouldDirectExchange(target, cfg) {
|
||
listTargets = append(listTargets, index)
|
||
continue
|
||
}
|
||
if state.prizeExchanged(account.UID, int64(target.PID)) {
|
||
state.emitSkippedDuplicate(emit, round, account.UID, index, int64(target.PID), target)
|
||
continue
|
||
}
|
||
result := r.exchangeTarget(ctx, client, state, account, target, index, round, cfg.SID, nil)
|
||
if result.Action == actionExchangeCanceled {
|
||
return finish()
|
||
}
|
||
attempted = true
|
||
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
|
||
categories = append(categories, result.Category)
|
||
if result.Category == CategorySuccess {
|
||
state.markPrizeExchanged(account.UID, int64(target.PID))
|
||
}
|
||
if emit != nil {
|
||
emit(result)
|
||
}
|
||
r.refreshAfterSuccess(state, client, account, target, index, round, cfg, result, emit)
|
||
if ctx.Err() != nil || state.accountFinished(account.UID, len(targets)) {
|
||
return finish()
|
||
}
|
||
}
|
||
if len(listTargets) == 0 || ctx.Err() != nil {
|
||
return finish()
|
||
}
|
||
|
||
list := r.listForAccount(ctx, client, account, cfg, state)
|
||
if ctx.Err() != nil || state.windowExpired() {
|
||
return finish()
|
||
}
|
||
attempted = true
|
||
if list.err != nil || list.resp == nil {
|
||
errText := "奖品列表响应为空"
|
||
if list.err != nil {
|
||
errText = list.err.Error()
|
||
}
|
||
categories = append(categories, CategoryError)
|
||
for _, index := range listTargets {
|
||
state.recordOutcome(CategoryError)
|
||
result := Result{
|
||
Loop: round,
|
||
UID: account.UID,
|
||
TargetIndex: index,
|
||
Target: targets[index],
|
||
Action: "list_error",
|
||
Category: CategoryError,
|
||
Message: "查询奖品列表失败",
|
||
Error: errText,
|
||
}
|
||
if emit != nil {
|
||
emit(result)
|
||
}
|
||
}
|
||
return finish()
|
||
}
|
||
|
||
prizes := list.resp.FilterByCategory(cfg.Category)
|
||
for _, index := range listTargets {
|
||
target := targets[index]
|
||
prize := matchPrize(prizes, target)
|
||
if prize == nil {
|
||
state.recordOutcome(CategoryNotFound)
|
||
categories = append(categories, CategoryNotFound)
|
||
if emit != nil {
|
||
emit(Result{
|
||
Loop: round,
|
||
UID: account.UID,
|
||
TargetIndex: index,
|
||
Target: target,
|
||
Action: "not_found",
|
||
Category: CategoryNotFound,
|
||
Message: notFoundMessage(cfg.Category),
|
||
})
|
||
}
|
||
continue
|
||
}
|
||
if state.prizeExchanged(account.UID, prize.ID) {
|
||
state.emitSkippedDuplicate(emit, round, account.UID, index, prize.ID, target)
|
||
continue
|
||
}
|
||
result := r.exchangeTarget(ctx, client, state, account, target, index, round, cfg.SID, prize)
|
||
if result.Action == actionExchangeCanceled {
|
||
return finish()
|
||
}
|
||
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
|
||
categories = append(categories, result.Category)
|
||
if result.Category == CategorySuccess {
|
||
state.markPrizeExchanged(account.UID, prize.ID)
|
||
}
|
||
if emit != nil {
|
||
emit(result)
|
||
}
|
||
r.refreshAfterSuccess(state, client, account, target, index, round, cfg, result, emit)
|
||
if ctx.Err() != nil || state.accountFinished(account.UID, len(targets)) {
|
||
return finish()
|
||
}
|
||
}
|
||
return finish()
|
||
}
|
||
|
||
func (r *Runner) client() huya.ActivityClient {
|
||
if r.Client != nil {
|
||
return r.Client
|
||
}
|
||
return huya.NewHTTPClient(nil)
|
||
}
|
||
|
||
// prefireProbeCount 限制开抢前探活账号数:探活只需要少量样本,
|
||
// 即使抢购并发设到 100,也不能在开抢前齐射一百个请求。
|
||
func prefireProbeCount(configured, total int) int {
|
||
if total <= 0 {
|
||
return 0
|
||
}
|
||
if configured <= 0 {
|
||
configured = 1
|
||
}
|
||
if configured > 8 {
|
||
configured = 8
|
||
}
|
||
if configured > total {
|
||
return total
|
||
}
|
||
return configured
|
||
}
|
||
|
||
// prewarmConcurrency 给建连预热单独限流:抢购请求仍然严格按 Concurrency,
|
||
// 但预热必须在开抢前 2 秒内完成。沿用 5 个并发时,几十上百个账号很容易
|
||
// 预热到开抢之后才返回,反而错过整个窗口。
|
||
func prewarmConcurrency(configured int) int {
|
||
if configured < 8 {
|
||
configured = 8
|
||
}
|
||
if configured > 16 {
|
||
configured = 16
|
||
}
|
||
return configured
|
||
}
|