Files
dygou_go/internal/seckill/scheduler.go
T

1029 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}