Files
huya-go/internal/sniper/run_control.go
T
yml2213 6e5beac151 refactor(sniper): 对齐 dygou_go 调度架构,增加双时间日志并解耦预热
- 抢购调度重构:参考 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 构建正常,单测覆盖双时间与预热配置
2026-10-17 18:29:17 +08:00

463 lines
12 KiB
Go

package sniper
import (
"context"
"errors"
"time"
)
func newRunState(accounts []Account, targetCount, concurrency int, cfg Config) *runState {
if concurrency <= 0 {
concurrency = 1
}
state := &runState{
startedAt: time.Now(),
accounts: make(map[int64]*accountState, 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] = &accountState{targets: make([]targetState, targetCount)}
}
state.configureWindow(cfg, len(accounts))
return state
}
// configureWindow 按配置初始化窗口与首发错峰参数。
//
// 只有 OpenAt 非零才是窗口模式;正常 GUI 抢购始终是窗口模式。
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
}
}
// 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
}
// noteAttempt 记录所有账号里跑过的最大轮次(用于汇总日志)。
func (s *runState) noteAttempt(round int) {
s.mu.Lock()
defer s.mu.Unlock()
if round > s.maxAttemptCount {
s.maxAttemptCount = round
}
}
// maxAttempt 返回所有账号里跑过的最大轮次。
func (s *runState) maxAttempt() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.maxAttemptCount
}
// accountFinished 返回该账号是否已经没有可尝试的目标(全部成功或进入终态)。
func (s *runState) accountFinished(uid int64, targetCount int) bool {
s.mu.Lock()
defer s.mu.Unlock()
account := s.accounts[uid]
if account == nil || account.done {
return true
}
for index := 0; index < targetCount; index++ {
if index < len(s.targetDone) && s.targetDone[index] {
continue
}
if index < len(account.targets) && !account.targets[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
}
// 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) []int {
s.mu.Lock()
defer s.mu.Unlock()
account := s.accounts[uid]
active := make([]int, 0, targetCount)
if account == nil || account.done {
return active
}
for i := 0; i < targetCount; i++ {
if i < len(s.targetDone) && s.targetDone[i] {
continue
}
if i >= len(account.targets) || account.targets[i].Done {
continue
}
active = append(active, i)
}
return active
}
func (s *runState) markDone(uid int64, index int) {
s.mu.Lock()
defer s.mu.Unlock()
if account := s.accounts[uid]; account != nil && index >= 0 && index < len(account.targets) {
account.targets[index].Done = true
}
}
// markAccountDone 一次性结束该账号的所有目标。
func (s *runState) markAccountDone(uid int64) {
s.mu.Lock()
defer s.mu.Unlock()
if account := s.accounts[uid]; account != nil {
account.done = true
for index := range account.targets {
account.targets[index].Done = true
}
}
}
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) allDone() bool {
s.mu.Lock()
defer s.mu.Unlock()
for index, globallyDone := range s.targetDone {
if globallyDone {
continue
}
for _, account := range s.accounts {
if account == nil || account.done {
continue
}
if index < len(account.targets) && !account.targets[index].Done {
return false
}
}
}
return true
}