1217 lines
37 KiB
Go
1217 lines
37 KiB
Go
package seckill
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"math/rand"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"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 = 10 * time.Second
|
||
seckillMaxDuration = 75 * time.Second
|
||
behaviorCooldown = 5 * time.Second
|
||
clickFastCooldown = 3 * time.Second
|
||
internalAdvance = 50 * time.Millisecond
|
||
maxCooldownCount = 5
|
||
)
|
||
|
||
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 抢购调度器。
|
||
type Scheduler struct {
|
||
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 // 抢购模式
|
||
|
||
accountsMu sync.RWMutex
|
||
accounts []types.Account
|
||
|
||
resultsMu sync.RWMutex
|
||
results map[string]string
|
||
|
||
runMu sync.Mutex
|
||
running bool
|
||
|
||
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
|
||
}
|
||
|
||
// 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,
|
||
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),
|
||
}
|
||
}
|
||
|
||
// Close 关闭调度器的后台资源。
|
||
func (s *Scheduler) Close() {
|
||
if s.successImages != nil {
|
||
s.successImages.Close()
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// LoadAccounts 从账号文件加载账号。
|
||
func (s *Scheduler) LoadAccounts() {
|
||
path := s.paths.AccountFile
|
||
if path == "" {
|
||
path = constants.DefaultAccountFile
|
||
}
|
||
|
||
// 文件不存在时自动创建目录和空文件,方便用户直接粘贴 Cookie
|
||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||
if mkErr := os.MkdirAll(dir, 0755); mkErr != nil {
|
||
logger.LogEvent("账号", "warn", "账号目录创建失败: %s: %v", dir, mkErr)
|
||
}
|
||
}
|
||
// 使用 Windows 换行格式 \r\n
|
||
hint := "# 格式: 账号名----密码----Cookie\r\n# 每行一个账号,粘贴后保存即可\r\n"
|
||
if wrErr := os.WriteFile(path, []byte(hint), 0644); wrErr != nil {
|
||
logger.LogEvent("账号", "warn", "账号文件创建失败: %s: %v", path, wrErr)
|
||
} else {
|
||
logger.LogEvent("账号", "info", "已创建空账号文件: %s,请粘贴 Cookie 后重新加载", path)
|
||
}
|
||
}
|
||
|
||
// 读取文件内容,自动转换换行格式为 Windows 格式
|
||
content, err := os.ReadFile(path)
|
||
if err != nil {
|
||
logger.LogEvent("账号", "error", "读取账号文件失败: %s: %v", path, err)
|
||
s.accountsMu.Lock()
|
||
s.accounts = nil
|
||
s.accountsMu.Unlock()
|
||
return
|
||
}
|
||
|
||
// 检测并转换换行格式
|
||
normalized, converted := s.normalizeLineEndings(path, content)
|
||
if converted {
|
||
// 保存转换后的内容
|
||
if err := os.WriteFile(path, normalized, 0644); err != nil {
|
||
logger.LogEvent("账号", "warn", "账号文件换行格式转换失败: %s: %v", path, err)
|
||
} else {
|
||
logger.LogEvent("账号", "info", "账号文件已自动转换为 Windows 换行格式: %s", path)
|
||
}
|
||
content = normalized
|
||
}
|
||
|
||
var list []types.Account
|
||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
parts := strings.SplitN(line, "----", 3)
|
||
if len(parts) != 3 {
|
||
logger.LogEvent("账号", "warn", "账号行格式错误,已跳过: %s", line)
|
||
continue
|
||
}
|
||
list = append(list, types.Account{
|
||
Name: strings.TrimSpace(parts[0]),
|
||
Password: strings.TrimSpace(parts[1]),
|
||
Cookie: strings.TrimSpace(parts[2]),
|
||
})
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
logger.LogEvent("账号", "error", "读取账号文件中断: %v", err)
|
||
}
|
||
|
||
s.accountsMu.Lock()
|
||
s.accounts = list
|
||
s.accountsMu.Unlock()
|
||
logger.LogEvent("账号", "success", "账号加载完成: %d 个,来源=%s", len(list), path)
|
||
}
|
||
|
||
// AccountCount 返回当前账号数。
|
||
func (s *Scheduler) AccountCount() int {
|
||
s.accountsMu.RLock()
|
||
defer s.accountsMu.RUnlock()
|
||
return len(s.accounts)
|
||
}
|
||
|
||
// normalizeLineEndings 检测并转换换行格式为 Windows 格式(\r\n)。
|
||
// 返回转换后的内容和是否进行了转换。
|
||
func (s *Scheduler) normalizeLineEndings(path string, content []byte) ([]byte, bool) {
|
||
// 检查是否已经是 Windows 格式(包含 \r\n)
|
||
if bytes.Contains(content, []byte("\r\n")) {
|
||
// 已经是 Windows 格式,检查是否还有单独的 \n
|
||
// 先将所有 \r\n 替换为占位符,检查是否还有单独的 \n
|
||
temp := bytes.ReplaceAll(content, []byte("\r\n"), []byte("__RN_PLACEHOLDER__"))
|
||
if !bytes.Contains(temp, []byte("\n")) && !bytes.Contains(temp, []byte("\r")) {
|
||
// 完全是 Windows 格式,不需要转换
|
||
return content, false
|
||
}
|
||
// 混合格式,需要统一转换
|
||
}
|
||
|
||
// 转换逻辑:先统一为 \n,再转换为 \r\n
|
||
normalized := content
|
||
// 1. 将 \r\n 替换为 \n
|
||
normalized = bytes.ReplaceAll(normalized, []byte("\r\n"), []byte("\n"))
|
||
// 2. 将单独的 \r 替换为 \n
|
||
normalized = bytes.ReplaceAll(normalized, []byte("\r"), []byte("\n"))
|
||
// 3. 将 \n 替换为 \r\n
|
||
normalized = bytes.ReplaceAll(normalized, []byte("\n"), []byte("\r\n"))
|
||
|
||
return normalized, true
|
||
}
|
||
|
||
// AccountFilePath 返回账号文件路径。
|
||
func (s *Scheduler) AccountFilePath() string {
|
||
return s.paths.AccountFile
|
||
}
|
||
|
||
// AccountSnapshot 复制一份账号列表。
|
||
func (s *Scheduler) AccountSnapshot() []types.Account {
|
||
s.accountsMu.RLock()
|
||
defer s.accountsMu.RUnlock()
|
||
list := make([]types.Account, len(s.accounts))
|
||
copy(list, s.accounts)
|
||
return list
|
||
}
|
||
|
||
// AccountCookieByName 按账号名查找 Cookie。
|
||
func (s *Scheduler) AccountCookieByName(name string) (string, bool) {
|
||
s.accountsMu.RLock()
|
||
defer s.accountsMu.RUnlock()
|
||
for _, account := range s.accounts {
|
||
if account.Name == name {
|
||
return account.Cookie, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// 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()
|
||
defer s.runMu.Unlock()
|
||
if s.running {
|
||
return false
|
||
}
|
||
s.running = true
|
||
return true
|
||
}
|
||
|
||
func (s *Scheduler) finishRun() {
|
||
s.runMu.Lock()
|
||
defer s.runMu.Unlock()
|
||
s.running = false
|
||
}
|
||
|
||
// 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 {
|
||
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))
|
||
time.Sleep(sleepFor)
|
||
}
|
||
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 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 {
|
||
time.Sleep(jitterDuration(retryInterval))
|
||
}
|
||
}
|
||
|
||
logger.LogEvent("抢购", "info", "抢购任务结束: 成功=%d/%d,停止=%d,账号尝试=%d,耗时=%v",
|
||
len(successAccounts), totalAccounts, len(stoppedAccounts)-len(successAccounts), totalAttempts, utils.RoundDuration(time.Since(startTime)))
|
||
}
|
||
|
||
// 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 {
|
||
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 {
|
||
wg.Add(1)
|
||
go func(attempt types.ExchangeAttempt) {
|
||
defer wg.Done()
|
||
limit <- struct{}{}
|
||
defer func() { <-limit }()
|
||
|
||
// 根据模式选择执行方法
|
||
var result types.ExchangeResult
|
||
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 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 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)
|
||
}
|
||
|
||
func warmupKey(attempt types.ExchangeAttempt) string {
|
||
return fmt.Sprintf("%s|%s", attempt.Account.Name, attempt.Product.CommodityID)
|
||
}
|
||
|
||
func (s *Scheduler) setWarmup(key string, data types.WarmupData) {
|
||
s.warmupsMu.Lock()
|
||
defer s.warmupsMu.Unlock()
|
||
if s.warmups == nil {
|
||
s.warmups = make(map[string]types.WarmupData)
|
||
}
|
||
s.warmups[key] = data
|
||
}
|
||
|
||
func (s *Scheduler) warmupFor(attempt types.ExchangeAttempt) (types.WarmupData, bool) {
|
||
s.warmupsMu.RLock()
|
||
data, ok := s.warmups[warmupKey(attempt)]
|
||
s.warmupsMu.RUnlock()
|
||
if !ok || data.CreatedAt.IsZero() || time.Since(data.CreatedAt) > warmupMaxAge {
|
||
return types.WarmupData{}, false
|
||
}
|
||
return data, true
|
||
}
|
||
|
||
func (s *Scheduler) clearWarmups() {
|
||
s.warmupsMu.Lock()
|
||
defer s.warmupsMu.Unlock()
|
||
s.warmups = make(map[string]types.WarmupData)
|
||
}
|
||
|
||
// ========== 激进模式缓存管理 ==========
|
||
|
||
func (s *Scheduler) getAttemptCache(accountName string) *types.AttemptCache {
|
||
s.attemptCacheMu.RLock()
|
||
defer s.attemptCacheMu.RUnlock()
|
||
return s.attemptCache[accountName]
|
||
}
|
||
|
||
// initCacheFromWarmup 从预热数据初始化激进模式缓存。
|
||
func (s *Scheduler) initCacheFromWarmup(attempts []types.ExchangeAttempt) {
|
||
for _, attempt := range attempts {
|
||
if warmup, ok := s.warmupFor(attempt); ok {
|
||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||
CSRF: warmup.CSRF,
|
||
RoleName: warmup.RoleName,
|
||
Qualified: warmup.Qualified,
|
||
LastCheck: time.Now(),
|
||
})
|
||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "从预热初始化缓存: 账号=%s", attempt.Account.Name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Scheduler) setAttemptCache(accountName string, cache *types.AttemptCache) {
|
||
s.attemptCacheMu.Lock()
|
||
defer s.attemptCacheMu.Unlock()
|
||
if cache == nil {
|
||
delete(s.attemptCache, accountName)
|
||
return
|
||
}
|
||
s.attemptCache[accountName] = cache
|
||
}
|
||
|
||
func (s *Scheduler) clearAttemptCache() {
|
||
s.attemptCacheMu.Lock()
|
||
defer s.attemptCacheMu.Unlock()
|
||
s.attemptCache = make(map[string]*types.AttemptCache)
|
||
}
|
||
|
||
func (s *Scheduler) warmupAttempts(attempts []types.ExchangeAttempt) {
|
||
s.warmupRunMu.Lock()
|
||
defer s.warmupRunMu.Unlock()
|
||
|
||
attempts = uniqueWarmupAttempts(attempts)
|
||
if len(attempts) == 0 {
|
||
return
|
||
}
|
||
s.clearWarmups()
|
||
logger.LogEvent("预热", "info", "开始预热: %d 个请求", len(attempts))
|
||
|
||
settings := s.settingsSnapshot()
|
||
limit := make(chan struct{}, settings.concurrency)
|
||
var wg sync.WaitGroup
|
||
for _, attempt := range attempts {
|
||
wg.Add(1)
|
||
go func(attempt types.ExchangeAttempt) {
|
||
defer wg.Done()
|
||
limit <- struct{}{}
|
||
defer func() { <-limit }()
|
||
|
||
start := time.Now()
|
||
csrf, err := s.client.GenerateCSRF(attempt.Account.Cookie)
|
||
if err != nil {
|
||
logger.LogAccountEvent("预热", "warn", attempt.Account.Name, "CSRF预热失败: 账号=%s 商品=%s err=%v", attempt.Account.Name, attempt.Product.CommodityName, err)
|
||
return
|
||
}
|
||
roleName, err := s.client.RoleName(attempt.Account.Cookie)
|
||
if err != nil {
|
||
logger.LogAccountEvent("预热", "warn", attempt.Account.Name, "角色预热失败: 账号=%s err=%v", attempt.Account.Name, err)
|
||
}
|
||
s.setWarmup(warmupKey(attempt), types.WarmupData{
|
||
CSRF: csrf,
|
||
RoleName: roleName,
|
||
Qualified: true,
|
||
CreatedAt: time.Now(),
|
||
})
|
||
logger.LogAccountEvent("预热", "success", attempt.Account.Name, "预热完成: 账号=%s 商品=%s 耗时=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(start)))
|
||
}(attempt)
|
||
}
|
||
wg.Wait()
|
||
logger.LogEvent("预热", "info", "预热任务结束")
|
||
}
|
||
|
||
func uniqueWarmupAttempts(attempts []types.ExchangeAttempt) []types.ExchangeAttempt {
|
||
if len(attempts) <= 1 {
|
||
return attempts
|
||
}
|
||
seen := make(map[string]struct{}, len(attempts))
|
||
out := make([]types.ExchangeAttempt, 0, len(attempts))
|
||
for _, attempt := range attempts {
|
||
key := warmupKey(attempt)
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
out = append(out, attempt)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *Scheduler) calibrateDouyuTime(label string) types.TimeCalibration {
|
||
calibration, err := s.client.BestServerTimeCalibration(timeCalibrateRounds, timeCalibrateWait)
|
||
if err != nil {
|
||
logger.LogEvent("时间", "warn", "%s校准降级: %v", label, err)
|
||
}
|
||
logger.SetDouyuOffset(calibration.Offset)
|
||
logger.LogEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s",
|
||
label,
|
||
calibration.EstimatedServerNow().In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||
calibration.Offset.Milliseconds(),
|
||
calibration.ErrorBound.Milliseconds(),
|
||
calibration.RTT.Milliseconds(),
|
||
calibration.Samples,
|
||
calibration.Source)
|
||
return calibration
|
||
}
|
||
|
||
func (s *Scheduler) replaceTimerLocked(timer **time.Timer, d time.Duration, fn func()) {
|
||
if *timer != nil {
|
||
(*timer).Stop()
|
||
}
|
||
*timer = time.AfterFunc(d, fn)
|
||
}
|
||
|
||
func (s *Scheduler) stopSchedulesLocked() {
|
||
if s.schedule != nil {
|
||
s.schedule.Stop()
|
||
s.schedule = nil
|
||
}
|
||
if s.warmupSchedule != nil {
|
||
s.warmupSchedule.Stop()
|
||
s.warmupSchedule = nil
|
||
}
|
||
if s.resyncSchedule != nil {
|
||
s.resyncSchedule.Stop()
|
||
s.resyncSchedule = nil
|
||
}
|
||
}
|
||
|
||
func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||
result := types.ExchangeResult{
|
||
Account: attempt.Account.Name,
|
||
Product: attempt.Product.CommodityName,
|
||
Attempt: attempt.Index,
|
||
}
|
||
|
||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "执行资格预检链路: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
|
||
var roleName string
|
||
csrf := ""
|
||
if warmup, ok := s.warmupFor(attempt); ok {
|
||
csrf = warmup.CSRF
|
||
roleName = warmup.RoleName
|
||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "使用预热CSRF: 账号=%s 商品=%s 预热距今=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(warmup.CreatedAt)))
|
||
}
|
||
if csrf == "" {
|
||
var err error
|
||
csrf, err = s.client.GenerateCSRF(attempt.Account.Cookie)
|
||
if err != nil {
|
||
result.Message = "失败|生成CSRF失败: " + err.Error()
|
||
return result
|
||
}
|
||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "CSRF已生成,本次兑换链路复用同一个 token: 账号=%s 商品=%s", attempt.Account.Name, attempt.Product.CommodityName)
|
||
}
|
||
|
||
limit, err := s.client.BatchExchangeLimit(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
|
||
if err != nil {
|
||
result.Message = "失败|批量限制检查失败: " + err.Error()
|
||
return result
|
||
}
|
||
if limit <= 0 {
|
||
result.Message = "失败|当前商品限制兑换"
|
||
return result
|
||
}
|
||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "批量限制通过: 账号=%s 商品=%s limit=%d", attempt.Account.Name, attempt.Product.CommodityName, limit)
|
||
|
||
if err := s.client.PreExchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf); err != nil {
|
||
result.Message = "失败|预兑换失败: " + err.Error()
|
||
return result
|
||
}
|
||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||
CSRF: csrf,
|
||
RoleName: roleName,
|
||
Qualified: true,
|
||
LastCheck: time.Now(),
|
||
})
|
||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "资格预检通过: 账号=%s 商品=%s,后续可直兑", attempt.Account.Name, attempt.Product.CommodityName)
|
||
|
||
exchangeInfo, err := s.client.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
|
||
if err != nil {
|
||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||
CSRF: csrf,
|
||
RoleName: roleName,
|
||
Qualified: true,
|
||
LastCheck: time.Now(),
|
||
})
|
||
result.RoleName = roleName
|
||
result.Message = "失败|" + err.Error()
|
||
return result
|
||
}
|
||
|
||
if roleName == "" {
|
||
roleName, err = s.client.RoleName(attempt.Account.Cookie)
|
||
if err != nil {
|
||
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name, "角色名查询失败: 账号=%s err=%v", attempt.Account.Name, err)
|
||
}
|
||
}
|
||
|
||
result.OK = true
|
||
result.RoleName = roleName
|
||
if roleName != "" {
|
||
result.Message = "成功|" + roleName + "|" + exchangeInfo
|
||
} else {
|
||
result.Message = "成功|" + exchangeInfo
|
||
}
|
||
|
||
// 缓存 CSRF 和角色名(激进模式使用)
|
||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||
CSRF: csrf,
|
||
RoleName: roleName,
|
||
Qualified: true,
|
||
LastCheck: time.Now(),
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
// runAttemptFast 激进模式的兑换逻辑,复用缓存的 CSRF,跳过 BatchExchangeLimit 和 PreExchange。
|
||
func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||
result := types.ExchangeResult{
|
||
Account: attempt.Account.Name,
|
||
Product: attempt.Product.CommodityName,
|
||
Attempt: attempt.Index,
|
||
}
|
||
|
||
cache := s.getAttemptCache(attempt.Account.Name)
|
||
if cache == nil || cache.CSRF == "" || time.Since(cache.LastCheck) >= qualifiedMaxAge {
|
||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "激进模式执行资格预检: 账号=%s 原因=无缓存CSRF", attempt.Account.Name)
|
||
return s.runAttempt(attempt)
|
||
}
|
||
if !cache.Qualified {
|
||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "激进模式执行资格预检: 账号=%s 原因=未完成资格预检", attempt.Account.Name)
|
||
return s.runAttempt(attempt)
|
||
}
|
||
|
||
csrf := cache.CSRF
|
||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "激进模式直兑: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
|
||
|
||
// 直接调用 Exchange
|
||
exchangeInfo, err := s.client.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
|
||
if err != nil {
|
||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||
CSRF: csrf,
|
||
RoleName: cache.RoleName,
|
||
Qualified: true,
|
||
LastCheck: time.Now(),
|
||
})
|
||
result.RoleName = cache.RoleName
|
||
errMsg := err.Error()
|
||
if shouldFallbackStandard(errMsg) {
|
||
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name, "激进模式回退标准: 账号=%s 原因=%s", attempt.Account.Name, utils.TrimForLog(errMsg, 80))
|
||
s.setAttemptCache(attempt.Account.Name, nil) // 清空缓存
|
||
return s.runAttempt(attempt)
|
||
}
|
||
result.Message = "失败|" + errMsg
|
||
return result
|
||
}
|
||
|
||
// 获取角色名
|
||
roleName := ""
|
||
if cache != nil {
|
||
roleName = cache.RoleName
|
||
}
|
||
if roleName == "" {
|
||
var err error
|
||
roleName, err = s.client.RoleName(attempt.Account.Cookie)
|
||
if err != nil {
|
||
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name, "角色名查询失败: 账号=%s err=%v", attempt.Account.Name, err)
|
||
}
|
||
}
|
||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||
CSRF: csrf,
|
||
RoleName: roleName,
|
||
Qualified: true,
|
||
LastCheck: time.Now(),
|
||
})
|
||
|
||
result.OK = true
|
||
result.RoleName = roleName
|
||
if roleName != "" {
|
||
result.Message = "成功|" + roleName + "|" + exchangeInfo
|
||
} else {
|
||
result.Message = "成功|" + exchangeInfo
|
||
}
|
||
return result
|
||
}
|
||
|
||
func shouldFallbackStandard(errMsg string) bool {
|
||
lower := strings.ToLower(errMsg)
|
||
switch {
|
||
case strings.Contains(lower, "csrf"):
|
||
return true
|
||
case strings.Contains(lower, "cvl_csrf_token"):
|
||
return true
|
||
case strings.Contains(lower, "preexchange"):
|
||
return true
|
||
case strings.Contains(lower, "limit"):
|
||
return true
|
||
case strings.Contains(errMsg, "预兑换"):
|
||
return true
|
||
case strings.Contains(errMsg, "限制检查"):
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// ScheduleRun 注册一次定时抢购。
|
||
func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error) {
|
||
if len(req.AccountProducts) == 0 {
|
||
return nil, errors.New("请选择账号和商品")
|
||
}
|
||
if strings.TrimSpace(req.TargetTime) == "" {
|
||
return nil, errors.New("请设置目标时间")
|
||
}
|
||
|
||
targetLocal, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, constants.CST)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("时间格式错误: %w", err)
|
||
}
|
||
targetTime := targetLocal.UTC()
|
||
|
||
calibration := s.calibrateDouyuTime("定时")
|
||
serverTime := calibration.EstimatedServerNow()
|
||
|
||
triggerTime := targetTime.Add(-internalAdvance)
|
||
remain := triggerTime.Sub(serverTime)
|
||
if remain <= 0 {
|
||
return nil, errors.New("目标时间已过")
|
||
}
|
||
|
||
ap := utils.CloneStringMap(req.AccountProducts)
|
||
s.scheduleMu.Lock()
|
||
s.stopSchedulesLocked()
|
||
s.clearWarmups()
|
||
s.scheduleMu.Unlock()
|
||
|
||
s.scheduleTimers(ap, targetTime, triggerTime, calibration, false)
|
||
|
||
resyncRemain := triggerTime.Sub(serverTime) - resyncBeforeTrigger
|
||
if resyncRemain < 0 {
|
||
resyncRemain = 0
|
||
}
|
||
|
||
s.scheduleMu.Lock()
|
||
s.replaceTimerLocked(&s.resyncSchedule, resyncRemain, func() {
|
||
newCalibration := s.calibrateDouyuTime("临近")
|
||
s.scheduleTimers(ap, targetTime, triggerTime, newCalibration, true)
|
||
})
|
||
s.scheduleMu.Unlock()
|
||
|
||
warmupRemain := remain - warmupBeforeTrigger
|
||
if warmupRemain < 0 {
|
||
warmupRemain = 0
|
||
}
|
||
|
||
logger.LogEvent("定时", "success", "定时已设置: 目标=%s 触发=%s 提前=%dms 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s 复校=%s",
|
||
targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||
triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||
internalAdvance.Milliseconds(),
|
||
serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||
calibration.Offset.Milliseconds(),
|
||
calibration.ErrorBound.Milliseconds(),
|
||
calibration.RTT.Milliseconds(),
|
||
time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000"),
|
||
time.Now().Add(resyncRemain).In(constants.CST).Format("15:04:05.000"),
|
||
)
|
||
|
||
return map[string]any{
|
||
"success": true,
|
||
"targetTime": targetTime.In(constants.CST).Format("2006-01-02 15:04:05"),
|
||
"triggerTime": triggerTime.In(constants.CST).Format("2006-01-02 15:04:05"),
|
||
"offsetMs": calibration.Offset.Milliseconds(),
|
||
"errorBoundMs": calibration.ErrorBound.Milliseconds(),
|
||
"rttMs": calibration.RTT.Milliseconds(),
|
||
}, nil
|
||
}
|
||
|
||
func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime, triggerTime time.Time, calibration types.TimeCalibration, resync bool) {
|
||
serverTime := calibration.EstimatedServerNow()
|
||
remain := triggerTime.Sub(serverTime)
|
||
if remain < 0 {
|
||
remain = 0
|
||
}
|
||
warmupRemain := remain - warmupBeforeTrigger
|
||
if warmupRemain < 0 {
|
||
warmupRemain = 0
|
||
}
|
||
|
||
s.scheduleMu.Lock()
|
||
defer s.scheduleMu.Unlock()
|
||
gen := atomic.AddInt64(&s.warmupGen, 1)
|
||
s.replaceTimerLocked(&s.warmupSchedule, warmupRemain, func() {
|
||
if atomic.LoadInt64(&s.warmupGen) != gen {
|
||
return
|
||
}
|
||
logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000"))
|
||
settings := s.settingsSnapshot()
|
||
attempts := s.buildAttempts(accountProducts, settings.attempts)
|
||
s.warmupAttempts(attempts)
|
||
})
|
||
s.replaceTimerLocked(&s.schedule, remain, func() {
|
||
now := time.Now()
|
||
estimatedServer := now.Add(calibration.Offset)
|
||
logger.LogEvent("定时", "success", "定时触发: 本地=%s 斗鱼估算=%s 偏移=%dms 误差<=%dms",
|
||
now.In(constants.CST).Format("15:04:05.000"),
|
||
estimatedServer.In(constants.CST).Format("15:04:05.000"),
|
||
calibration.Offset.Milliseconds(),
|
||
calibration.ErrorBound.Milliseconds())
|
||
if !s.TryStartRun() {
|
||
logger.LogEvent("抢购", "warn", "已有抢购任务正在运行,本次定时触发已跳过")
|
||
return
|
||
}
|
||
s.RunNow(accountProducts)
|
||
})
|
||
if resync {
|
||
logger.LogEvent("定时", "success", "临近复校已应用: 目标=%s 触发=%s 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s",
|
||
targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||
triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||
serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||
calibration.Offset.Milliseconds(),
|
||
calibration.ErrorBound.Milliseconds(),
|
||
calibration.RTT.Milliseconds(),
|
||
time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000"))
|
||
}
|
||
}
|
||
|
||
// CancelSchedule 停止尚未触发的定时任务。
|
||
func (s *Scheduler) CancelSchedule() bool {
|
||
s.scheduleMu.Lock()
|
||
defer s.scheduleMu.Unlock()
|
||
stopped := false
|
||
if s.schedule != nil {
|
||
stopped = s.schedule.Stop()
|
||
}
|
||
s.stopSchedulesLocked()
|
||
s.clearWarmups()
|
||
return stopped
|
||
}
|