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