Files
dygou_go/internal/seckill/scheduler.go
T
yml2213 ebbc8f2d5c 优化: 兑换上限视为成功
- classifyRetryStop 添加 success 返回值
- 兑换上限(2019)现在算作抢购成功,而非失败
- 前端状态显示:兑换上限显示为「已抢到」
- 设置弹窗说明更新

以日志为例:成功率从 0.6% 提升到 39.4%(63/160)
2026-06-26 11:00:07 +08:00

782 lines
24 KiB
Go

package seckill
import (
"bufio"
"errors"
"fmt"
"math/rand"
"os"
"path/filepath"
"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
timeCalibrateRounds = 3
timeCalibrateWait = 1200 * time.Millisecond
resyncBeforeTrigger = 10 * time.Second
seckillMaxDuration = 75 * time.Second
)
type roundOutcome struct {
successAccounts map[string]bool
stoppedAccounts map[string]string
stoppedProducts map[string]string
attempts int
}
// Scheduler 抢购调度器。
type Scheduler struct {
client *client.DouyuClient
paths types.AppPaths
font *truetype.Font
concurrency int // 抢购并发数
attempts int // 单号重试次数
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
warmupsMu sync.RWMutex
warmups map[string]types.WarmupData
}
// New 创建抢购调度器。
func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, concurrency, attempts int) *Scheduler {
if concurrency <= 0 {
concurrency = constants.DefaultConcurrency
}
if attempts <= 0 {
attempts = constants.DefaultAttempts
}
return &Scheduler{
client: cli,
paths: paths,
font: font,
concurrency: concurrency,
attempts: attempts,
results: make(map[string]string),
}
}
// UpdateSettings 运行时更新并发数和重试次数。
func (s *Scheduler) UpdateSettings(concurrency, attempts int) {
if concurrency > 0 {
s.concurrency = concurrency
}
if attempts > 0 {
s.attempts = attempts
}
}
// 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)
}
}
hint := "# 格式: 账号名----密码----Cookie\n# 每行一个账号,粘贴后保存即可\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)
}
}
f, err := os.Open(path)
if err != nil {
logger.LogEvent("账号", "error", "读取账号文件失败: %s: %v", path, err)
s.accountsMu.Lock()
s.accounts = nil
s.accountsMu.Unlock()
return
}
defer f.Close()
var list []types.Account
scanner := bufio.NewScanner(f)
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)
}
// 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()
allAttempts := s.buildAttempts(accountProducts)
if len(allAttempts) == 0 {
logger.LogEvent("抢购", "warn", "没有可执行的抢购任务")
return
}
successAccounts := make(map[string]bool)
stoppedAccounts := make(map[string]string)
stoppedProducts := make(map[string]string)
totalAccounts := uniqueAttemptAccounts(allAttempts)
totalAttempts := 0
startTime := time.Now()
round := 0
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 := s.pickRoundAttempts(pendingAttempts, round)
if len(roundAttempts) == 0 {
logger.LogEvent("抢购", "info", "第%d轮无可执行账号,停止抢购", round)
break
}
logger.LogEvent("抢购", "info", "第%d轮: 待抢购 %d 个账号,本轮执行=%d,剩余时间=%v",
round, uniqueAttemptAccounts(pendingAttempts), len(roundAttempts), utils.RoundDuration(remaining))
outcome := s.executeRound(roundAttempts)
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
}
}
for product, reason := range outcome.stoppedProducts {
if stoppedProducts[product] == "" {
stoppedProducts[product] = reason
logger.LogEvent("抢购", "warn", "商品停止重试: %s,原因=%s", product, reason)
}
}
if len(outcome.successAccounts) > 0 {
logger.LogEvent("抢购", "success", "第%d轮成功: %d 个账号", round, len(outcome.successAccounts))
}
if len(stoppedAccounts) >= totalAccounts {
logger.LogEvent("抢购", "info", "所有账号都已成功或停止重试,停止抢购")
break
}
retryInterval := seckillRetryInterval(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
}
// pickRoundAttempts 每轮每个账号最多执行一次,避免同账号并发过猛触发频控。
func (s *Scheduler) pickRoundAttempts(attempts []types.ExchangeAttempt, round int) []types.ExchangeAttempt {
if len(attempts) == 0 {
return nil
}
preferredIndex := 1
if s.attempts > 0 {
preferredIndex = ((round - 1) % s.attempts) + 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) roundOutcome {
outcome := roundOutcome{
successAccounts: make(map[string]bool),
stoppedAccounts: make(map[string]string),
stoppedProducts: make(map[string]string),
}
var mu sync.Mutex
limit := make(chan struct{}, s.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 }()
result := s.runAttempt(attempt)
key := fmt.Sprintf("%s_第%d包", result.Account, result.Attempt)
s.setResult(key, result.Message)
mu.Lock()
outcome.attempts++
mu.Unlock()
if result.OK {
logger.LogEvent("抢购", "success", "成功: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message)
go image.SaveSuccessImage(attempt.Account, attempt.Product, result.RoleName, s.font, s.paths.SuccessDir)
mu.Lock()
outcome.successAccounts[attempt.Account.Name] = true
mu.Unlock()
} else {
logger.LogEvent("抢购", "info", "尝试: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message)
terminalAccount, terminalProduct, isSuccess, reason := classifyRetryStop(result.Message)
if terminalAccount || terminalProduct {
mu.Lock()
if terminalAccount {
outcome.stoppedAccounts[attempt.Account.Name] = reason
}
if terminalProduct {
outcome.stoppedProducts[attempt.Product.CommodityID] = reason
}
// 兑换上限也算成功
if isSuccess {
outcome.successAccounts[attempt.Account.Name] = true
logger.LogEvent("抢购", "success", "成功(兑换上限): %s %s 第%d包", result.Account, result.Product, result.Attempt)
go image.SaveSuccessImage(attempt.Account, attempt.Product, result.RoleName, s.font, s.paths.SuccessDir)
}
mu.Unlock()
}
}
}(attempt)
}
wg.Wait()
return outcome
}
func seckillRetryInterval(elapsed time.Duration) time.Duration {
switch {
case elapsed < 8*time.Second:
return 180 * time.Millisecond
case elapsed < 25*time.Second:
return 350 * time.Millisecond
default:
return 900 * time.Millisecond
}
}
func jitterDuration(base time.Duration) time.Duration {
if base <= 0 {
return 0
}
// 加一点抖动,避免所有机器严格同频请求。
jitter := time.Duration(rand.Int63n(int64(base / 3)))
return base + jitter
}
func classifyRetryStop(message string) (account bool, product bool, success bool, reason string) {
switch {
case strings.Contains(message, "商品本次活动库存已耗完"):
return true, true, false, "商品库存已耗完"
case strings.Contains(message, "您已达本次活动兑换上限"):
return true, false, true, "已抢到(兑换上限)"
case strings.Contains(message, "未绑定账号"):
return true, false, false, "账号未绑定角色"
case strings.Contains(message, "需要开通精英手册"):
return true, false, false, "账号不满足精英手册条件"
default:
return false, 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) []types.ExchangeAttempt {
accounts := s.AccountSnapshot()
var attempts []types.ExchangeAttempt
for _, account := range accounts {
commodityID := accountProducts[account.Name]
if commodityID == "" {
continue
}
product := types.Product{CommodityID: commodityID, CommodityName: commodityID}
for i := 1; i <= s.attempts; i++ {
attempts = append(attempts, types.ExchangeAttempt{
Account: account,
Product: product,
Index: i,
})
}
}
return attempts
}
func warmupKey(attempt types.ExchangeAttempt) string {
return fmt.Sprintf("%s|%s|%d", attempt.Account.Name, attempt.Product.CommodityID, attempt.Index)
}
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) warmupAttempts(attempts []types.ExchangeAttempt) {
if len(attempts) == 0 {
return
}
s.clearWarmups()
logger.LogEvent("预热", "info", "开始预热: %d 个请求", len(attempts))
limit := make(chan struct{}, s.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.LogEvent("预热", "warn", "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.LogEvent("预热", "warn", "角色预热失败: 账号=%s err=%v", attempt.Account.Name, err)
}
s.setWarmup(warmupKey(attempt), types.WarmupData{
CSRF: csrf,
RoleName: roleName,
CreatedAt: time.Now(),
})
logger.LogEvent("预热", "success", "预热完成: 账号=%s 商品=%s 耗时=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(start)))
}(attempt)
}
wg.Wait()
logger.LogEvent("预热", "info", "预热任务结束")
}
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.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.LogEvent("抢购", "info", "执行兑换链路: 账号=%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.LogEvent("抢购", "info", "使用预热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.LogEvent("抢购", "info", "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.LogEvent("抢购", "info", "批量限制通过: 账号=%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
}
exchangeInfo, err := s.client.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
if err != nil {
result.Message = "失败|" + err.Error()
return result
}
if roleName == "" {
roleName, err = s.client.RoleName(attempt.Account.Cookie)
if err != nil {
logger.LogEvent("抢购", "warn", "%s 角色名查询失败: %v", attempt.Account.Name, err)
}
}
result.OK = true
result.RoleName = roleName
if roleName != "" {
result.Message = "成功|" + roleName + "|" + exchangeInfo
} else {
result.Message = "成功|" + exchangeInfo
}
return result
}
// 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()
advance := time.Duration(req.Advance) * time.Millisecond
triggerTime := targetTime.Add(-advance)
remain := triggerTime.Sub(serverTime)
if remain <= 0 {
return nil, errors.New("目标时间已过")
}
ap := utils.CloneStringMap(req.AccountProducts)
attempts := s.buildAttempts(ap)
s.clearWarmups()
s.scheduleMu.Lock()
s.stopSchedulesLocked()
s.scheduleMu.Unlock()
s.scheduleTimers(ap, attempts, 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, attempts, 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"),
req.Advance,
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.000"),
"advance": req.Advance,
"remainSecond": int(remain.Seconds()),
"serverTime": serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
"offsetMs": calibration.Offset.Milliseconds(),
"errorBoundMs": calibration.ErrorBound.Milliseconds(),
"rttMs": calibration.RTT.Milliseconds(),
}, nil
}
func (s *Scheduler) scheduleTimers(accountProducts map[string]string, attempts []types.ExchangeAttempt, 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()
s.replaceTimerLocked(&s.warmupSchedule, warmupRemain, func() {
logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000"))
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
}