1029 lines
30 KiB
Go
1029 lines
30 KiB
Go
package seckill
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/golang/freetype/truetype"
|
||
|
||
"douyu_go/internal/client"
|
||
"douyu_go/internal/constants"
|
||
"douyu_go/internal/image"
|
||
"douyu_go/internal/logger"
|
||
"douyu_go/internal/types"
|
||
"douyu_go/internal/utils"
|
||
)
|
||
|
||
const (
|
||
warmupBeforeTrigger = 2 * time.Second
|
||
warmupMaxAge = 30 * time.Second
|
||
qualifiedMaxAge = 30 * time.Second
|
||
timeCalibrateRounds = 3
|
||
timeCalibrateWait = 1200 * time.Millisecond
|
||
resyncBeforeTrigger = 3 * time.Second
|
||
calibrationHistorySize = 3
|
||
seckillMaxDuration = 75 * time.Second
|
||
behaviorCooldown = 5 * time.Second
|
||
clickFastCooldown = 3 * time.Second
|
||
minInternalAdvance = 50 * time.Millisecond
|
||
maxCooldownCount = 5
|
||
xiaodianDefaultDelay = 500 * time.Millisecond
|
||
)
|
||
|
||
type roundOutcome struct {
|
||
successAccounts map[string]bool
|
||
stoppedAccounts map[string]string
|
||
stoppedProducts map[string]string
|
||
cooldownAccounts map[string]time.Time
|
||
attempts int
|
||
}
|
||
|
||
type runSettings struct {
|
||
concurrency int
|
||
attempts int
|
||
retryStages []types.RetryStage
|
||
seckillMode types.SeckillMode
|
||
}
|
||
|
||
// Scheduler 抢购调度器。
|
||
// 具体职责拆分到同包文件:attempt.go(兑换执行)、warmup.go(预热)、
|
||
// schedule_timer.go(定时抢购)、account_store.go(账号管理)。
|
||
type Scheduler struct {
|
||
clientMu sync.RWMutex
|
||
client *client.DouyuClient
|
||
paths types.AppPaths
|
||
font *truetype.Font
|
||
|
||
successImages *image.SuccessImageSaver
|
||
|
||
settingsMu sync.RWMutex
|
||
concurrency int // 抢购并发数
|
||
attempts int // 单号重试次数
|
||
|
||
retryStages []types.RetryStage // 重试间隔阶段配置
|
||
seckillMode types.SeckillMode // 抢购模式
|
||
|
||
accounts *AccountStore
|
||
|
||
resultsMu sync.RWMutex
|
||
results map[string]string
|
||
|
||
runMu sync.Mutex
|
||
running bool
|
||
stopRun chan struct{}
|
||
stopRequested bool
|
||
stateMu sync.RWMutex
|
||
onStateChange func(string)
|
||
// pendingClient 保存运行中收到的客户端更新,当前任务结束后自动应用。
|
||
pendingClient *client.DouyuClient
|
||
|
||
scheduleMu sync.Mutex
|
||
schedule *time.Timer
|
||
warmupSchedule *time.Timer
|
||
resyncSchedule *time.Timer
|
||
warmupGen int64
|
||
|
||
warmupsMu sync.RWMutex
|
||
warmups map[string]types.WarmupData
|
||
warmupRunMu sync.Mutex
|
||
|
||
attemptCacheMu sync.RWMutex
|
||
attemptCache map[string]*types.AttemptCache // 激进模式缓存
|
||
|
||
productsMu sync.RWMutex
|
||
products map[string]types.Product
|
||
|
||
calibrationMu sync.Mutex
|
||
calibrationOffsets []time.Duration // 最近N次校准偏移,用于中位数平滑
|
||
}
|
||
|
||
// SetStateChangeCallback 设置运行状态变化回调。
|
||
func (s *Scheduler) SetStateChangeCallback(cb func(string)) {
|
||
s.stateMu.Lock()
|
||
s.onStateChange = cb
|
||
s.stateMu.Unlock()
|
||
}
|
||
|
||
func (s *Scheduler) emitState(state string) {
|
||
s.stateMu.RLock()
|
||
cb := s.onStateChange
|
||
s.stateMu.RUnlock()
|
||
if cb != nil {
|
||
cb(state)
|
||
}
|
||
}
|
||
|
||
// New 创建抢购调度器。
|
||
func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, concurrency, attempts int, retryStages []types.RetryStage, seckillMode types.SeckillMode) *Scheduler {
|
||
if concurrency <= 0 {
|
||
concurrency = constants.DefaultConcurrency
|
||
}
|
||
if attempts <= 0 {
|
||
attempts = constants.DefaultAttempts
|
||
}
|
||
return &Scheduler{
|
||
client: cli,
|
||
paths: paths,
|
||
font: font,
|
||
accounts: NewAccountStore(paths.AccountFile),
|
||
successImages: image.NewSuccessImageSaver(
|
||
font,
|
||
paths.SuccessDir,
|
||
1,
|
||
256,
|
||
),
|
||
concurrency: concurrency,
|
||
attempts: attempts,
|
||
retryStages: cloneRetryStages(retryStages),
|
||
seckillMode: normalizeSeckillMode(seckillMode),
|
||
results: make(map[string]string),
|
||
attemptCache: make(map[string]*types.AttemptCache),
|
||
products: make(map[string]types.Product),
|
||
calibrationOffsets: make([]time.Duration, 0, calibrationHistorySize),
|
||
}
|
||
}
|
||
|
||
// Close 关闭调度器的后台资源。
|
||
func (s *Scheduler) Close() {
|
||
if s.successImages != nil {
|
||
s.successImages.Close()
|
||
}
|
||
}
|
||
|
||
// clientSnapshot 返回当前 client 的指针快照(读锁保护,用于执行期间持有一个稳定引用)。
|
||
func (s *Scheduler) clientSnapshot() *client.DouyuClient {
|
||
s.clientMu.RLock()
|
||
defer s.clientMu.RUnlock()
|
||
return s.client
|
||
}
|
||
|
||
// UpdateClient 热替换底层 HTTP 客户端(用于活动切换)。
|
||
// 若有抢购任务正在运行则暂存新客户端,当前任务结束后自动应用,并返回 false。
|
||
func (s *Scheduler) UpdateClient(newClient *client.DouyuClient) bool {
|
||
s.runMu.Lock()
|
||
if s.running {
|
||
if s.pendingClient != nil && s.pendingClient.HTTPClient != nil {
|
||
s.pendingClient.HTTPClient.CloseIdleConnections()
|
||
}
|
||
s.pendingClient = newClient
|
||
s.runMu.Unlock()
|
||
return false
|
||
}
|
||
s.runMu.Unlock()
|
||
|
||
s.replaceClient(newClient)
|
||
return true
|
||
}
|
||
|
||
func (s *Scheduler) replaceClient(newClient *client.DouyuClient) {
|
||
s.clientMu.Lock()
|
||
defer s.clientMu.Unlock()
|
||
old := s.client
|
||
s.client = newClient
|
||
if old != nil && old.HTTPClient != nil {
|
||
old.HTTPClient.CloseIdleConnections()
|
||
}
|
||
}
|
||
|
||
// UpdateProducts 更新商品缓存,抢购成功图片会优先使用中文商品名。
|
||
func (s *Scheduler) UpdateProducts(products []types.Product) {
|
||
next := make(map[string]types.Product, len(products))
|
||
for _, product := range products {
|
||
if product.CommodityID == "" {
|
||
continue
|
||
}
|
||
if product.CommodityName == "" {
|
||
product.CommodityName = product.CommodityID
|
||
}
|
||
next[product.CommodityID] = product
|
||
}
|
||
s.productsMu.Lock()
|
||
s.products = next
|
||
s.productsMu.Unlock()
|
||
}
|
||
|
||
// UpdateSettings 运行时更新并发数、重试次数、重试间隔和抢购模式。
|
||
func (s *Scheduler) UpdateSettings(concurrency, attempts int, retryStages []types.RetryStage, seckillMode types.SeckillMode) {
|
||
s.settingsMu.Lock()
|
||
defer s.settingsMu.Unlock()
|
||
if concurrency > 0 {
|
||
s.concurrency = concurrency
|
||
}
|
||
if attempts > 0 {
|
||
s.attempts = attempts
|
||
}
|
||
if len(retryStages) > 0 {
|
||
s.retryStages = cloneRetryStages(retryStages)
|
||
}
|
||
s.seckillMode = normalizeSeckillMode(seckillMode)
|
||
}
|
||
|
||
func (s *Scheduler) settingsSnapshot() runSettings {
|
||
s.settingsMu.RLock()
|
||
settings := runSettings{
|
||
concurrency: s.concurrency,
|
||
attempts: s.attempts,
|
||
retryStages: cloneRetryStages(s.retryStages),
|
||
seckillMode: normalizeSeckillMode(s.seckillMode),
|
||
}
|
||
s.settingsMu.RUnlock()
|
||
if settings.concurrency <= 0 {
|
||
settings.concurrency = constants.DefaultConcurrency
|
||
}
|
||
if settings.attempts <= 0 {
|
||
settings.attempts = constants.DefaultAttempts
|
||
}
|
||
return settings
|
||
}
|
||
|
||
func cloneRetryStages(stages []types.RetryStage) []types.RetryStage {
|
||
if len(stages) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]types.RetryStage, len(stages))
|
||
copy(out, stages)
|
||
return out
|
||
}
|
||
|
||
func normalizeSeckillMode(mode types.SeckillMode) types.SeckillMode {
|
||
switch mode {
|
||
case types.SeckillModeNormal, types.SeckillModeAggressive:
|
||
return mode
|
||
default:
|
||
return types.SeckillModeAggressive
|
||
}
|
||
}
|
||
|
||
// ==================== 账号转发(AccountStore) ====================
|
||
|
||
// LoadAccounts 从账号文件加载账号。
|
||
func (s *Scheduler) LoadAccounts() {
|
||
s.accounts.Load()
|
||
}
|
||
|
||
// AccountCount 返回当前账号数。
|
||
func (s *Scheduler) AccountCount() int {
|
||
return s.accounts.Count()
|
||
}
|
||
|
||
// AccountFilePath 返回账号文件路径。
|
||
func (s *Scheduler) AccountFilePath() string {
|
||
return s.accounts.FilePath()
|
||
}
|
||
|
||
// AccountSnapshot 复制一份账号列表。
|
||
func (s *Scheduler) AccountSnapshot() []types.Account {
|
||
return s.accounts.Snapshot()
|
||
}
|
||
|
||
// AccountCookieByName 按账号名查找 Cookie。
|
||
func (s *Scheduler) AccountCookieByName(name string) (string, bool) {
|
||
return s.accounts.CookieByName(name)
|
||
}
|
||
|
||
// ==================== 结果管理 ====================
|
||
|
||
// Results 返回当前抢购结果快照。
|
||
func (s *Scheduler) Results() map[string]string {
|
||
s.resultsMu.RLock()
|
||
defer s.resultsMu.RUnlock()
|
||
out := make(map[string]string, len(s.results))
|
||
for k, v := range s.results {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ClearResults 清空抢购结果。
|
||
func (s *Scheduler) ClearResults() {
|
||
s.resultsMu.Lock()
|
||
defer s.resultsMu.Unlock()
|
||
s.results = make(map[string]string)
|
||
}
|
||
|
||
func (s *Scheduler) setResult(key, value string) {
|
||
s.resultsMu.Lock()
|
||
defer s.resultsMu.Unlock()
|
||
s.results[key] = value
|
||
}
|
||
|
||
// ==================== 运行控制 ====================
|
||
|
||
// TryStartRun 尝试开始抢购,返回 false 表示已有任务在运行。
|
||
func (s *Scheduler) TryStartRun() bool {
|
||
s.runMu.Lock()
|
||
if s.running {
|
||
s.runMu.Unlock()
|
||
return false
|
||
}
|
||
s.running = true
|
||
s.stopRequested = false
|
||
s.stopRun = make(chan struct{})
|
||
s.runMu.Unlock()
|
||
s.emitState("running")
|
||
return true
|
||
}
|
||
|
||
func (s *Scheduler) finishRun() {
|
||
s.runMu.Lock()
|
||
s.running = false
|
||
s.stopRequested = false
|
||
s.stopRun = nil
|
||
pendingClient := s.pendingClient
|
||
s.pendingClient = nil
|
||
s.runMu.Unlock()
|
||
|
||
if pendingClient != nil {
|
||
s.replaceClient(pendingClient)
|
||
logger.LogEvent("配置", "success", "抢购任务结束,已应用等待中的客户端配置")
|
||
}
|
||
s.emitState("idle")
|
||
}
|
||
|
||
// StopRun 请求停止当前正在执行的抢购任务。
|
||
func (s *Scheduler) StopRun() bool {
|
||
s.runMu.Lock()
|
||
if !s.running || s.stopRequested || s.stopRun == nil {
|
||
s.runMu.Unlock()
|
||
return false
|
||
}
|
||
s.stopRequested = true
|
||
close(s.stopRun)
|
||
s.runMu.Unlock()
|
||
s.emitState("stopping")
|
||
return true
|
||
}
|
||
|
||
// IsRunning 返回当前是否有抢购任务正在执行。
|
||
func (s *Scheduler) IsRunning() bool {
|
||
s.runMu.Lock()
|
||
defer s.runMu.Unlock()
|
||
return s.running
|
||
}
|
||
|
||
func (s *Scheduler) stopRunChan() <-chan struct{} {
|
||
s.runMu.Lock()
|
||
defer s.runMu.Unlock()
|
||
return s.stopRun
|
||
}
|
||
|
||
func (s *Scheduler) runStopped() bool {
|
||
s.runMu.Lock()
|
||
defer s.runMu.Unlock()
|
||
return s.stopRequested
|
||
}
|
||
|
||
func (s *Scheduler) interruptibleSleep(d time.Duration) bool {
|
||
if d <= 0 {
|
||
return s.runStopped()
|
||
}
|
||
stop := s.stopRunChan()
|
||
if stop == nil {
|
||
time.Sleep(d)
|
||
return false
|
||
}
|
||
timer := time.NewTimer(d)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-timer.C:
|
||
return false
|
||
case <-stop:
|
||
return true
|
||
}
|
||
}
|
||
|
||
// RunNow 立即执行抢购任务,持续循环直到超时或全部成功。
|
||
func (s *Scheduler) RunNow(accountProducts map[string]string) {
|
||
defer s.finishRun()
|
||
s.ClearResults()
|
||
|
||
settings := s.settingsSnapshot()
|
||
allAttempts := s.buildAttempts(accountProducts, settings.attempts)
|
||
if len(allAttempts) == 0 {
|
||
logger.LogEvent("抢购", "warn", "没有可执行的抢购任务")
|
||
return
|
||
}
|
||
|
||
successAccounts := make(map[string]bool)
|
||
stoppedAccounts := make(map[string]string)
|
||
stoppedProducts := make(map[string]string)
|
||
cooldownAccounts := make(map[string]time.Time)
|
||
cooldownCount := make(map[string]int)
|
||
totalAccounts := uniqueAttemptAccounts(allAttempts)
|
||
totalAttempts := 0
|
||
startTime := time.Now()
|
||
round := 0
|
||
|
||
s.clearAttemptCache()
|
||
if settings.seckillMode == types.SeckillModeAggressive {
|
||
s.initCacheFromWarmup(allAttempts)
|
||
}
|
||
|
||
logger.LogEvent("抢购", "info", "开始抢购: %d 个账号,窗口=%v,预检通过后直兑,前段高频后段降速", totalAccounts, seckillMaxDuration)
|
||
|
||
for {
|
||
if s.runStopped() {
|
||
logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
|
||
break
|
||
}
|
||
elapsed := time.Since(startTime)
|
||
if elapsed >= seckillMaxDuration {
|
||
logger.LogEvent("抢购", "info", "已达到抢购窗口 %v,停止抢购", seckillMaxDuration)
|
||
break
|
||
}
|
||
|
||
round++
|
||
remaining := seckillMaxDuration - elapsed
|
||
|
||
pendingAttempts := s.filterPendingAttempts(allAttempts, stoppedAccounts, stoppedProducts)
|
||
if len(pendingAttempts) == 0 {
|
||
logger.LogEvent("抢购", "info", "所有账号都已成功或遇到终止条件,停止抢购")
|
||
break
|
||
}
|
||
|
||
roundAttempts := pickRoundAttempts(pendingAttempts, round, settings.attempts)
|
||
if len(roundAttempts) == 0 {
|
||
logger.LogEvent("抢购", "info", "第%d轮无可执行账号,停止抢购", round)
|
||
break
|
||
}
|
||
|
||
now := time.Now()
|
||
roundAttempts, nextReady := filterCooldownAttempts(roundAttempts, cooldownAccounts, now)
|
||
if len(roundAttempts) == 0 {
|
||
sleepFor := nextReady.Sub(now)
|
||
if sleepFor <= 0 {
|
||
sleepFor = retryIntervalFor(settings.retryStages, time.Since(startTime))
|
||
}
|
||
if sleepFor > remaining {
|
||
sleepFor = remaining
|
||
}
|
||
if sleepFor > 0 {
|
||
logger.LogEvent("抢购", "info", "第%d轮账号冷却中,等待=%v", round, utils.RoundDuration(sleepFor))
|
||
if s.interruptibleSleep(sleepFor) {
|
||
logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
|
||
break
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
|
||
logger.LogEvent("抢购", "info", "第%d轮: 待抢购 %d 个账号,本轮执行=%d,剩余时间=%v,模式=%s",
|
||
round, uniqueAttemptAccounts(pendingAttempts), len(roundAttempts), utils.RoundDuration(remaining),
|
||
modeName(settings.seckillMode))
|
||
|
||
outcome := s.executeRound(roundAttempts, settings)
|
||
totalAttempts += outcome.attempts
|
||
|
||
for account := range outcome.successAccounts {
|
||
successAccounts[account] = true
|
||
stoppedAccounts[account] = "已抢到"
|
||
}
|
||
for account, reason := range outcome.stoppedAccounts {
|
||
if stoppedAccounts[account] == "" {
|
||
stoppedAccounts[account] = reason
|
||
logger.LogAccountEvent("抢购", "warn", account, "账号停止重试: 账号=%s 原因=%s", account, reason)
|
||
}
|
||
}
|
||
for product, reason := range outcome.stoppedProducts {
|
||
if stoppedProducts[product] == "" {
|
||
stoppedProducts[product] = reason
|
||
logger.LogEvent("抢购", "warn", "商品停止重试: %s,原因=%s", product, reason)
|
||
}
|
||
}
|
||
for account, until := range outcome.cooldownAccounts {
|
||
if until.After(cooldownAccounts[account]) {
|
||
cooldownAccounts[account] = until
|
||
cooldownCount[account]++
|
||
logger.LogAccountEvent("抢购", "warn", account, "账号临时降频: 账号=%s 冷却至=%s (第%d次)",
|
||
account, until.In(constants.CST).Format("15:04:05.000"), cooldownCount[account])
|
||
if cooldownCount[account] >= maxCooldownCount {
|
||
stoppedAccounts[account] = "冷却次数过多"
|
||
logger.LogAccountEvent("抢购", "warn", account, "账号停止重试: 账号=%s 原因=冷却次数过多(%d次)", account, cooldownCount[account])
|
||
}
|
||
}
|
||
}
|
||
|
||
if s.runStopped() {
|
||
logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
|
||
break
|
||
}
|
||
if len(outcome.successAccounts) > 0 {
|
||
logger.LogEvent("抢购", "success", "第%d轮成功: %d 个账号", round, len(outcome.successAccounts))
|
||
}
|
||
if len(stoppedAccounts) >= totalAccounts {
|
||
logger.LogEvent("抢购", "info", "所有账号都已成功或停止重试,停止抢购")
|
||
break
|
||
}
|
||
|
||
retryInterval := retryIntervalFor(settings.retryStages, time.Since(startTime))
|
||
if retryInterval > 0 && time.Since(startTime)+retryInterval < seckillMaxDuration {
|
||
if s.interruptibleSleep(jitterDuration(retryInterval)) {
|
||
logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
logger.LogEvent("抢购", "info", "抢购任务结束: 成功=%d/%d,停止=%d,账号尝试=%d,耗时=%v",
|
||
len(successAccounts), totalAccounts, len(stoppedAccounts)-len(successAccounts), totalAttempts, utils.RoundDuration(time.Since(startTime)))
|
||
}
|
||
|
||
// RunFixed 按固定轮数和轮间隔抢购,账号成功后不再参与后续轮次。
|
||
func (s *Scheduler) RunFixed(accountProducts map[string]string, attempts, delayMs int) {
|
||
defer s.finishRun()
|
||
s.ClearResults()
|
||
settings := s.settingsSnapshot()
|
||
if attempts < 1 {
|
||
attempts = 1
|
||
}
|
||
if attempts > 100000 {
|
||
attempts = 100000
|
||
}
|
||
if delayMs < 0 {
|
||
delayMs = 0
|
||
}
|
||
if delayMs > 60000 {
|
||
delayMs = 60000
|
||
}
|
||
|
||
accounts := make([]types.Account, 0, len(accountProducts))
|
||
for name, commodityID := range accountProducts {
|
||
cookie, ok := s.AccountCookieByName(name)
|
||
if !ok {
|
||
continue
|
||
}
|
||
product := s.productForCommodityID(commodityID)
|
||
if product.CommodityID == "" {
|
||
product.CommodityID = commodityID
|
||
}
|
||
accounts = append(accounts, types.Account{Name: name, Cookie: cookie})
|
||
}
|
||
if len(accounts) == 0 {
|
||
logger.LogEvent("抢购", "warn", "固定次数抢购没有可执行账号")
|
||
return
|
||
}
|
||
|
||
stopped := make(map[string]bool)
|
||
start := time.Now()
|
||
logger.LogEvent("抢购", "info", "固定次数抢购开始: %d 个账号,次数=%d,间隔=%dms", len(accounts), attempts, delayMs)
|
||
for round := 1; round <= attempts; round++ {
|
||
if s.runStopped() {
|
||
logger.LogEvent("抢购", "warn", "收到停止指令,固定次数抢购正在结束")
|
||
break
|
||
}
|
||
roundAttempts := make([]types.ExchangeAttempt, 0, len(accounts))
|
||
for _, account := range accounts {
|
||
if stopped[account.Name] {
|
||
continue
|
||
}
|
||
commodityID := accountProducts[account.Name]
|
||
product := s.productForCommodityID(commodityID)
|
||
if product.CommodityID == "" {
|
||
product.CommodityID = commodityID
|
||
}
|
||
roundAttempts = append(roundAttempts, types.ExchangeAttempt{Account: account, Product: product, Index: round})
|
||
}
|
||
if len(roundAttempts) == 0 {
|
||
break
|
||
}
|
||
if round > 1 && delayMs > 0 {
|
||
if s.interruptibleSleep(time.Duration(delayMs) * time.Millisecond) {
|
||
logger.LogEvent("抢购", "warn", "收到停止指令,固定次数抢购正在结束")
|
||
break
|
||
}
|
||
}
|
||
outcome := s.executeRound(roundAttempts, settings)
|
||
for name := range outcome.successAccounts {
|
||
stopped[name] = true
|
||
}
|
||
for name := range outcome.stoppedAccounts {
|
||
stopped[name] = true
|
||
}
|
||
logger.LogEvent("抢购", "info", "固定抢购第%d/%d轮完成: 成功=%d,剩余账号=%d", round, attempts, len(outcome.successAccounts), len(accounts)-len(stopped))
|
||
}
|
||
logger.LogEvent("抢购", "info", "固定次数抢购结束: 成功=%d/%d,耗时=%v", len(stopped), len(accounts), utils.RoundDuration(time.Since(start)))
|
||
}
|
||
|
||
// RunXiaodian 使用和平小店专用策略:固定次数、固定间隔、每轮直接调用 livebuy。
|
||
// 小店补货不依赖旧活动的 75 秒阶段窗口,也不执行手册的预检链路。
|
||
func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, delayMs int) {
|
||
defer s.finishRun()
|
||
s.ClearResults()
|
||
|
||
settings := s.settingsSnapshot()
|
||
attempts, delay := normalizeXiaodianRun(attempts, delayMs, settings.attempts)
|
||
accounts := make([]types.Account, 0, len(accountProducts))
|
||
for name := range accountProducts {
|
||
cookie, ok := s.AccountCookieByName(name)
|
||
if !ok {
|
||
continue
|
||
}
|
||
accounts = append(accounts, types.Account{Name: name, Cookie: cookie})
|
||
}
|
||
if len(accounts) == 0 {
|
||
logger.LogEvent("抢购", "warn", "和平小店抢购没有可执行账号")
|
||
return
|
||
}
|
||
|
||
stopped := make(map[string]bool)
|
||
start := time.Now()
|
||
logger.LogEvent("抢购", "info", "和平小店抢购开始: %d 个账号,次数=%d,间隔=%dms",
|
||
len(accounts), attempts, delay.Milliseconds())
|
||
|
||
for round := 1; round <= attempts; round++ {
|
||
if s.runStopped() {
|
||
logger.LogEvent("抢购", "warn", "收到停止指令,和平小店抢购正在结束")
|
||
break
|
||
}
|
||
if round > 1 && s.interruptibleSleep(delay) {
|
||
logger.LogEvent("抢购", "warn", "收到停止指令,和平小店抢购正在结束")
|
||
break
|
||
}
|
||
|
||
roundAttempts := make([]types.ExchangeAttempt, 0, len(accounts))
|
||
for _, account := range accounts {
|
||
if stopped[account.Name] {
|
||
continue
|
||
}
|
||
productID := accountProducts[account.Name]
|
||
product := s.productForCommodityID(productID)
|
||
if product.CommodityID == "" {
|
||
product.CommodityID = productID
|
||
}
|
||
roundAttempts = append(roundAttempts, types.ExchangeAttempt{
|
||
Account: account,
|
||
Product: product,
|
||
Index: round,
|
||
})
|
||
}
|
||
if len(roundAttempts) == 0 {
|
||
break
|
||
}
|
||
|
||
outcome := s.executeXiaodianRound(roundAttempts, settings)
|
||
for name := range outcome.successAccounts {
|
||
stopped[name] = true
|
||
}
|
||
for name := range outcome.stoppedAccounts {
|
||
stopped[name] = true
|
||
}
|
||
logger.LogEvent("抢购", "info", "和平小店第%d/%d轮完成: 成功=%d,停止=%d,剩余账号=%d",
|
||
round, attempts, len(outcome.successAccounts), len(outcome.stoppedAccounts), len(accounts)-len(stopped))
|
||
|
||
if len(stopped) >= len(accounts) {
|
||
break
|
||
}
|
||
}
|
||
|
||
logger.LogEvent("抢购", "info", "和平小店抢购结束: 成功或停止=%d/%d,耗时=%v",
|
||
len(stopped), len(accounts), utils.RoundDuration(time.Since(start)))
|
||
}
|
||
|
||
func normalizeXiaodianRun(attempts, delayMs, fallbackAttempts int) (int, time.Duration) {
|
||
if attempts <= 0 {
|
||
attempts = fallbackAttempts
|
||
}
|
||
if attempts <= 0 {
|
||
attempts = 3
|
||
}
|
||
if attempts > 100000 {
|
||
attempts = 100000
|
||
}
|
||
if delayMs < 0 {
|
||
delayMs = 0
|
||
}
|
||
if delayMs == 0 {
|
||
delayMs = int(xiaodianDefaultDelay / time.Millisecond)
|
||
}
|
||
if delayMs > 60000 {
|
||
delayMs = 60000
|
||
}
|
||
return attempts, time.Duration(delayMs) * time.Millisecond
|
||
}
|
||
|
||
// filterPendingAttempts 过滤掉已终止账号和已确认无库存的商品。
|
||
func (s *Scheduler) filterPendingAttempts(allAttempts []types.ExchangeAttempt, stoppedAccounts, stoppedProducts map[string]string) []types.ExchangeAttempt {
|
||
var pending []types.ExchangeAttempt
|
||
for _, attempt := range allAttempts {
|
||
if stoppedAccounts[attempt.Account.Name] != "" {
|
||
continue
|
||
}
|
||
if stoppedProducts[attempt.Product.CommodityID] != "" {
|
||
continue
|
||
}
|
||
pending = append(pending, attempt)
|
||
}
|
||
return pending
|
||
}
|
||
|
||
func filterCooldownAttempts(attempts []types.ExchangeAttempt, cooldownAccounts map[string]time.Time, now time.Time) ([]types.ExchangeAttempt, time.Time) {
|
||
out := make([]types.ExchangeAttempt, 0, len(attempts))
|
||
var nextReady time.Time
|
||
for _, attempt := range attempts {
|
||
until := cooldownAccounts[attempt.Account.Name]
|
||
if until.After(now) {
|
||
if nextReady.IsZero() || until.Before(nextReady) {
|
||
nextReady = until
|
||
}
|
||
continue
|
||
}
|
||
out = append(out, attempt)
|
||
}
|
||
return out, nextReady
|
||
}
|
||
|
||
// pickRoundAttempts 每轮每个账号最多执行一次,避免同账号并发过猛触发频控。
|
||
func pickRoundAttempts(attempts []types.ExchangeAttempt, round, attemptsPerAccount int) []types.ExchangeAttempt {
|
||
if len(attempts) == 0 {
|
||
return nil
|
||
}
|
||
preferredIndex := 1
|
||
if attemptsPerAccount > 0 {
|
||
preferredIndex = ((round - 1) % attemptsPerAccount) + 1
|
||
}
|
||
|
||
byAccount := make(map[string]types.ExchangeAttempt)
|
||
fallback := make(map[string]types.ExchangeAttempt)
|
||
order := make([]string, 0, len(attempts))
|
||
for _, attempt := range attempts {
|
||
name := attempt.Account.Name
|
||
if _, ok := fallback[name]; !ok {
|
||
fallback[name] = attempt
|
||
order = append(order, name)
|
||
}
|
||
if attempt.Index == preferredIndex {
|
||
byAccount[name] = attempt
|
||
}
|
||
}
|
||
|
||
out := make([]types.ExchangeAttempt, 0, len(order))
|
||
for _, name := range order {
|
||
if attempt, ok := byAccount[name]; ok {
|
||
out = append(out, attempt)
|
||
} else {
|
||
out = append(out, fallback[name])
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// executeRound 执行一轮抢购,并返回成功和终止重试的账号。
|
||
func (s *Scheduler) executeRound(attempts []types.ExchangeAttempt, settings runSettings) roundOutcome {
|
||
return s.executeRoundWithRunner(attempts, settings, nil)
|
||
}
|
||
|
||
func (s *Scheduler) executeXiaodianRound(attempts []types.ExchangeAttempt, settings runSettings) roundOutcome {
|
||
return s.executeRoundWithRunner(attempts, settings, s.runXiaodianAttempt)
|
||
}
|
||
|
||
func (s *Scheduler) executeRoundWithRunner(
|
||
attempts []types.ExchangeAttempt,
|
||
settings runSettings,
|
||
runner func(types.ExchangeAttempt) types.ExchangeResult,
|
||
) roundOutcome {
|
||
outcome := roundOutcome{
|
||
successAccounts: make(map[string]bool),
|
||
stoppedAccounts: make(map[string]string),
|
||
stoppedProducts: make(map[string]string),
|
||
cooldownAccounts: make(map[string]time.Time),
|
||
}
|
||
var mu sync.Mutex
|
||
|
||
limit := make(chan struct{}, settings.concurrency)
|
||
var wg sync.WaitGroup
|
||
|
||
for _, attempt := range attempts {
|
||
if s.runStopped() {
|
||
break
|
||
}
|
||
wg.Add(1)
|
||
go func(attempt types.ExchangeAttempt) {
|
||
defer wg.Done()
|
||
if s.runStopped() {
|
||
return
|
||
}
|
||
limit <- struct{}{}
|
||
defer func() { <-limit }()
|
||
if s.runStopped() {
|
||
return
|
||
}
|
||
|
||
var result types.ExchangeResult
|
||
if runner != nil {
|
||
result = runner(attempt)
|
||
} else if settings.seckillMode == types.SeckillModeAggressive {
|
||
result = s.runAttemptFast(attempt)
|
||
} else {
|
||
result = s.runAttempt(attempt)
|
||
}
|
||
|
||
key := fmt.Sprintf("%s_第%d包", result.Account, result.Attempt)
|
||
s.setResult(key, result.Message)
|
||
|
||
if result.OK {
|
||
logger.LogAccountEvent("抢购", "success", result.Account, "成功: 账号=%s 商品=%s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message)
|
||
s.enqueueSuccessImage(attempt.Account, attempt.Product, result.RoleName)
|
||
} else {
|
||
logger.LogAccountEvent("抢购", "info", result.Account, "尝试: 账号=%s 商品=%s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message)
|
||
}
|
||
|
||
mu.Lock()
|
||
outcome.attempts++
|
||
if result.OK {
|
||
outcome.successAccounts[attempt.Account.Name] = true
|
||
} else {
|
||
if cooldown := retryCooldownDuration(result.Message); cooldown > 0 {
|
||
outcome.cooldownAccounts[attempt.Account.Name] = time.Now().Add(jitterDuration(cooldown))
|
||
}
|
||
terminalAccount, terminalProduct, reason := classifyRetryStop(result.Message)
|
||
if runner != nil {
|
||
terminalAccount, terminalProduct, reason = classifyXiaodianRetryStop(result.Message)
|
||
}
|
||
if terminalAccount {
|
||
outcome.stoppedAccounts[attempt.Account.Name] = reason
|
||
}
|
||
if terminalProduct {
|
||
outcome.stoppedProducts[attempt.Product.CommodityID] = reason
|
||
}
|
||
}
|
||
mu.Unlock()
|
||
}(attempt)
|
||
}
|
||
|
||
wg.Wait()
|
||
return outcome
|
||
}
|
||
|
||
// ==================== 辅助函数 ====================
|
||
|
||
// modeName 返回抢购模式名称。
|
||
func modeName(mode types.SeckillMode) string {
|
||
if mode == types.SeckillModeAggressive {
|
||
return "激进"
|
||
}
|
||
return "标准"
|
||
}
|
||
|
||
// retryIntervalFor 根据配置的重试阶段计算当前应该使用的重试间隔。
|
||
func retryIntervalFor(retryStages []types.RetryStage, elapsed time.Duration) time.Duration {
|
||
elapsedSec := int(elapsed.Seconds())
|
||
|
||
// 使用配置的阶段
|
||
for _, stage := range retryStages {
|
||
if stage.MaxElapsed > 0 && elapsedSec < stage.MaxElapsed && stage.Interval > 0 {
|
||
return time.Duration(stage.Interval) * time.Millisecond
|
||
}
|
||
}
|
||
|
||
// 默认值(如果没有配置或配置无效)
|
||
return 900 * time.Millisecond
|
||
}
|
||
|
||
func jitterDuration(base time.Duration) time.Duration {
|
||
if base <= 0 {
|
||
return 0
|
||
}
|
||
jitter := time.Duration(rand.Int63n(int64(base / 10)))
|
||
return base + jitter
|
||
}
|
||
|
||
func retryCooldownDuration(message string) time.Duration {
|
||
switch {
|
||
case strings.Contains(message, "用户行为异常"):
|
||
return behaviorCooldown
|
||
case strings.Contains(message, "点击太快"):
|
||
return clickFastCooldown
|
||
case strings.Contains(message, "操作频繁"):
|
||
return behaviorCooldown
|
||
case strings.Contains(message, "请求过多"):
|
||
return behaviorCooldown
|
||
case strings.Contains(message, "请求太频繁"):
|
||
return behaviorCooldown
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func classifyRetryStop(message string) (account bool, product bool, reason string) {
|
||
switch {
|
||
case strings.Contains(message, "商品本次活动库存已耗完"):
|
||
// 库存耗尽不停止,继续重试(斗鱼可能会补货)
|
||
return false, false, ""
|
||
case strings.Contains(message, "您已达本次活动兑换上限"):
|
||
return true, false, "已达兑换上限"
|
||
case strings.Contains(message, "未绑定账号"):
|
||
return true, false, "账号未绑定角色"
|
||
case strings.Contains(message, "需要开通精英手册"):
|
||
return true, false, "账号不满足精英手册条件"
|
||
default:
|
||
return false, false, ""
|
||
}
|
||
}
|
||
|
||
func classifyXiaodianRetryStop(message string) (account bool, product bool, reason string) {
|
||
switch {
|
||
case strings.Contains(message, "已拥有该道具"),
|
||
strings.Contains(message, "已经拥有该道具"),
|
||
strings.Contains(message, "已拥有该商品"):
|
||
return true, false, "账号已拥有该道具"
|
||
case strings.Contains(message, "已达限量"),
|
||
strings.Contains(message, "已达本次活动兑换上限"),
|
||
strings.Contains(message, "兑换上限"):
|
||
return true, false, "已达兑换限量"
|
||
case strings.Contains(message, "未绑定账号"):
|
||
return true, false, "账号未绑定角色"
|
||
case strings.Contains(message, "商品不存在"),
|
||
strings.Contains(message, "商品已下架"),
|
||
strings.Contains(message, "不支持该商品"):
|
||
return false, true, "商品不可兑换"
|
||
default:
|
||
return false, false, ""
|
||
}
|
||
}
|
||
|
||
// recordCalibrationOffset 记录校准偏移,保留最近N次用于中位数平滑。
|
||
func (s *Scheduler) recordCalibrationOffset(offset time.Duration) {
|
||
s.calibrationMu.Lock()
|
||
defer s.calibrationMu.Unlock()
|
||
s.calibrationOffsets = append(s.calibrationOffsets, offset)
|
||
if len(s.calibrationOffsets) > calibrationHistorySize {
|
||
s.calibrationOffsets = s.calibrationOffsets[len(s.calibrationOffsets)-calibrationHistorySize:]
|
||
}
|
||
}
|
||
|
||
// smoothedOffset 返回最近校准偏移的中位数,过滤跳变异常。
|
||
func (s *Scheduler) smoothedOffset() time.Duration {
|
||
s.calibrationMu.Lock()
|
||
defer s.calibrationMu.Unlock()
|
||
if len(s.calibrationOffsets) == 0 {
|
||
return 0
|
||
}
|
||
sorted := append([]time.Duration(nil), s.calibrationOffsets...)
|
||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||
return sorted[len(sorted)/2]
|
||
}
|
||
|
||
// dynamicAdvance 根据校准误差边界和 RTT 计算动态提前量,最少 50ms。
|
||
func dynamicAdvance(calibration types.TimeCalibration) time.Duration {
|
||
advance := calibration.ErrorBound + calibration.RTT/2
|
||
if advance < minInternalAdvance {
|
||
advance = minInternalAdvance
|
||
}
|
||
return advance
|
||
}
|
||
|
||
func uniqueAttemptAccounts(attempts []types.ExchangeAttempt) int {
|
||
seen := make(map[string]struct{}, len(attempts))
|
||
for _, attempt := range attempts {
|
||
seen[attempt.Account.Name] = struct{}{}
|
||
}
|
||
return len(seen)
|
||
}
|
||
|
||
func (s *Scheduler) buildAttempts(accountProducts map[string]string, attemptsPerAccount int) []types.ExchangeAttempt {
|
||
if attemptsPerAccount <= 0 {
|
||
attemptsPerAccount = constants.DefaultAttempts
|
||
}
|
||
accounts := s.AccountSnapshot()
|
||
var attempts []types.ExchangeAttempt
|
||
for _, account := range accounts {
|
||
commodityID := accountProducts[account.Name]
|
||
if commodityID == "" {
|
||
continue
|
||
}
|
||
product := s.productForCommodityID(commodityID)
|
||
for i := 1; i <= attemptsPerAccount; i++ {
|
||
attempts = append(attempts, types.ExchangeAttempt{
|
||
Account: account,
|
||
Product: product,
|
||
Index: i,
|
||
})
|
||
}
|
||
}
|
||
return attempts
|
||
}
|
||
|
||
func (s *Scheduler) productForCommodityID(commodityID string) types.Product {
|
||
s.productsMu.RLock()
|
||
product, ok := s.products[commodityID]
|
||
s.productsMu.RUnlock()
|
||
if ok {
|
||
if product.CommodityName == "" {
|
||
product.CommodityName = commodityID
|
||
}
|
||
return product
|
||
}
|
||
return types.Product{CommodityID: commodityID, CommodityName: commodityID}
|
||
}
|
||
|
||
func (s *Scheduler) enqueueSuccessImage(account types.Account, product types.Product, roleName string) {
|
||
if s.successImages == nil {
|
||
return
|
||
}
|
||
s.successImages.Enqueue(account, product, roleName)
|
||
}
|