381 lines
12 KiB
Go
381 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
warmupBeforeTrigger = 2 * time.Second
|
|
warmupMaxAge = 30 * time.Second
|
|
timeCalibrateRounds = 3
|
|
timeCalibrateWait = 1200 * time.Millisecond
|
|
resyncBeforeTrigger = 10 * time.Second
|
|
)
|
|
|
|
// runNow 立即执行一批抢购任务。
|
|
func (s *appState) runNow(accountProducts map[string]string) {
|
|
s.clearResults()
|
|
attempts := s.buildAttempts(accountProducts)
|
|
if len(attempts) == 0 {
|
|
logEvent("抢购", "warn", "没有可执行的抢购任务")
|
|
return
|
|
}
|
|
|
|
logEvent("抢购", "info", "开始抢购: %d 个请求", len(attempts))
|
|
limit := make(chan struct{}, defaultConcurrency)
|
|
var wg sync.WaitGroup
|
|
for _, attempt := range attempts {
|
|
wg.Add(1)
|
|
go func(attempt 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)
|
|
if result.OK {
|
|
logEvent("抢购", "success", "成功: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message)
|
|
go s.saveSuccessImage(attempt.Account, attempt.Product, result.RoleName)
|
|
} else {
|
|
logEvent("抢购", "error", "失败: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message)
|
|
}
|
|
}(attempt)
|
|
}
|
|
wg.Wait()
|
|
logEvent("抢购", "info", "抢购任务结束")
|
|
}
|
|
|
|
// buildAttempts 根据前端传入的账号到商品映射,生成具体兑换尝试。
|
|
func (s *appState) buildAttempts(accountProducts map[string]string) []ExchangeAttempt {
|
|
accounts := s.accountSnapshot()
|
|
products, _ := s.productSnapshot()
|
|
productByID := make(map[string]Product, len(products))
|
|
for _, p := range products {
|
|
productByID[p.CommodityID] = p
|
|
}
|
|
|
|
var attempts []ExchangeAttempt
|
|
for _, account := range accounts {
|
|
commodityID := accountProducts[account.Name]
|
|
if commodityID == "" {
|
|
continue
|
|
}
|
|
product := productByID[commodityID]
|
|
if product.CommodityID == "" {
|
|
product = Product{CommodityID: commodityID, CommodityName: commodityID}
|
|
}
|
|
for i := 1; i <= defaultAttempts; i++ {
|
|
attempts = append(attempts, ExchangeAttempt{
|
|
Account: account,
|
|
Product: product,
|
|
Index: i,
|
|
})
|
|
}
|
|
}
|
|
return attempts
|
|
}
|
|
|
|
func warmupKey(attempt ExchangeAttempt) string {
|
|
return fmt.Sprintf("%s|%s|%d", attempt.Account.Name, attempt.Product.CommodityID, attempt.Index)
|
|
}
|
|
|
|
func (s *appState) setWarmup(key string, data WarmupData) {
|
|
s.warmupsMu.Lock()
|
|
defer s.warmupsMu.Unlock()
|
|
if s.warmups == nil {
|
|
s.warmups = make(map[string]WarmupData)
|
|
}
|
|
s.warmups[key] = data
|
|
}
|
|
|
|
func (s *appState) warmupFor(attempt ExchangeAttempt) (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 WarmupData{}, false
|
|
}
|
|
return data, true
|
|
}
|
|
|
|
func (s *appState) clearWarmups() {
|
|
s.warmupsMu.Lock()
|
|
defer s.warmupsMu.Unlock()
|
|
s.warmups = make(map[string]WarmupData)
|
|
}
|
|
|
|
func (s *appState) warmupAttempts(attempts []ExchangeAttempt) {
|
|
if len(attempts) == 0 {
|
|
return
|
|
}
|
|
s.clearWarmups()
|
|
logEvent("预热", "info", "开始预热: %d 个请求", len(attempts))
|
|
|
|
limit := make(chan struct{}, defaultConcurrency)
|
|
var wg sync.WaitGroup
|
|
for _, attempt := range attempts {
|
|
wg.Add(1)
|
|
go func(attempt ExchangeAttempt) {
|
|
defer wg.Done()
|
|
limit <- struct{}{}
|
|
defer func() { <-limit }()
|
|
|
|
start := time.Now()
|
|
csrf, err := s.client.GenerateCSRF(attempt.Account.Cookie)
|
|
if err != nil {
|
|
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 {
|
|
logEvent("预热", "warn", "角色预热失败: 账号=%s err=%v", attempt.Account.Name, err)
|
|
}
|
|
s.setWarmup(warmupKey(attempt), WarmupData{
|
|
CSRF: csrf,
|
|
RoleName: roleName,
|
|
CreatedAt: time.Now(),
|
|
})
|
|
logEvent("预热", "success", "预热完成: 账号=%s 商品=%s 耗时=%s", attempt.Account.Name, attempt.Product.CommodityName, roundDuration(time.Since(start)))
|
|
}(attempt)
|
|
}
|
|
wg.Wait()
|
|
logEvent("预热", "info", "预热任务结束")
|
|
}
|
|
|
|
func (s *appState) calibrateDouyuTime(label string) TimeCalibration {
|
|
calibration, err := s.client.BestServerTimeCalibration(timeCalibrateRounds, timeCalibrateWait)
|
|
if err != nil {
|
|
logEvent("时间", "warn", "%s校准降级: %v", label, err)
|
|
}
|
|
logEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s",
|
|
label,
|
|
calibration.EstimatedServerNow().In(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 *appState) replaceTimerLocked(timer **time.Timer, d time.Duration, fn func()) {
|
|
if *timer != nil {
|
|
(*timer).Stop()
|
|
}
|
|
*timer = time.AfterFunc(d, fn)
|
|
}
|
|
|
|
func (s *appState) 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
|
|
}
|
|
}
|
|
|
|
// runAttempt 按抓包链路执行一次兑换:CSRF -> 限制 -> 预兑换 -> 正式兑换。
|
|
// 同一次抢购尝试只生成一个 CSRF,后续接口共用,减少临门请求耗时。
|
|
func (s *appState) runAttempt(attempt ExchangeAttempt) ExchangeResult {
|
|
result := ExchangeResult{
|
|
Account: attempt.Account.Name,
|
|
Product: attempt.Product.CommodityName,
|
|
Attempt: attempt.Index,
|
|
}
|
|
|
|
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
|
|
logEvent("抢购", "info", "使用预热CSRF: 账号=%s 商品=%s 预热距今=%s", attempt.Account.Name, attempt.Product.CommodityName, 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
|
|
}
|
|
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
|
|
}
|
|
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 {
|
|
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 *appState) scheduleRun(req 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, 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 := 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
|
|
}
|
|
|
|
logEvent("定时", "success", "定时已设置: 目标=%s 触发=%s 提前=%dms 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s 复校=%s",
|
|
targetTime.In(cst).Format("2006-01-02 15:04:05.000"),
|
|
triggerTime.In(cst).Format("2006-01-02 15:04:05.000"),
|
|
req.Advance,
|
|
serverTime.In(cst).Format("2006-01-02 15:04:05.000"),
|
|
calibration.Offset.Milliseconds(),
|
|
calibration.ErrorBound.Milliseconds(),
|
|
calibration.RTT.Milliseconds(),
|
|
time.Now().Add(warmupRemain).In(cst).Format("15:04:05.000"),
|
|
time.Now().Add(resyncRemain).In(cst).Format("15:04:05.000"),
|
|
)
|
|
|
|
return map[string]any{
|
|
"success": true,
|
|
"targetTime": targetTime.In(cst).Format("2006-01-02 15:04:05"),
|
|
"triggerTime": triggerTime.In(cst).Format("2006-01-02 15:04:05.000"),
|
|
"advance": req.Advance,
|
|
"remainSecond": int(remain.Seconds()),
|
|
"serverTime": serverTime.In(cst).Format("2006-01-02 15:04:05.000"),
|
|
"offsetMs": calibration.Offset.Milliseconds(),
|
|
"errorBoundMs": calibration.ErrorBound.Milliseconds(),
|
|
"rttMs": calibration.RTT.Milliseconds(),
|
|
}, nil
|
|
}
|
|
|
|
func (s *appState) scheduleTimers(accountProducts map[string]string, attempts []ExchangeAttempt, targetTime, triggerTime time.Time, calibration 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() {
|
|
logEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(cst).Format("15:04:05.000"))
|
|
s.warmupAttempts(attempts)
|
|
})
|
|
s.replaceTimerLocked(&s.schedule, remain, func() {
|
|
now := time.Now()
|
|
estimatedServer := now.Add(calibration.Offset)
|
|
logEvent("定时", "success", "定时触发: 本地=%s 斗鱼估算=%s 偏移=%dms 误差<=%dms",
|
|
now.In(cst).Format("15:04:05.000"),
|
|
estimatedServer.In(cst).Format("15:04:05.000"),
|
|
calibration.Offset.Milliseconds(),
|
|
calibration.ErrorBound.Milliseconds())
|
|
s.runNow(accountProducts)
|
|
})
|
|
if resync {
|
|
logEvent("定时", "success", "临近复校已应用: 目标=%s 触发=%s 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s",
|
|
targetTime.In(cst).Format("2006-01-02 15:04:05.000"),
|
|
triggerTime.In(cst).Format("2006-01-02 15:04:05.000"),
|
|
serverTime.In(cst).Format("2006-01-02 15:04:05.000"),
|
|
calibration.Offset.Milliseconds(),
|
|
calibration.ErrorBound.Milliseconds(),
|
|
calibration.RTT.Milliseconds(),
|
|
time.Now().Add(warmupRemain).In(cst).Format("15:04:05.000"))
|
|
}
|
|
}
|
|
|
|
// cancelSchedule 停止尚未触发的定时任务。
|
|
func (s *appState) 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
|
|
}
|