- classifyRetryStop 添加 success 返回值 - 兑换上限(2019)现在算作抢购成功,而非失败 - 前端状态显示:兑换上限显示为「已抢到」 - 设置弹窗说明更新 以日志为例:成功率从 0.6% 提升到 39.4%(63/160)
782 lines
24 KiB
Go
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
|
|
}
|