From 6e5beac151d658dd51f1208399d9a14c8b87e932 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 17 Oct 2026 18:29:17 +0800 Subject: [PATCH] =?UTF-8?q?refactor(sniper):=20=E5=AF=B9=E9=BD=90=20dygou?= =?UTF-8?q?=5Fgo=20=E8=B0=83=E5=BA=A6=E6=9E=B6=E6=9E=84=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=8F=8C=E6=97=B6=E9=97=B4=E6=97=A5=E5=BF=97=E5=B9=B6?= =?UTF-8?q?=E8=A7=A3=E8=80=A6=E9=A2=84=E7=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抢购调度重构:参考 dygou_go 将 sniper 拆分为独立子模块(runner/run_control/retry/exchange/classify/config/summary/category/helpers);移除全局自适应降速与轮次上限,采用账号独立循环、统一账号级退避、单请求并发闸门与首发错峰 - 预热与建连优化:默认关闭预热(避免单账号超时连坐整机开抢),开启时转为后台非阻塞协程;WebSocket 握手超时从 10s 降至 1500ms - 双时间日志:logger 增加 SetServerOffset,校准后日志前缀自动渲染「本地时间(H虎牙估算时间)」,实现跨机器绝对毫秒对齐 - 默认配置调优:版本提升至 v0.5.0,默认线程调为 2(缓解多实例并发限流),前端自动平滑迁移 - 验证方式:go test ./... -count=1 全通,npm run build 构建正常,单测覆盖双时间与预热配置 --- frontend/src/App.tsx | 83 +- frontend/wailsjs/go/models.ts | 108 +- internal/gui/app.go | 96 +- internal/gui/app_test.go | 16 +- internal/huya/wss_client.go | 2 +- internal/logger/entry.go | 31 +- internal/logger/logger_test.go | 36 + internal/sniper/category.go | 87 ++ internal/sniper/classify.go | 191 +++ internal/sniper/config.go | 88 ++ internal/sniper/exchange.go | 222 +++ internal/sniper/helpers.go | 57 + internal/sniper/optimization_test.go | 217 +-- internal/sniper/retry.go | 187 +++ internal/sniper/run_control.go | 462 +++++++ internal/sniper/runner.go | 368 +++++ internal/sniper/sniper.go | 1860 +------------------------- internal/sniper/sniper_test.go | 48 +- internal/sniper/summary.go | 53 + internal/version/version.go | 6 +- wails.json | 2 +- 21 files changed, 2142 insertions(+), 2078 deletions(-) create mode 100644 internal/sniper/category.go create mode 100644 internal/sniper/classify.go create mode 100644 internal/sniper/config.go create mode 100644 internal/sniper/exchange.go create mode 100644 internal/sniper/helpers.go create mode 100644 internal/sniper/retry.go create mode 100644 internal/sniper/run_control.go create mode 100644 internal/sniper/runner.go create mode 100644 internal/sniper/summary.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a7225a..c4ecc6c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,8 +61,12 @@ const configStorageBase = 'huya-go.gui.config.v2' const cookieStorageBase = 'huya-go.gui.cookies.v1' const prizeCacheStorageBase = 'huya-go.gui.prizes.v1' const leadDefaultMigrationBase = 'huya-go.gui.lead-default.v1' -// 旧版本默认把提前量写成 2500ms,与现在的动态提前设计冲突;只迁移一次。 +// 旧版本默认把提前量写成 2500ms、线程写成 5。轮次上限已从产品移除; +// 线程 5 在多开时会放大请求洪峰,旧默认只迁移一次。 const legacyDefaultLeadMs = 2500 +const concurrencyDefaultMigrationBase = 'huya-go.gui.concurrency-pressure.v1' +const legacyDefaultConcurrency = 5 +const defaultConcurrency = 2 // 界面本地存储保留的日志上限,与后端的增量拉取配合使用。 const maxClientLogs = 1000 @@ -102,8 +106,6 @@ type UiConfig = { minLeft: number cookieText: string selectedAccountLines: number[] - // loops 是轮次上限:0(留空)表示按窗口运行到窗口结束。 - loops: number delayMs: number concurrency: number // windowSeconds 是抢购窗口秒数(窗口模式下跑到「开抢+窗口」才收工)。 @@ -122,10 +124,6 @@ type UiConfig = { errorBackoffMs: number timeoutSec: number startAt: string - // adaptiveThrottle 是「全局自适应降速」开关(表单用正语义)。 - adaptiveThrottle: boolean - // disableAdaptiveThrottle 只发给后端(后端零值=开启,所以用反语义字段),不落本地存储。 - disableAdaptiveThrottle: boolean } type CookieCheckStatus = 'valid' | 'expired' | 'unknown' | 'invalid' @@ -331,7 +329,7 @@ function exchangeOrderID(exchange?: ExchangeResp): string { return exchange?.order_id || exchange?.orderId || '' } -type SavedConfig = Omit +type SavedConfig = Omit type ScheduleCountdown = { targetAt: number @@ -535,14 +533,47 @@ function readSavedConfig(): Partial { try { const configKey = scopedKey(configStorageBase) const saved = JSON.parse(localStorage.getItem(configKey) || '{}') as Partial - const migrationKey = scopedKey(leadDefaultMigrationBase) - if (!localStorage.getItem(migrationKey)) { + let dirty = false + + // v0.5.0 移除了轮次和全局降速器配置,顺手清掉本地旧字段。 + const legacyFields = saved as Record + for (const key of ['loops', 'adaptiveThrottle', 'disableAdaptiveThrottle']) { + if (key in legacyFields) { + delete legacyFields[key] + dirty = true + } + } + + const leadMigrationKey = scopedKey(leadDefaultMigrationBase) + if (!localStorage.getItem(leadMigrationKey)) { if (Number(saved.leadMs || 0) === legacyDefaultLeadMs) { saved.leadMs = 0 - // 迁移必须立即落盘,否则下一次 readSavedConfig 又会读回旧值。 - localStorage.setItem(configKey, JSON.stringify(saved)) + dirty = true } - localStorage.setItem(migrationKey, '1') + localStorage.setItem(leadMigrationKey, '1') + } + + const concurrencyMigrationKey = scopedKey(concurrencyDefaultMigrationBase) + if (!localStorage.getItem(concurrencyMigrationKey)) { + if (Number(saved.concurrency || 0) === legacyDefaultConcurrency) { + saved.concurrency = defaultConcurrency + dirty = true + } + localStorage.setItem(concurrencyMigrationKey, '1') + } + + const prewarmMigrationKey = scopedKey('huya_gui_prewarm_default_migration_v050') + if (!localStorage.getItem(prewarmMigrationKey)) { + if (saved.prewarm !== false) { + saved.prewarm = false + dirty = true + } + localStorage.setItem(prewarmMigrationKey, '1') + } + + if (dirty) { + // 迁移必须立即落盘,否则下一次 readSavedConfig 又会读回旧值。 + localStorage.setItem(configKey, JSON.stringify(saved)) } return saved } catch { @@ -656,13 +687,12 @@ function pickConfig(values: Partial): UiConfig { minLeft: Number(values.minLeft || 0), cookieText: values.cookieText || '', selectedAccountLines: values.selectedAccountLines || [], - loops: Number(values.loops || 0), delayMs: Number(values.delayMs || 150), - concurrency: Number(values.concurrency || 1), + concurrency: Number(values.concurrency || defaultConcurrency), windowSeconds: Number(values.windowSeconds || 30), stopAfterSuccess: Boolean(values.stopAfterSuccess), stopAccountAfterSuccess: values.stopAccountAfterSuccess !== false, - prewarm: values.prewarm !== false, + prewarm: Boolean(values.prewarm), directExchange: values.directExchange !== false, sharePrizeList: values.sharePrizeList !== false, autoSelect: Boolean(values.autoSelect), @@ -673,8 +703,6 @@ function pickConfig(values: Partial): UiConfig { errorBackoffMs: Number(values.errorBackoffMs || 300), timeoutSec: Number(values.timeoutSec || 15), startAt: values.startAt || '', - adaptiveThrottle: values.adaptiveThrottle !== false, - disableAdaptiveThrottle: values.adaptiveThrottle === false, } } @@ -1340,7 +1368,6 @@ export default function Dashboard() { targetText: values.targetText, maxPrice: values.maxPrice, minLeft: values.minLeft, - loops: values.loops, delayMs: values.delayMs, concurrency: values.concurrency, windowSeconds: values.windowSeconds, @@ -1357,7 +1384,6 @@ export default function Dashboard() { errorBackoffMs: values.errorBackoffMs, timeoutSec: values.timeoutSec, startAt: values.startAt, - adaptiveThrottle: values.adaptiveThrottle, } localStorage.setItem(scopedKey(configStorageBase), JSON.stringify(saved)) message.success('已保存') @@ -2267,16 +2293,13 @@ export default function Dashboard() { - - - - + - + @@ -2284,7 +2307,12 @@ export default function Dashboard() {
- + @@ -2305,9 +2333,6 @@ export default function Dashboard() { - - -
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 7c34d05..6c2c0c5 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,15 +1,15 @@ export namespace gui { - + export class AccountPreview { line: number; uid: number; valid: boolean; error?: string; - + static createFrom(source: any = {}) { return new AccountPreview(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.line = source["line"]; @@ -30,7 +30,6 @@ export namespace gui { minLeft: number; cookieText: string; selectedAccountLines: number[]; - loops: number; delayMs: number; concurrency: number; stopAfterSuccess: boolean; @@ -47,12 +46,11 @@ export namespace gui { timeoutSec: number; startAt: string; windowSeconds: number; - disableAdaptiveThrottle: boolean; - + static createFrom(source: any = {}) { return new Config(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.transport = source["transport"]; @@ -66,7 +64,6 @@ export namespace gui { this.minLeft = source["minLeft"]; this.cookieText = source["cookieText"]; this.selectedAccountLines = source["selectedAccountLines"]; - this.loops = source["loops"]; this.delayMs = source["delayMs"]; this.concurrency = source["concurrency"]; this.stopAfterSuccess = source["stopAfterSuccess"]; @@ -83,7 +80,6 @@ export namespace gui { this.timeoutSec = source["timeoutSec"]; this.startAt = source["startAt"]; this.windowSeconds = source["windowSeconds"]; - this.disableAdaptiveThrottle = source["disableAdaptiveThrottle"]; } } export class CookieCheckResult { @@ -93,11 +89,11 @@ export namespace gui { message?: string; statusCode?: number; checkedAt: number; - + static createFrom(source: any = {}) { return new CookieCheckResult(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.line = source["line"]; @@ -114,11 +110,11 @@ export namespace gui { category?: string; message?: string; error?: string; - + static createFrom(source: any = {}) { return new ExchangeResult(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.uid = source["uid"]; @@ -127,7 +123,7 @@ export namespace gui { this.message = source["message"]; this.error = source["error"]; } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -152,11 +148,11 @@ export namespace gui { dataDir: string; logDir: string; logFile: string; - + static createFrom(source: any = {}) { return new InstanceInfo(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.name = source["name"]; @@ -172,11 +168,11 @@ export namespace gui { dir: string; dropped: number; count: number; - + static createFrom(source: any = {}) { return new LogStats(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.level = source["level"]; @@ -194,11 +190,11 @@ export namespace gui { categories: huya.PrizeTag[]; categoryStats: huya.CategoryStat[]; raw?: huya.GetActPrizeListResp; - + static createFrom(source: any = {}) { return new PrizeListResult(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.uid = source["uid"]; @@ -209,7 +205,7 @@ export namespace gui { this.categoryStats = this.convertValues(source["categoryStats"], huya.CategoryStat); this.raw = this.convertValues(source["raw"], huya.GetActPrizeListResp); } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -233,11 +229,11 @@ export namespace gui { records: huya.UserPrizeInfo[]; resp?: huya.GetUserPrizeRecordsResp; error?: string; - + static createFrom(source: any = {}) { return new RecordResult(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.uid = source["uid"]; @@ -245,7 +241,7 @@ export namespace gui { this.resp = this.convertValues(source["resp"], huya.GetUserPrizeRecordsResp); this.error = source["error"]; } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -269,11 +265,11 @@ export namespace gui { score: number; resp?: huya.GetUserScoreResp; error?: string; - + static createFrom(source: any = {}) { return new ScoreResult(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.uid = source["uid"]; @@ -281,7 +277,7 @@ export namespace gui { this.resp = this.convertValues(source["resp"], huya.GetUserScoreResp); this.error = source["error"]; } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -304,18 +300,18 @@ export namespace gui { } export namespace huya { - + export class ActPreCondition { condition_type: number; condition_params?: Record; tip?: string; jump_url?: string; button_desc?: string; - + static createFrom(source: any = {}) { return new ActPreCondition(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.condition_type = source["condition_type"]; @@ -331,11 +327,11 @@ export namespace huya { count: number; left: number; total: number; - + static createFrom(source: any = {}) { return new CategoryStat(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; @@ -349,11 +345,11 @@ export namespace huya { pid: number; exchange_score: number; desc: string; - + static createFrom(source: any = {}) { return new ExchangeInfo(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.pid = source["pid"]; @@ -365,11 +361,11 @@ export namespace huya { id: number; name: string; weight: number; - + static createFrom(source: any = {}) { return new PrizeTag(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; @@ -400,11 +396,11 @@ export namespace huya { end_time?: number; daily_start?: string; daily_end?: string; - + static createFrom(source: any = {}) { return new PrizeInfo(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; @@ -437,11 +433,11 @@ export namespace huya { prizes: PrizeInfo[]; module_id: number; tags: PrizeTag[]; - + static createFrom(source: any = {}) { return new GetActPrizeListResp(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.status = source["status"]; @@ -450,7 +446,7 @@ export namespace huya { this.module_id = source["module_id"]; this.tags = this.convertValues(source["tags"], PrizeTag); } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -488,11 +484,11 @@ export namespace huya { item_id?: number; ext?: Record; exchange_date?: number; - + static createFrom(source: any = {}) { return new UserPrizeInfo(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; @@ -519,18 +515,18 @@ export namespace huya { status: number; msg: string; records: UserPrizeInfo[]; - + static createFrom(source: any = {}) { return new GetUserPrizeRecordsResp(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.status = source["status"]; this.msg = source["msg"]; this.records = this.convertValues(source["records"], UserPrizeInfo); } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -558,11 +554,11 @@ export namespace huya { new_used_score: number; score: number; is_open_exchange_tip: number; - + static createFrom(source: any = {}) { return new GetUserScoreResp(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.status = source["status"]; @@ -575,19 +571,19 @@ export namespace huya { this.is_open_exchange_tip = source["is_open_exchange_tip"]; } } - - + + export class ScoreExchangePrizeResp { status: number; msg: string; order_id?: string; exchange_info: ExchangeInfo; act_pre_condition: ActPreCondition; - + static createFrom(source: any = {}) { return new ScoreExchangePrizeResp(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.status = source["status"]; @@ -596,7 +592,7 @@ export namespace huya { this.exchange_info = this.convertValues(source["exchange_info"], ExchangeInfo); this.act_pre_condition = this.convertValues(source["act_pre_condition"], ActPreCondition); } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -619,7 +615,7 @@ export namespace huya { } export namespace logger { - + export class Entry { id: number; time: string; @@ -627,11 +623,11 @@ export namespace logger { category: string; account: string; message: string; - + static createFrom(source: any = {}) { return new Entry(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; diff --git a/internal/gui/app.go b/internal/gui/app.go index ddd17ac..bc41e26 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -66,7 +66,6 @@ type Config struct { MinLeft int64 `json:"minLeft"` CookieText string `json:"cookieText"` SelectedAccountLines []int `json:"selectedAccountLines"` - Loops int `json:"loops"` DelayMS int `json:"delayMs"` Concurrency int `json:"concurrency"` StopAfterSuccess bool `json:"stopAfterSuccess"` @@ -83,12 +82,8 @@ type Config struct { ErrorBackoffMS int `json:"errorBackoffMs"` TimeoutSec int `json:"timeoutSec"` StartAt string `json:"startAt"` - // WindowSeconds 是抢购窗口长度(秒):窗口模式下跑到「开抢 + 窗口」才收工, - // 账号少时也不会像旧实现那样在开抢前把轮次烧光。<=0 时取默认 30 秒。 + // WindowSeconds 是抢购窗口长度(秒):跑到「开抢 + 窗口」才收工。 WindowSeconds int `json:"windowSeconds"` - // DisableAdaptiveThrottle 关闭全局自适应降速(默认开启,故用反向字段): - // 连续十几个请求都拿不到业务响应时整体降速,缓解同机同 IP 的限流风暴。 - DisableAdaptiveThrottle bool `json:"disableAdaptiveThrottle"` } type AccountPreview struct { @@ -302,15 +297,18 @@ func (a *App) Shutdown(ctx context.Context) { func (a *App) DefaultConfig() Config { return Config{ - Transport: "wss", - WSSHost: "", - SID: 2203, - Loops: 20, - DelayMS: 120, - Concurrency: 5, + Transport: "wss", + WSSHost: "", + SID: 2203, + DelayMS: 120, + // 线程是「同时在途请求数」,不是账号槽位;多开时同一台机器的总请求速率 + // 约为 线程 × 实例数 / 平均 RTT。2026-10-17 的 24 个任务用了旧默认 5, + // 峰值合集约 400~600 请求/秒、84% 被限流,所以默认压到 2,优先换取 + // 服务端真正受理请求的概率。账号很多时仍可在界面显式调高。 + Concurrency: 2, StopAfterSuccess: false, StopAccountAfterSuccess: true, - Prewarm: true, + Prewarm: false, DirectExchange: true, SharePrizeList: true, AutoSelect: false, @@ -505,9 +503,9 @@ func (a *App) StartMonitor(cfg Config) error { } a.emitState(true, "运行中") - a.addLog("info", "启动监控: sid=%d 类目=%s targets=%d 目标=%s accounts=%d prewarm=%v direct=%v 窗口=%s 自适应降速=%v", + a.addLog("info", "启动监控: sid=%d 类目=%s targets=%d 目标=%s accounts=%d prewarm=%v direct=%v 窗口=%s", cfg.SID, sniper.CategoryText(cfg.Category), len(targets), sniper.TargetSummary(targets), len(accounts), - cfg.Prewarm, cfg.DirectExchange, sniper.WindowDuration(cfg.WindowSeconds), !cfg.DisableAdaptiveThrottle) + cfg.Prewarm, cfg.DirectExchange, sniper.WindowDuration(cfg.WindowSeconds)) go func() { defer closeClient() @@ -548,23 +546,37 @@ func (a *App) StartMonitor(cfg Config) error { if err := a.waitFor(ctx, plan.ResyncAt, "复校"); err != nil { return } - calibration, calErr := huya.TryCalibrateServerTime(ctx, client, accounts[0].UID, accounts[0].Cookie, huya.DefaultTimeCalibrationRounds) + // 校准最多占用到触发时刻:服务器繁忙时 pingSignal 可能连续超时, + // 不能让它把真正的开抢时间整体拖后。 + calCtx, calCancel := context.WithDeadline(ctx, plan.TriggerAt) + calibration, calErr := huya.TryCalibrateServerTime(calCtx, client, accounts[0].UID, accounts[0].Cookie, huya.DefaultTimeCalibrationRounds) + calCancel() + if ctx.Err() != nil { + return + } if calErr != nil { + logger.SetServerOffset(0) a.addLog("warn", "时间校准失败,按本机时钟触发: %v", calErr) } else { plan = sniper.PlanTrigger(startAt, calibration, lead, window) + logger.SetServerOffset(calibration.Offset) a.addLog("info", "时间校准完成: %s", calibration.Summary()) } } if cfg.Prewarm { - if err := a.waitFor(ctx, plan.WarmupAt, "预热"); err != nil { - return - } - a.addLog("info", "开始预热连接: accounts=%d(距触发 %s)", len(accounts), time.Until(plan.TriggerAt).Round(time.Millisecond)) - if err := runner.Prewarm(ctx, accounts, runnerCfg, emit); err != nil { - a.addLog("error", "连接预热异常: %v", err) - return - } + // 预热在独立后台协程运行,绝不阻塞主发令枪: + // 无论预热成功与否、耗时多久,主定时器到达 TriggerAt 立即开抢。 + prewarmCtx, prewarmCancel := context.WithDeadline(ctx, plan.TriggerAt) + go func() { + defer prewarmCancel() + if err := a.waitFor(prewarmCtx, plan.WarmupAt, "预热"); err != nil { + return + } + a.addLog("info", "开始预热连接: accounts=%d(距触发 %s)", len(accounts), time.Until(plan.TriggerAt).Round(time.Millisecond)) + if err := runner.Prewarm(prewarmCtx, accounts, runnerCfg, emit); err != nil && prewarmCtx.Err() == nil { + a.addLog("warn", "预热未在触发前完成,继续抢购(未完成账号将在首次请求时建连): %v", err) + } + }() } runnerCfg.OpenAt = plan.OpenAt a.addLog("info", "等待定时开始: 触发=%s 开抢=%s 窗口结束=%s 提前=%dms 首发错峰=%dms", @@ -577,6 +589,9 @@ func (a *App) StartMonitor(cfg Config) error { a.addLog("info", "定时等待已取消") return } + if late := time.Since(plan.TriggerAt); late > 50*time.Millisecond { + a.addLog("warn", "实际触发比计划晚 %s(触发=%s)", late.Round(time.Millisecond), plan.TriggerAt.Format("15:04:05.000")) + } a.runMonitor(ctx, runner, accounts, targets, runnerCfg, emit) }() return nil @@ -618,7 +633,6 @@ func runnerConfig(cfg Config) sniper.Config { return sniper.Config{ SID: cfg.SID, Category: cfg.Category, - Loops: cfg.Loops, Delay: time.Duration(cfg.DelayMS) * time.Millisecond, Concurrency: cfg.Concurrency, StopAfterSuccess: cfg.StopAfterSuccess, @@ -632,9 +646,8 @@ func runnerConfig(cfg Config) sniper.Config { ErrorBackoff: time.Duration(cfg.ErrorBackoffMS) * time.Millisecond, WindowSeconds: cfg.WindowSeconds, // 首发错峰固定用调优过的默认值:同机上百个账号齐射是最容易被限流的姿势。 - FirstWaveSpread: sniper.DefaultFirstWaveSpread, - FirstWaveMaxStep: sniper.DefaultFirstWaveMaxStep, - DisableAdaptiveThrottle: cfg.DisableAdaptiveThrottle, + FirstWaveSpread: sniper.DefaultFirstWaveSpread, + FirstWaveMaxStep: sniper.DefaultFirstWaveMaxStep, } } @@ -652,10 +665,10 @@ func (a *App) logRunSummary(summary sniper.RunSummary) { if !summary.OpenAt.IsZero() { window = fmt.Sprintf(" 开抢=%s 窗口=%ds", summary.OpenAt.Format("15:04:05.000"), summary.WindowSeconds) } - a.addLog("info", "任务结束: 轮次=%d 账号=%d 目标=%d 成功账号=%d 耗时=%s 限流=%d/%d(%d%%) 降速=×%.1f 请求[列表=%d 兑换=%d 积分=%d 记录=%d]%s", - summary.Loops, summary.Accounts, summary.Targets, summary.SuccessUIDs, + a.addLog("info", "任务结束: 最大尝试=%d 账号=%d 目标=%d 成功账号=%d 耗时=%s 限流=%d/%d(%d%%) 请求[列表=%d 兑换=%d 积分=%d 记录=%d]%s", + summary.Attempts, summary.Accounts, summary.Targets, summary.SuccessUIDs, summary.Duration.Round(time.Millisecond), - throttled, total, percentOf(throttled, total), throttleFactorText(summary.ThrottleFactor), + throttled, total, percentOf(throttled, total), summary.ListReqs, summary.ExchangeReqs, summary.ScoreReqs, summary.RecordReqs, window) for _, category := range summary.Categories() { a.logEvent(categoryMonitor, sniper.LogLevelForCategory(category), "结局 %s ×%d(%s)", @@ -690,14 +703,6 @@ func percentOf(part, whole int) int { return part * 100 / whole } -// throttleFactorText 渲染自适应降速倍率。 -func throttleFactorText(factor float64) float64 { - if factor < 1 { - return 1 - } - return factor -} - func (a *App) StopMonitor() bool { a.runMu.Lock() cancel := a.monitorCancel @@ -774,10 +779,6 @@ func normalizeConfig(cfg Config) Config { cfg.Transport = "wss" } // WSSHost 留空表示走 launch.queryHttpDns 自动解析,不在这里补默认值。 - // Loops 只作为可选上限:0(留空)表示「按窗口运行」,由 WindowSeconds 兜底。 - if cfg.Loops < 0 { - cfg.Loops = 0 - } if cfg.DelayMS <= 0 { cfg.DelayMS = 150 } @@ -953,13 +954,20 @@ func nextClockTime(now time.Time, hour, minute, second int) time.Time { // waitUntil 等到指定时刻。用一次性定时器精确睡眠,避免旧实现 200ms 轮询带来的 // 0-200ms 随机延迟(抢购时这段时间正好是关键窗口)。 +// +// 长等待拆成最多 1 秒一段:部分 Windows 机器上几小时的单个定时器会被系统合并/延迟, +// 今天日志里就出现过“等待 2 小时后复校晚到 5~8 秒”的实例。 func waitUntil(ctx context.Context, when time.Time) error { for { remaining := time.Until(when) if remaining <= 0 { return nil } - timer := time.NewTimer(remaining) + chunk := remaining + if chunk > time.Second { + chunk = time.Second + } + timer := time.NewTimer(chunk) select { case <-timer.C: case <-ctx.Done(): diff --git a/internal/gui/app_test.go b/internal/gui/app_test.go index d603d40..2f97de2 100644 --- a/internal/gui/app_test.go +++ b/internal/gui/app_test.go @@ -68,9 +68,16 @@ func TestParseAccountsRequiresExplicitSelection(t *testing.T) { } } -func TestDefaultConfigUsesDynamicLead(t *testing.T) { - if got := (&App{}).DefaultConfig().LeadMS; got != 0 { - t.Fatalf("LeadMS=%d, want 0(动态提前量)", got) +func TestDefaultConfigUsesWindowMode(t *testing.T) { + cfg := (&App{}).DefaultConfig() + if cfg.LeadMS != 0 { + t.Fatalf("LeadMS=%d, want 0(动态提前量)", cfg.LeadMS) + } + if cfg.Concurrency != 2 { + t.Fatalf("Concurrency=%d, want 2(降低多开时的请求洪峰)", cfg.Concurrency) + } + if cfg.Prewarm { + t.Fatal("Prewarm 应默认关闭,避免开抢前因单账号建连超时阻塞") } } @@ -170,7 +177,6 @@ func TestParseTargetsWithoutFiltersKeepsZeroValues(t *testing.T) { func TestRunnerConfigMapsDirectExchangeToExchangeMode(t *testing.T) { direct := runnerConfig(Config{ SID: 2203, - Loops: 3, DelayMS: 120, Concurrency: 5, DirectExchange: true, @@ -178,7 +184,7 @@ func TestRunnerConfigMapsDirectExchangeToExchangeMode(t *testing.T) { if !direct.DirectExchange || direct.ListBeforeExchange { t.Fatalf("直接兑换模式映射错误: %+v", direct) } - if direct.SID != 2203 || direct.Loops != 3 || direct.Delay != 120*time.Millisecond || direct.Concurrency != 5 { + if direct.SID != 2203 || direct.Delay != 120*time.Millisecond || direct.Concurrency != 5 { t.Fatalf("基础字段未透传: %+v", direct) } diff --git a/internal/huya/wss_client.go b/internal/huya/wss_client.go index b27e0b5..11cfb21 100644 --- a/internal/huya/wss_client.go +++ b/internal/huya/wss_client.go @@ -369,7 +369,7 @@ func (c *WSSClient) dialSession(ctx context.Context, uid int64, cookie string) ( dialer := c.Dialer if dialer == nil { dialer = &websocket.Dialer{ - HandshakeTimeout: 10 * time.Second, + HandshakeTimeout: 1500 * time.Millisecond, EnableCompression: true, Proxy: http.ProxyFromEnvironment, } diff --git a/internal/logger/entry.go b/internal/logger/entry.go index 29ee13f..c19a23e 100644 --- a/internal/logger/entry.go +++ b/internal/logger/entry.go @@ -43,13 +43,27 @@ type Entry struct { at time.Time } -var minLevel atomic.Int32 +var ( + minLevel atomic.Int32 + serverOffset atomic.Int64 +) var ( emitterMu sync.RWMutex emitter func(Entry) ) +// SetServerOffset 设置服务端时间与本地时间的偏移量(服务端时间 - 本机时间), +// 用于日志双时间显示(本地时间 + 估算虎牙时间)。 +func SetServerOffset(offset time.Duration) { + serverOffset.Store(int64(offset)) +} + +// ServerOffset 返回当前设置的服务端时间偏移量。 +func ServerOffset() time.Duration { + return time.Duration(serverOffset.Load()) +} + // SetMinLevel 设置日志最低输出等级,低于该等级的日志(例如逐条网络请求的 debug) // 不会进入内存缓冲,也不会落盘。 func SetMinLevel(level string) { @@ -132,8 +146,13 @@ func logEntry(category, level, account, format string, args ...any) { category = "系统" } now := time.Now() + timeStr := now.Format(entryTimeLayout) + if offset := time.Duration(serverOffset.Load()); offset != 0 { + serverTime := now.Add(offset) + timeStr = fmt.Sprintf("%s(H%s)", timeStr, serverTime.Format(entryTimeLayout)) + } entry := Entry{ - Time: now.Format(entryTimeLayout), + Time: timeStr, Level: level, Category: category, Account: account, @@ -152,6 +171,11 @@ func formatLogLine(entry Entry) string { at = time.Now() } prefix := at.Format(lineTimeLayout) + if offset := time.Duration(serverOffset.Load()); offset != 0 { + serverTime := at.Add(offset) + prefix = fmt.Sprintf("%s(H%02d:%02d:%02d.%03d)", prefix, + serverTime.Hour(), serverTime.Minute(), serverTime.Second(), serverTime.Nanosecond()/1e6) + } if entry.Account != "" { return fmt.Sprintf("%s [%s][%s][账号:%s] %s\n", prefix, entry.Category, entry.Level, entry.Account, entry.Message) } @@ -172,7 +196,8 @@ func logFileHeader(instanceName, instanceID, dataDir string) string { if dataDir != "" { fmt.Fprintf(&b, "数据目录: %s\n", dataDir) } - b.WriteString("时间格式: YYYY-MM-DD HH:MM:SS.mmm [分类][级别][账号] 正文\n") + b.WriteString("时间格式: 本地时间(H虎牙估算时间) [分类][级别][账号] 正文\n") + b.WriteString(" H 为经服务端校准的虎牙时间估算;校准完成前偏移为 0,只显示本地时间\n") b.WriteString("逐条网络请求为 debug 级,默认不显示;排查时把日志等级改成 debug\n") b.WriteString("==================================================================\n") return b.String() diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 73b11a1..d59c044 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -198,3 +198,39 @@ func TestMinLevelFiltersDebug(t *testing.T) { t.Fatalf("LevelFromString(非法) = %q, want info", got) } } + +// TestDoubleTimeWithServerOffset 验证设置服务端时间偏移后,日志带双时间前缀。 +func TestDoubleTimeWithServerOffset(t *testing.T) { + store, path, closeLog := initForTest(t) + defer func() { + SetServerOffset(0) + closeLog() + }() + + // 模拟服务端比本地快 500ms + SetServerOffset(500 * time.Millisecond) + if ServerOffset() != 500*time.Millisecond { + t.Fatalf("ServerOffset() = %v, want 500ms", ServerOffset()) + } + + LogEvent("监控", LevelInfo, "双时间测试消息") + closeLog() + + entries := store.Since(0) + if len(entries) == 0 { + t.Fatal("未写入日志条目") + } + last := entries[len(entries)-1] + if !strings.Contains(last.Time, "(H") { + t.Fatalf("条目 Time 未包含虎牙估算时间前缀: %s", last.Time) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读取日志文件失败: %v", err) + } + if !strings.Contains(string(data), "(H") { + t.Fatalf("文件未包含虎牙估算时间前缀:\n%s", string(data)) + } +} + diff --git a/internal/sniper/category.go b/internal/sniper/category.go new file mode 100644 index 0000000..59600fa --- /dev/null +++ b/internal/sniper/category.go @@ -0,0 +1,87 @@ +package sniper + +import ( + "fmt" + "strconv" + "strings" +) + +// TargetSummary 把目标列表渲染成「13437(冠军之刃)」这样的单行摘要,供启动日志使用。 +// +// 2026-10-16 的复盘正卡在「日志里没有目标 pid」:三台机器都只记了 sid 与 targets=1, +// 看不出各自打的是哪个奖品,也就无法解释为什么一台售罄、另一台库存完好。 +func TargetSummary(targets []Target) string { + parts := make([]string, 0, len(targets)) + for _, target := range targets { + name := strings.TrimSpace(target.Name) + switch { + case target.PID > 0 && name != "": + parts = append(parts, fmt.Sprintf("%d(%s)", target.PID, name)) + case target.PID > 0: + parts = append(parts, strconv.FormatInt(int64(target.PID), 10)) + case name != "": + parts = append(parts, name) + case target.Auto: + parts = append(parts, "自动选择") + } + } + if len(parts) == 0 { + return "未指定" + } + return strings.Join(parts, ",") +} + +// CategoryLabel 返回结局分类的中文短标签,供任务结束时的结局日志使用。 +func CategoryLabel(category string) string { + switch category { + case CategorySuccess: + return "已抢到" + case CategorySoldOut: + return "售罄" + case CategoryTooFrequent: + return "被限流" + case CategoryNotOpen: + return "未开放" + case CategoryNotEnough: + return "积分不足" + case CategoryAlreadyDone: + return "已兑换过" + case CategoryOffShelf: + return "已下架" + case CategorySessionLost: + return "会话失效" + case CategoryProtocol: + return "协议异常" + case CategoryCondition: + return "条件未达成" + case CategoryNotFound: + return "未匹配到奖品" + case CategoryError: + return "请求异常" + default: + return "失败" + } +} + +// CategoryText 把类目限制渲染成日志文案(空串表示不限类目)。 +func CategoryText(category string) string { + key := strings.TrimSpace(category) + if key == "" { + return "不限" + } + return key +} + +// LogLevelForCategory 把结果分类映射成日志等级,供界面按等级高亮与筛选。 +func LogLevelForCategory(category string) string { + switch category { + case CategorySuccess: + return "success" + case CategoryError, CategoryProtocol: + return "error" + case CategoryFailed, CategoryTooFrequent, CategoryCondition, CategorySessionLost: + return "warn" + default: + return "info" + } +} diff --git a/internal/sniper/classify.go b/internal/sniper/classify.go new file mode 100644 index 0000000..be613cc --- /dev/null +++ b/internal/sniper/classify.go @@ -0,0 +1,191 @@ +package sniper + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "huya-go/internal/huya" +) + +// notFoundMessage 在未命中时把类目限制写进提示,便于区分「被类目过滤掉」与「列表里确实没有」。 +func notFoundMessage(category string) string { + if strings.TrimSpace(category) == "" { + return "未查询到所需物品,继续监控" + } + return fmt.Sprintf("类目[%s]内未查询到所需物品,继续监控", strings.TrimSpace(category)) +} + +func matchPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo { + if target.Auto { + return autoPrize(prizes, target) + } + for i := range prizes { + if target.PID > 0 && int32(prizes[i].ID) != target.PID { + continue + } + if target.PID == 0 && target.Name != "" && !strings.Contains(prizes[i].Name, target.Name) { + continue + } + if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice { + continue + } + if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft { + continue + } + return &prizes[i] + } + return nil +} + +func autoPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo { + var fallback *huya.PrizeInfo + for i := range prizes { + if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice { + continue + } + if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft { + continue + } + if fallback == nil { + fallback = &prizes[i] + } + if prizes[i].EstimatedLeft() > 0 { + return &prizes[i] + } + } + return fallback +} + +// exchangeSignals 汇总一次兑换响应里可用于判定的信号。 +// +// 虎牙把失败原因分散在 msg / actPreCondition(Tip/ButtonDesc/ConditionParams) +// 和 exchangeInfo.desc 里,status 除 200 外没有可依据的公开语义, +// 所以判定顺序是:文案 -> 成功结构(status=200 且有 orderId)-> 结构门禁 -> 兜底失败。 +type exchangeSignals struct { + text string + status int32 + hasOrderID bool + conditionType int32 +} + +func buildExchangeSignals(resp *huya.ScoreExchangePrizeResp) exchangeSignals { + if resp == nil { + return exchangeSignals{} + } + parts := []string{ + resp.Msg, + resp.ActPreCondition.Tip, + resp.ActPreCondition.ButtonDesc, + resp.ExchangeInfo.Desc, + } + for _, value := range resp.ActPreCondition.ConditionParams { + parts = append(parts, value) + } + texts := make([]string, 0, len(parts)) + for _, part := range parts { + if item := strings.TrimSpace(part); item != "" { + texts = append(texts, item) + } + } + return exchangeSignals{ + text: strings.Join(texts, " "), + status: resp.Status, + hasOrderID: strings.TrimSpace(resp.OrderID) != "", + conditionType: resp.ActPreCondition.ConditionType, + } +} + +// classifyExchangeText 只依据文案判定失败原因,命中时返回分类。 +// +// 判定顺序固定:未开放 → 售罄 → 货币/次数 → 会话失效 → 下架 → 限流。 +// 顺序很关键:限流关键词里有「稍后重试」「稍后」,如果排在前面会把更具体的原因吃掉。 +// 2026-10-16 的日志里 244 条「领取频率限制,请稍后重试!!」和 917 条「活动太火爆了, +// 请稍后重试」过去全落到 failed:既看不出是被限流,也享受不到限流该有的退避语义。 +func classifyExchangeText(text string) (string, bool) { + switch { + case containsAny(text, "暂未开放", "未开放", "未开始", "活动未开始"): + return CategoryNotOpen, true + case containsAny(text, "已抢完", "已抢光", "已兑完", "已售完", "库存不足", "没有库存", "领完了", "库存已耗完"): + return CategorySoldOut, true + case containsAny(text, "货币不足", "积分不足", "余额不足", "点券不足", "不足"): + return CategoryNotEnough, true + case containsAny(text, "已兑换", "重复兑换", "已领取", "超过兑换次数", "达到上限", "限购"): + return CategoryAlreadyDone, true + case containsAny(text, "账号未绑定", "未绑定账号", "未绑定手机", "登录态", "登录失效", "请先登录", "凭证失效", "token 失效"): + return CategorySessionLost, true + case containsAny(text, "奖品已下架", "已下架", "奖品不存在", "活动不存在"): + return CategoryOffShelf, true + case containsAny(text, "操作频繁", "太频繁", "稍后再试", "稍后重试", "火爆", "频繁", "频率", "限流", "繁忙", "人多", "排队", "手慢", "点击太快", "请求过多", "行为异常"): + return CategoryTooFrequent, true + } + return "", false +} + +func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string { + if err != nil { + // 协议层错误(缺少 tRsp/tResp、解码失败)单独归类:它不是业务失败, + // 通常是服务端在高并发下回了错误信封,需要单独统计与排查。 + if errors.Is(err, huya.ErrProtocolResponse) { + return CategoryProtocol + } + return CategoryError + } + if resp == nil { + return CategoryFailed + } + if resp.Success() { + return CategorySuccess + } + signals := buildExchangeSignals(resp) + if category, ok := classifyExchangeText(signals.text); ok { + return category + } + // 成功回包也可能不带中文文案:status=200 且有 orderId 视为成功。 + if signals.status == 200 && signals.hasOrderID { + return CategorySuccess + } + // 没有文案但服务端回了 precondition 门禁,归为条件未达成,按可重试失败处理。 + if signals.conditionType != 0 { + return CategoryCondition + } + return CategoryFailed +} + +// ExchangeMessage 返回响应里最有代表性的提示文案,供日志与 GUI 展示。 +func ExchangeMessage(resp *huya.ScoreExchangePrizeResp) string { + if resp == nil { + return "" + } + for _, text := range []string{ + resp.Msg, + resp.ActPreCondition.Tip, + resp.ActPreCondition.ButtonDesc, + resp.ExchangeInfo.Desc, + } { + if strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + return "" +} + +func containsAny(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} + +func targetKey(target Target) string { + return strings.Join([]string{ + strings.TrimSpace(target.Name), + int32String(target.PID), + strconv.FormatBool(target.Auto), + int64String(target.MaxPrice), + int64String(target.MinLeft), + }, "\x00") +} diff --git a/internal/sniper/config.go b/internal/sniper/config.go new file mode 100644 index 0000000..d01f937 --- /dev/null +++ b/internal/sniper/config.go @@ -0,0 +1,88 @@ +package sniper + +import ( + "strings" + "time" +) + +func normalizeConfig(cfg Config) Config { + cfg.Category = strings.TrimSpace(cfg.Category) + if cfg.maxAttempts <= 0 && cfg.OpenAt.IsZero() { + // 非窗口模式必须有个有限轮次,否则会变成死循环。 + cfg.maxAttempts = 1 + } + // 窗口模式由 deadline 兜底,maxAttempts<=0 表示一直尝试到窗口结束。 + if cfg.Delay <= 0 { + cfg.Delay = 150 * time.Millisecond + } + if cfg.Concurrency <= 0 { + cfg.Concurrency = 1 + } + if cfg.TooFrequentBackoff <= 0 { + cfg.TooFrequentBackoff = maxDuration(cfg.Delay*2, defaultRetryBackoff) + } + if cfg.ErrorBackoff <= 0 { + cfg.ErrorBackoff = maxDuration(cfg.Delay, defaultRetryBackoff) + } + // 首发错峰给了 spread 就至少要有 1ms 步长,否则等于没启用。 + if cfg.FirstWaveSpread > 0 && cfg.FirstWaveMaxStep <= 0 { + cfg.FirstWaveMaxStep = DefaultFirstWaveMaxStep + } + // 积分不足是终态:积分不会自己变多,继续打只是浪费请求,默认停手。 + if !cfg.ContinueOnNotEnough { + cfg.StopOnNotEnough = true + } + // 售罄刻意不给默认值:库存可能因别人取消 / 锁单过期而回流,默认继续探测到 + // 窗口结束;需要「售罄即停」时显式设置 StopOnSoldOut。 + if !cfg.ListBeforeExchange { + cfg.DirectExchange = true + } + return cfg +} + +// normalizeTargets 去掉空目标与重复目标。 +// +// 除了完全相同的 target,还按 pid / 名称去重:界面选品会同时提交 pid、名称, +// 以及 targetText 里的同一个 pid,不去重就会对同一奖品重复兑换(实测踩过)。 +func normalizeTargets(targets []Target) []Target { + out := make([]Target, 0, len(targets)) + seenKey := map[string]bool{} + seenPID := map[int32]bool{} + seenName := map[string]bool{} + for _, target := range targets { + target.Name = strings.TrimSpace(target.Name) + if target.PID <= 0 && target.Name == "" && !target.Auto { + continue + } + if target.PID > 0 && seenPID[target.PID] { + continue + } + if target.Name != "" && seenName[target.Name] { + continue + } + key := targetKey(target) + if seenKey[key] { + continue + } + seenKey[key] = true + if target.PID > 0 { + seenPID[target.PID] = true + } + if target.Name != "" { + seenName[target.Name] = true + } + out = append(out, target) + } + return out +} + +func shouldDirectExchange(target Target, cfg Config) bool { + // 设置了价格/库存门槛时必须先查列表,否则直兑会绕过过滤条件, + // 商品临时涨价或库存不足时仍会直接下单。 + return cfg.DirectExchange && + !cfg.ListBeforeExchange && + target.PID > 0 && + !target.Auto && + target.MaxPrice <= 0 && + target.MinLeft <= 0 +} diff --git a/internal/sniper/exchange.go b/internal/sniper/exchange.go new file mode 100644 index 0000000..5fbae1b --- /dev/null +++ b/internal/sniper/exchange.go @@ -0,0 +1,222 @@ +package sniper + +import ( + "context" + "errors" + "fmt" + "time" + + "huya-go/internal/huya" +) + +// emitSkippedDuplicate 在跳过同一奖品的重复兑换时给出明确结果,便于界面观察。 +// +// 每个「账号 + 奖品」只提示一次:窗口模式下每轮都会跳过同一个奖品, +// 不记录会刷出一整个窗口的重复提示(但请求数不会增加)。 +func (s *runState) emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, prizeID int64, target Target) { + if s.markDuplicateNotified(uid, prizeID) { + s.recordOutcome(CategoryAlreadyDone) + } + // 同一奖品已由本账号兑换成功,这个 target 不可能再成功,直接标记完成, + // 避免多名称目标反复空转。 + s.markDone(uid, index) + if emit == nil { + return + } + emit(Result{ + Loop: loop, + UID: uid, + TargetIndex: index, + Target: target, + Action: "skip_duplicate", + Category: CategoryAlreadyDone, + Message: "该奖品本账号已兑换成功,跳过重复兑换", + }) +} + +// markDuplicateNotified 记录并返回是否是首次提示。 +func (s *runState) markDuplicateNotified(uid, prizeID int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.duplicateNotified[uid] == nil { + s.duplicateNotified[uid] = map[int64]bool{} + } + if s.duplicateNotified[uid][prizeID] { + return false + } + s.duplicateNotified[uid][prizeID] = true + return true +} + +func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, state *runState, account Account, target Target, index int, loop int, sid int32, prize *huya.PrizeInfo) Result { + pid := target.PID + if prize != nil { + pid = int32(prize.ID) + } + result := Result{ + Loop: loop, + UID: account.UID, + TargetIndex: index, + Target: target, + Action: "exchange", + Prize: prize, + } + if !state.acquire(ctx) { + result.Action = actionExchangeCanceled + result.Category = CategoryError + result.Message = "任务已取消,未发起兑换" + result.Error = ctx.Err().Error() + return result + } + // 排队等额度期间窗口可能已经结束,不能把请求补发到窗口外。 + if state.windowExpired() { + state.release() + result.Action = actionExchangeCanceled + result.Category = CategoryError + result.Message = "窗口已结束,未发起兑换" + return result + } + started := time.Now() + exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid) + state.release() + state.recordRequest(reqExchange, time.Since(started), err) + result.Exchange = exchange + result.Category = ClassifyExchange(exchange, err) + result.Message = ExchangeMessage(exchange) + if err != nil { + result.Action = "exchange_error" + result.Error = err.Error() + if result.Message == "" { + result.Message = "兑换请求失败" + } + } + return result +} + +func (r *Runner) applyExchangeDecision(state *runState, account Account, index int, cfg Config, result Result, cancel context.CancelFunc) Result { + state.recordOutcome(result.Category) + if result.Category == CategorySuccess { + state.markSuccessUID(account.UID) + } + switch result.Category { + case CategorySuccess: + if cfg.StopAccountAfterSuccess { + state.markAccountDone(account.UID) + result.Done = true + } else { + state.markDone(account.UID, index) + } + if cfg.StopAfterSuccess { + cancel() + } + case CategoryNotEnough, CategoryAlreadyDone: + if cfg.StopOnNotEnough { + state.markDone(account.UID, index) + result.Done = true + } + case CategoryOffShelf: + // 已下架 / 奖品不存在:本窗口内不会再出现,直接终止该 target。 + state.markTargetDone(index) + result.Done = true + case CategorySoldOut: + if cfg.StopOnSoldOut { + state.markTargetDone(index) + result.Done = true + } + } + return result +} + +func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient, account Account, target Target, index int, loop int, cfg Config, result Result, emit func(Result)) { + if !cfg.RefreshAfterSuccess || emit == nil || result.Category != CategorySuccess { + return + } + state.refresh.Add(1) + go func() { + defer state.refresh.Done() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 刷新请求同样走并发闸门,但用拥有 5 秒预算的独立 ctx。 + score, err := func() (*huya.GetUserScoreResp, error) { + if !state.acquire(ctx) { + return nil, ctx.Err() + } + defer state.release() + started := time.Now() + resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID) + state.recordRequest(reqScore, time.Since(started), err) + return resp, err + }() + scoreResult := Result{ + Loop: loop, + UID: account.UID, + TargetIndex: index, + Target: target, + Action: "refresh_score", + Category: CategoryReady, + Message: "刷新积分完成", + Score: score, + } + if err != nil { + scoreResult.Category = CategoryError + scoreResult.Message = "刷新积分失败" + scoreResult.Error = err.Error() + } else if score != nil { + scoreResult.Message = fmt.Sprintf("剩余积分: %d", score.AvailableScore()) + } + emit(scoreResult) + + records, err := func() (*huya.GetUserPrizeRecordsResp, error) { + if !state.acquire(ctx) { + return nil, ctx.Err() + } + defer state.release() + started := time.Now() + resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID) + state.recordRequest(reqRecord, time.Since(started), err) + return resp, err + }() + recordResult := Result{ + Loop: loop, + UID: account.UID, + TargetIndex: index, + Target: target, + Action: "refresh_records", + Category: CategoryReady, + Message: "刷新兑换记录完成", + Records: records, + } + if err != nil { + recordResult.Category = CategoryError + recordResult.Message = "刷新兑换记录失败" + recordResult.Error = err.Error() + } + emit(recordResult) + }() +} + +// listForAccount 返回本账号本轮要用的奖品列表快照。 +// +// SharePrizeList 打开时多账号复用同一份快照:TTL 内第一个账号查完,其余账号直接复用 +// (单飞:同一时刻只有一个账号在查,避免并发重复查询)。关闭时各查各的。 +func (r *Runner) listForAccount(ctx context.Context, client huya.ActivityClient, account Account, cfg Config, state *runState) *listSnapshot { + fetch := func() *listSnapshot { + if !state.acquire(ctx) { + return &listSnapshot{err: ctx.Err()} + } + if state.windowExpired() { + state.release() + return &listSnapshot{err: errors.New("窗口已结束")} + } + started := time.Now() + resp, err := client.GetActPrizeList(ctx, account.UID, account.Cookie, cfg.SID) + state.release() + state.recordRequest(reqList, time.Since(started), err) + return &listSnapshot{resp: resp, err: err} + } + if !cfg.SharePrizeList { + return fetch() + } + return state.sharedListOnce(ctx, fetch) +} diff --git a/internal/sniper/helpers.go b/internal/sniper/helpers.go new file mode 100644 index 0000000..7dba991 --- /dev/null +++ b/internal/sniper/helpers.go @@ -0,0 +1,57 @@ +package sniper + +import ( + "context" + "math/rand" + "strconv" + "time" +) + +func int32String(value int32) string { + return strconv.FormatInt(int64(value), 10) +} + +func int64String(value int64) string { + return strconv.FormatInt(value, 10) +} + +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func maxDuration(a, b time.Duration) time.Duration { + if a > b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// jitterDuration 在基准退避上叠加 0-25% 的随机抖动。 +// +// 多个账号同时失败时,固定退避会让它们在同一个时刻整批重试,形成周期性尖峰, +// 更容易撞上限频(参考 dygou_go 的 jitterFraction)。这里只往长了抖,不缩短, +// 保证配置值始终是退避下限。 +func jitterDuration(base time.Duration) time.Duration { + if base <= 0 { + return base + } + span := int64(base) / 4 + if span <= 0 { + return base + } + return base + time.Duration(rand.Int63n(span+1)) +} diff --git a/internal/sniper/optimization_test.go b/internal/sniper/optimization_test.go index 4d9301b..ddd21d4 100644 --- a/internal/sniper/optimization_test.go +++ b/internal/sniper/optimization_test.go @@ -98,39 +98,61 @@ func TestRetryEscalationMath(t *testing.T) { t.Fatalf("stageMultiplier(%v)=%d, want %d", elapsed, got, want) } } - // 正常等待类(售罄/未开放)放慢得更保守,避免错过库存回流。 - if got := stageMultiplier(6*time.Second, CategorySoldOut); got != 2 { - t.Fatalf("售罄阶段倍率=%d, want 2", got) + // 售罄也按同一账号级退避阶梯放慢,避免每轮固定高速探测。 + if got := stageMultiplier(6*time.Second, CategorySoldOut); got != 3 { + t.Fatalf("售罄阶段倍率=%d, want 3", got) } } -// TestThrottleGovernorEscalatesAndRecovers 验证全局降速器:连续无进展才降速、有进展立刻回退。 -func TestThrottleGovernorEscalatesAndRecovers(t *testing.T) { - g := newThrottleGovernor() - g.minRequests = 2 - g.stallWindow = time.Millisecond - g.lastProgress = time.Now().Add(-time.Second) - g.lastEscalate = time.Now().Add(-time.Second) - - g.observe(CategoryTooFrequent) - if got := g.current(); got != 1 { - t.Fatalf("未达阈值不该降速,factor=%v", got) - } - g.observe(CategoryTooFrequent) - if got := g.current(); got <= 1 { - t.Fatalf("连续无进展应开始降速,factor=%v", got) - } - if got := g.peak(); got <= 1 { - t.Fatalf("peak 应记录最大倍率,实际 %v", got) +// TestCompleteRoundSessionStreakIsScopedToAccount 验证会话失效计数只属于当前账号, +// 网络/协议错误不会洗掉计数,任意可信业务回包才会清零。 +func TestCompleteRoundSessionStreakIsScopedToAccount(t *testing.T) { + state := newRunState([]Account{{UID: 1}, {UID: 2}}, 1, 1, Config{}) + state.completeRound(1, []string{CategorySessionLost}, defaultSessionLostLimit) + state.completeRound(1, []string{CategorySessionLost}, defaultSessionLostLimit) + if got := state.accounts[1].sessionStreak; got != 2 { + t.Fatalf("sessionStreak=%d, want 2", got) } - before := g.current() - g.observe(CategorySuccess) - if got := g.current(); got >= before { - t.Fatalf("有进展后应回退,factor 从 %v 到 %v", before, got) + state.completeRound(2, []string{CategorySoldOut}, defaultSessionLostLimit) + if got := state.accounts[1].sessionStreak; got != 2 { + t.Fatalf("其他账号不应重置会话失效计数,got=%d", got) } - if got := g.current(); got < 1 { - t.Fatalf("倍率不应小于 1,实际 %v", got) + + state.completeRound(1, []string{CategoryError}, defaultSessionLostLimit) + state.completeRound(1, []string{CategoryProtocol}, defaultSessionLostLimit) + if got := state.accounts[1].sessionStreak; got != 2 { + t.Fatalf("网络/协议错误不应重置会话失效计数,got=%d", got) + } + + state.completeRound(1, []string{CategorySoldOut}, defaultSessionLostLimit) + if got := state.accounts[1].sessionStreak; got != 0 { + t.Fatalf("可信业务回包后应清零,got=%d", got) + } +} + +// TestCompleteRoundCountsEachRoundOnce 验证多目标同一轮失败只增加一次连续失败, +// 不会因为 target 多而把退避倍率放大两次。 +func TestCompleteRoundCountsEachRoundOnce(t *testing.T) { + state := newRunState([]Account{{UID: 1}}, 2, 1, Config{}) + state.completeRound(1, []string{CategoryTooFrequent, CategorySessionLost}, defaultSessionLostLimit) + if got := state.accounts[1].failStreak; got != 1 { + t.Fatalf("failStreak=%d, want 1(每轮只计一次)", got) + } + if got := state.accounts[1].sessionStreak; got != 1 { + t.Fatalf("sessionStreak=%d, want 1", got) + } + state.completeRound(1, []string{CategorySuccess}, defaultSessionLostLimit) + if got := state.accounts[1].failStreak; got != 0 { + t.Fatalf("成功后 failStreak 应清零,got=%d", got) + } +} + +// TestDefaultSessionLostLimitToleratesTransientBurst 验证未显式配置时,不会因为 +// 洪峰中常见的 3 次瞬时 session_lost 就把有效账号整场踢出。 +func TestDefaultSessionLostLimitToleratesTransientBurst(t *testing.T) { + if got := sessionLostLimit(Config{}); got <= 3 { + t.Fatalf("默认会话失效阈值=%d,want > 3(避免瞬时洪峰误停账号)", got) } } @@ -193,39 +215,42 @@ func TestRunStateFirstWaveStagger(t *testing.T) { } } -// TestRunStateSessionStreakResetIsScopedAndIndependentOfGovernor 验证会话失效计数 -// 只按当前账号清理,并且不依赖自适应降速开关。 -func TestRunStateSessionStreakResetIsScopedAndIndependentOfGovernor(t *testing.T) { - for _, withGovernor := range []bool{false, true} { - state := newRunState([]Account{{UID: 1}, {UID: 2}}, 1, 1, Config{}) - if withGovernor { - state.governor = newThrottleGovernor() - } - state.bumpSessionStreak(1, 0) - state.bumpSessionStreak(1, 0) - - // 另一个账号拿到正常业务响应,不能重置账号 1 的连续失效计数。 - state.observeThrottle(2, CategorySoldOut) - if got := state.accounts[1][0].SessionStreak; got != 2 { - t.Fatalf("governor=%v: 其他账号不应重置会话失效计数,got=%d", withGovernor, got) - } - - // 网络错误 / 协议异常没有可信业务响应,也不能把失效计数洗掉。 - state.observeThrottle(1, CategoryError) - state.observeThrottle(1, CategoryProtocol) - if got := state.accounts[1][0].SessionStreak; got != 2 { - t.Fatalf("governor=%v: 网络/协议错误不应重置会话失效计数,got=%d", withGovernor, got) - } - - // 账号 1 自己拿到正常业务响应后必须清零,即使降速器关闭。 - state.observeThrottle(1, CategorySoldOut) - if got := state.accounts[1][0].SessionStreak; got != 0 { - t.Fatalf("governor=%v: 本账号恢复后应清零,got=%d", withGovernor, got) - } +// TestRunTargetsThrottleOnOneTargetDoesNotSkipAnother 验证账号级退避不会让 +// 一个 target 的限流结果阻塞同一轮里的另一个 target。这是 per-target 重试 +// 状态被移除后的核心行为。 +func TestRunTargetsThrottleOnOneTargetDoesNotSkipAnother(t *testing.T) { + var secondSucceeded atomic.Bool + client := &fakeActivityClient{ + exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { + if pid == 1 { + return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil + } + secondSucceeded.Store(true) + return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功", OrderID: "O1"}, nil + }, + } + runner := Runner{Client: client} + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{ + {PID: 1}, + {PID: 2}, + }, Config{ + SID: 2203, + maxAttempts: 1, + Delay: time.Millisecond, + DirectExchange: true, + StopAccountAfterSuccess: true, + }, nil) + if err != nil { + t.Fatal(err) + } + if _, count, _ := client.counts(); count != 2 { + t.Fatalf("exchangeCount=%d, want 2(同一轮两个目标都要尝试)", count) + } + if !secondSucceeded.Load() { + t.Fatal("第二个目标应成功") } } -// TestRunTargetsWaitsForWindowWithoutPrefire 验证即使不需要预打,也不会提前进入窗口循环。 func TestRunTargetsWaitsForWindowWithoutPrefire(t *testing.T) { var mu sync.Mutex var first time.Time @@ -244,7 +269,7 @@ func TestRunTargetsWaitsForWindowWithoutPrefire(t *testing.T) { SID: 2203, OpenAt: openAt, windowOverride: 20 * time.Millisecond, - Loops: 1, + maxAttempts: 1, Delay: time.Millisecond, StartAhead: 100 * time.Millisecond, ErrorBackoff: time.Millisecond, @@ -272,7 +297,7 @@ func TestRunTargetsStopAccountAfterSuccessStopsAllTargets(t *testing.T) { {PID: 2}, }, Config{ SID: 2203, - Loops: 2, + maxAttempts: 2, Delay: time.Millisecond, StopAccountAfterSuccess: true, }, nil) @@ -298,7 +323,7 @@ func TestRunTargetsSessionLostStopsWholeAccount(t *testing.T) { {PID: 2}, }, Config{ SID: 2203, - Loops: 10, + maxAttempts: 10, Delay: 5 * time.Millisecond, ErrorBackoff: time.Millisecond, SessionLostLimit: 3, @@ -306,9 +331,10 @@ func TestRunTargetsSessionLostStopsWholeAccount(t *testing.T) { if err != nil { t.Fatal(err) } - // 第 1、2 轮各打两个目标;第 3 轮第一个目标连续第 3 次失效后整账号停止。 - if _, exchangeCount, _ := client.counts(); exchangeCount != 5 { - t.Fatalf("exchangeCount=%d, want 5(达到阈值后应停止整个账号)", exchangeCount) + // 每轮会尝试两个目标;第 1 轮 2 次、第 2 轮 2 次、第 3 轮两个目标都返回 + // session_lost 后账号级计数达到 3,共 6 次请求。 + if _, exchangeCount, _ := client.counts(); exchangeCount != 6 { + t.Fatalf("exchangeCount=%d, want 6(达到阈值后应停止整个账号)", exchangeCount) } } @@ -323,16 +349,17 @@ func TestRunStateElapsedUsesOpenAt(t *testing.T) { } } -// TestRunTargetsNotFoundBacksOff 验证未命中时不会每轮刷事件和列表请求。 +// TestRunTargetsNotFoundBacksOff 验证未命中的每次真实尝试之间会退避。 func TestRunTargetsNotFoundBacksOff(t *testing.T) { client := &fakeActivityClient{listResp: &huya.GetActPrizeListResp{Status: 200}} runner := Runner{Client: client} var notFound int32 + started := time.Now() err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{ {Name: "不存在的奖品"}, }, Config{ SID: 2203, - Loops: 6, + maxAttempts: 3, Delay: time.Millisecond, ErrorBackoff: 25 * time.Millisecond, ListBeforeExchange: true, @@ -344,12 +371,15 @@ func TestRunTargetsNotFoundBacksOff(t *testing.T) { if err != nil { t.Fatal(err) } - if got := atomic.LoadInt32(¬Found); got != 1 { - t.Fatalf("not_found 事件=%d, want 1(退避期间应跳过)", got) + if got := atomic.LoadInt32(¬Found); got != 3 { + t.Fatalf("not_found 事件=%d, want 3 次真实尝试", got) + } + if elapsed := time.Since(started); elapsed < 45*time.Millisecond { + t.Fatalf("未命中退避没有生效,耗时=%v", elapsed) } } -// TestRunTargetsNotOpenBacksOff 验证未开放时也会退避,不会以 Delay 持续轰接口。 +// TestRunTargetsNotOpenBacksOff 验证未开放的每次真实尝试之间也会退避。 func TestRunTargetsNotOpenBacksOff(t *testing.T) { client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { @@ -357,19 +387,23 @@ func TestRunTargetsNotOpenBacksOff(t *testing.T) { }, } runner := Runner{Client: client} + started := time.Now() err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{ {PID: 1}, }, Config{ SID: 2203, - Loops: 6, + maxAttempts: 3, Delay: time.Millisecond, ErrorBackoff: 25 * time.Millisecond, }, nil) if err != nil { t.Fatal(err) } - if _, exchangeCount, _ := client.counts(); exchangeCount != 1 { - t.Fatalf("exchangeCount=%d, want 1(未开放应退避)", exchangeCount) + if _, exchangeCount, _ := client.counts(); exchangeCount != 3 { + t.Fatalf("exchangeCount=%d, want 3 次真实尝试", exchangeCount) + } + if elapsed := time.Since(started); elapsed < 90*time.Millisecond { + t.Fatalf("未开放退避没有生效,耗时=%v", elapsed) } } @@ -405,7 +439,7 @@ func TestRunTargetsDirectExchangeHonorsPriceFilter(t *testing.T) { {PID: 1, MaxPrice: 50}, }, Config{ SID: 2203, - Loops: 1, + maxAttempts: 1, Delay: time.Millisecond, DirectExchange: true, ListBeforeExchange: false, @@ -441,7 +475,7 @@ func TestRunTargetsPrefireProbeIsBounded(t *testing.T) { SID: 2203, OpenAt: openAt, windowOverride: 30 * time.Millisecond, - Loops: 1, + maxAttempts: 1, Delay: time.Millisecond, StartAhead: 20 * time.Millisecond, Concurrency: 2, @@ -465,13 +499,12 @@ func TestRunTargetsPrefireProbeIsBounded(t *testing.T) { } } -// TestRetryDelayNeverExceedsCap 验证全局降速和抖动不能把单次退避放大到上限之外。 +// TestRetryDelayNeverExceedsCap 验证阶段倍率、连续失败倍率和抖动不会突破上限。 func TestRetryDelayNeverExceedsCap(t *testing.T) { state := newRunState([]Account{{UID: 1}}, 1, 1, Config{}) - state.governor = newThrottleGovernor() - state.governor.factor = governorMaxFactor + state.accounts[1].failStreak = 20 for i := 0; i < 20; i++ { - got := state.retryDelay(1, 0, Config{ + got := state.retryDelay(1, Config{ ErrorBackoff: 300 * time.Millisecond, TooFrequentBackoff: 300 * time.Millisecond, }, CategoryTooFrequent) @@ -496,7 +529,7 @@ func TestRunTargetsWindowModeKeepsTryingUntilDeadline(t *testing.T) { SID: 2203, OpenAt: started, windowOverride: 120 * time.Millisecond, - Loops: 0, // 窗口模式:不限制轮次 + maxAttempts: 0, // 窗口模式:不限制轮次 Delay: 5 * time.Millisecond, ErrorBackoff: time.Millisecond, TooFrequentBackoff: time.Millisecond, @@ -533,7 +566,7 @@ func TestRunTargetsPrefireOnceBeforeOpen(t *testing.T) { SID: 2203, OpenAt: openAt, windowOverride: 60 * time.Millisecond, - Loops: 0, + maxAttempts: 0, Delay: 5 * time.Millisecond, StartAhead: 20 * time.Millisecond, ErrorBackoff: time.Millisecond, @@ -571,7 +604,7 @@ func TestRunTargetsSkipsPrefireNearWindowStart(t *testing.T) { SID: 2203, OpenAt: openAt, windowOverride: 40 * time.Millisecond, - Loops: 1, // 只跑一轮,用于观察首发是否被重复 + maxAttempts: 1, // 只跑一轮,用于观察首发是否被重复 StartAhead: 60 * time.Millisecond, }, nil) if err != nil { @@ -591,7 +624,7 @@ func TestRunTargetsWindowAlreadyExpiredSkips(t *testing.T) { SID: 2203, OpenAt: time.Now().Add(-2 * time.Second), windowOverride: time.Second, - Loops: 0, + maxAttempts: 0, }, nil) if err != nil { t.Fatal(err) @@ -614,7 +647,7 @@ func TestRunTargetsSessionLostStopsAfterLimit(t *testing.T) { runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 10, + maxAttempts: 10, Delay: 2 * time.Millisecond, ErrorBackoff: time.Millisecond, SessionLostLimit: 3, @@ -639,7 +672,7 @@ func TestRunTargetsSummaryIncludesRequestAndAccountStats(t *testing.T) { runner := Runner{Client: client, OnSummary: func(item RunSummary) { summary = item }} err := runner.RunTargets(context.Background(), []Account{{UID: 7, Cookie: "yyuid=7"}}, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 2, + maxAttempts: 2, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, StopAfterSuccess: true, @@ -742,7 +775,7 @@ func TestRunTargetsPerAccountLoopsAreIndependent(t *testing.T) { go func() { done <- runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 30, + maxAttempts: 30, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, TooFrequentBackoff: time.Millisecond, @@ -792,7 +825,7 @@ func TestRunTargetsSharedListTTLSingleFlight(t *testing.T) { err := runner.RunTargets(context.Background(), accounts, []Target{{Name: "小黄鸭"}}, Config{ SID: 2203, - Loops: 3, + maxAttempts: 3, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, TooFrequentBackoff: time.Millisecond, @@ -833,11 +866,11 @@ func TestRunStateAccountFinishedAndRoundTracking(t *testing.T) { if state.accountFinished(1, 2) { t.Fatal("目标都没完成时不应判定账号结束") } - state.accounts[1][0].Done = true + state.accounts[1].targets[0].Done = true if state.accountFinished(1, 2) { t.Fatal("还有一个目标未完成时不应判定账号结束") } - state.accounts[1][1].Done = true + state.accounts[1].targets[1].Done = true if !state.accountFinished(1, 2) { t.Fatal("所有目标完成后应判定账号结束") } @@ -849,11 +882,11 @@ func TestRunStateAccountFinishedAndRoundTracking(t *testing.T) { t.Fatal("全局目标已终止时账号应判定结束") } - rounds := newRunState([]Account{{UID: 3}}, 1, 1, Config{}) - rounds.noteRound(3) - rounds.noteRound(2) - rounds.noteRound(7) - if got := rounds.maxRound(); got != 7 { + maxAttemptCount := newRunState([]Account{{UID: 3}}, 1, 1, Config{}) + maxAttemptCount.noteAttempt(3) + maxAttemptCount.noteAttempt(2) + maxAttemptCount.noteAttempt(7) + if got := maxAttemptCount.maxAttempt(); got != 7 { t.Fatalf("最大轮次=%d, want 7", got) } } diff --git a/internal/sniper/retry.go b/internal/sniper/retry.go new file mode 100644 index 0000000..7f84b29 --- /dev/null +++ b/internal/sniper/retry.go @@ -0,0 +1,187 @@ +package sniper + +import ( + "time" +) + +// sessionLostLimit 返回「连续会话失效多少次后停止该账号」的阈值。 +func sessionLostLimit(cfg Config) int { + if cfg.SessionLostLimit > 0 { + return cfg.SessionLostLimit + } + return defaultSessionLostLimit +} + +// completeRound 每个账号每轮只更新一次连续失败/会话失效计数。 +// 多目标一轮同时失败不会被放大成多次失败;任意可信业务回包都会清零会话失效。 +func (s *runState) completeRound(uid int64, categories []string, sessionLimit int) { + s.mu.Lock() + defer s.mu.Unlock() + account := s.accounts[uid] + if account == nil { + return + } + + hasSuccess := false + hasRetry := false + hasSessionLost := false + hasSessionAlive := false + for _, category := range categories { + switch category { + case CategorySuccess: + hasSuccess = true + hasSessionAlive = true + case CategorySessionLost: + hasSessionLost = true + default: + if sessionAliveCategory(category) { + hasSessionAlive = true + } + if backoffCategory(category) { + hasRetry = true + } + } + } + + if hasSuccess { + account.failStreak = 0 + account.sessionStreak = 0 + return + } + if hasRetry { + account.failStreak++ + } + if hasSessionAlive { + account.sessionStreak = 0 + } else if hasSessionLost { + account.sessionStreak++ + } + if sessionLimit > 0 && account.sessionStreak >= sessionLimit { + account.done = true + } +} + +// roundDelay 返回本轮结束后该账号的统一等待时间:取本轮所有失败分类里 +// 最长的退避值,不再为每个 target 单独维护下一次重试时间。 +func (s *runState) roundDelay(uid int64, cfg Config, categories []string) time.Duration { + delay := cfg.Delay + if delay <= 0 { + delay = 100 * time.Millisecond + } + seen := make(map[string]bool, len(categories)) + for _, category := range categories { + if category == "" || seen[category] || !backoffCategory(category) { + continue + } + seen[category] = true + if candidate := s.retryDelay(uid, cfg, category); candidate > delay { + delay = candidate + } + } + return delay +} + +// retryDelay 计算当前账号在某个结局后的退避时间。 +func (s *runState) retryDelay(uid int64, cfg Config, category string) time.Duration { + s.mu.Lock() + streak := 0 + if account := s.accounts[uid]; account != nil { + streak = account.failStreak + } + s.mu.Unlock() + return computeRetryDelay(s.elapsed(), streak, cfg, category) +} + +// computeRetryDelay 使用与 dygou_go 相同的简单阶梯: +// 分类基础值 × 时间阶段 × 连续失败倍率,最后叠加抖动并封顶 1.5 秒。 +func computeRetryDelay(elapsed time.Duration, failStreak int, cfg Config, category string) time.Duration { + base := cfg.ErrorBackoff + switch category { + case CategoryTooFrequent: + base = cfg.TooFrequentBackoff + case CategoryNotOpen: + base = maxDuration(cfg.Delay, 50*time.Millisecond) + } + if base <= 0 { + base = defaultRetryBackoff + } + delay := time.Duration(stageMultiplier(elapsed, category)) * base + if mult := streakMultiplier(failStreak); mult > 1 { + delay *= time.Duration(mult) + } + if delay > maxRetryDelay { + delay = maxRetryDelay + } + delay = jitterDuration(delay) + if delay > maxRetryDelay { + delay = maxRetryDelay + } + return delay +} + +// elapsed 返回相对开抢时刻的已用时,用于按阶段取退避节奏。 +func (s *runState) elapsed() time.Duration { + if !s.openAt.IsZero() { + elapsed := time.Since(s.openAt) + if elapsed < 0 { + return 0 + } + return elapsed + } + if s.startedAt.IsZero() { + return 0 + } + return time.Since(s.startedAt) +} + +// stageMultiplier 按已抢购时长返回退避倍率:开抢头 2 秒最激进,5 秒后放慢到 3 倍。 +func stageMultiplier(elapsed time.Duration, category string) int { + if backoffCategory(category) { + switch { + case elapsed < 2*time.Second: + return 1 + case elapsed < 5*time.Second: + return 2 + default: + return 3 + } + } + if elapsed >= 5*time.Second { + return 2 + } + return 1 +} + +// backoffCategory 返回该结局是否需要账号级退避。 +func backoffCategory(category string) bool { + switch category { + case "", CategorySuccess, CategoryNotEnough, CategoryAlreadyDone, CategoryOffShelf: + return false + default: + return true + } +} + +// streakMultiplier 把连续失败次数换算成翻倍系数:第 3 次起 ×2,第 4 次起 ×4。 +func streakMultiplier(streak int) int { + if streak <= 2 { + return 1 + } + mult := 1 + for i := 2; i < streak && mult < 4; i++ { + mult <<= 1 + } + return mult +} + +// sessionAliveCategory 返回该结局是否能证明当前账号的登录态仍然有效。 +// 网络错误、协议异常和限流都不算。 +func sessionAliveCategory(category string) bool { + switch category { + case CategorySuccess, CategoryNotOpen, CategorySoldOut, CategoryNotEnough, + CategoryAlreadyDone, CategoryOffShelf, CategoryCondition, CategoryFailed: + return true + default: + return false + } +} diff --git a/internal/sniper/run_control.go b/internal/sniper/run_control.go new file mode 100644 index 0000000..59aea50 --- /dev/null +++ b/internal/sniper/run_control.go @@ -0,0 +1,462 @@ +package sniper + +import ( + "context" + "errors" + "time" +) + +func newRunState(accounts []Account, targetCount, concurrency int, cfg Config) *runState { + if concurrency <= 0 { + concurrency = 1 + } + state := &runState{ + startedAt: time.Now(), + accounts: make(map[int64]*accountState, len(accounts)), + targetDone: make([]bool, targetCount), + exchanged: make(map[int64]map[int64]bool, len(accounts)), + duplicateNotified: make(map[int64]map[int64]bool, len(accounts)), + firstWaveSent: make(map[int64]bool, len(accounts)), + sem: make(chan struct{}, concurrency), + stats: runStats{ + outcomes: map[string]int{}, + successUIDs: map[int64]bool{}, + requests: map[requestKind]*requestStat{}, + accounts: map[int64]*AccountOutcome{}, + }, + } + for _, account := range accounts { + state.accounts[account.UID] = &accountState{targets: make([]targetState, targetCount)} + } + state.configureWindow(cfg, len(accounts)) + return state +} + +// configureWindow 按配置初始化窗口与首发错峰参数。 +// +// 只有 OpenAt 非零才是窗口模式;正常 GUI 抢购始终是窗口模式。 +func (s *runState) configureWindow(cfg Config, accountCount int) { + if cfg.OpenAt.IsZero() { + return + } + window := time.Duration(cfg.WindowSeconds) * time.Second + if cfg.WindowSeconds <= 0 { + window = DefaultWindowSeconds * time.Second + } + if cfg.windowOverride > 0 { + window = cfg.windowOverride + } + startAhead := cfg.StartAhead + if startAhead <= 0 { + startAhead = DefaultStartAhead + } + s.openAt = cfg.OpenAt + s.startAhead = startAhead + s.windowLen = window + s.deadline = cfg.OpenAt.Add(window) + + if cfg.FirstWaveSpread > 0 { + step := cfg.FirstWaveSpread / time.Duration(maxInt(accountCount-1, 1)) + maxStep := cfg.FirstWaveMaxStep + if maxStep <= 0 { + maxStep = DefaultFirstWaveMaxStep + } + if step > maxStep { + step = maxStep + } + if step < 0 { + step = 0 + } + s.firstWaveSpread = cfg.FirstWaveSpread + s.firstWaveStep = step + } +} + +// windowed 返回是否处于窗口模式。 +func (s *runState) windowed() bool { + return !s.deadline.IsZero() +} + +// windowExpired 返回窗口是否已经结束(非窗口模式永远为 false)。 +func (s *runState) windowExpired() bool { + if s.deadline.IsZero() { + return false + } + return !time.Now().Before(s.deadline) +} + +// windowStart 返回窗口循环应当开始发请求的时刻(开抢时刻减去先手缓冲)。 +func (s *runState) windowStart() time.Time { + return s.openAt.Add(-s.startAhead) +} + +// needsPrefire 返回是否还欠一轮「开抢前预打」。 +// +// 只有距离窗口起点还有一段时间才值得单独预打一轮:正常情况下触发时刻(开抢 − advance) +// 与窗口起点(开抢 − StartAhead)几乎重合,此时再预打一轮就等于把首发齐射打两遍。 +func (s *runState) needsPrefire() bool { + if s.openAt.IsZero() || s.prefireDone { + return false + } + return time.Now().Add(prefireMinGap).Before(s.windowStart()) +} + +func (s *runState) markPrefire() { + s.prefireDone = true +} + +// clipToDeadline 把等待时间裁剪到窗口结束,保证不会睡过窗口。 +func (s *runState) clipToDeadline(delay time.Duration) time.Duration { + if s.deadline.IsZero() { + return delay + } + remaining := time.Until(s.deadline) + if remaining <= 0 { + return 0 + } + if delay > remaining { + return remaining + } + return delay +} + +// noteAttempt 记录所有账号里跑过的最大轮次(用于汇总日志)。 +func (s *runState) noteAttempt(round int) { + s.mu.Lock() + defer s.mu.Unlock() + if round > s.maxAttemptCount { + s.maxAttemptCount = round + } +} + +// maxAttempt 返回所有账号里跑过的最大轮次。 +func (s *runState) maxAttempt() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.maxAttemptCount +} + +// accountFinished 返回该账号是否已经没有可尝试的目标(全部成功或进入终态)。 +func (s *runState) accountFinished(uid int64, targetCount int) bool { + s.mu.Lock() + defer s.mu.Unlock() + account := s.accounts[uid] + if account == nil || account.done { + return true + } + for index := 0; index < targetCount; index++ { + if index < len(s.targetDone) && s.targetDone[index] { + continue + } + if index < len(account.targets) && !account.targets[index].Done { + return false + } + } + return true +} + +// sharedListOnce 保证同一时刻只有一个账号去查询奖品列表,其余账号等它完成后复用结果。 +// +// 只缓存成功的快照:失败结果不缓存,否则一次抖动会让所有账号一起失败。 +func (s *runState) sharedListOnce(ctx context.Context, fetch func() *listSnapshot) *listSnapshot { + s.listMu.Lock() + if s.listSnapshot != nil && time.Since(s.listCachedAt) <= sharedListTTL { + snapshot := s.listSnapshot + s.listMu.Unlock() + return snapshot + } + if inflight := s.listInflight; inflight != nil { + s.listMu.Unlock() + select { + case <-inflight: + case <-ctx.Done(): + return &listSnapshot{err: ctx.Err()} + } + s.listMu.Lock() + snapshot := s.listSnapshot + s.listMu.Unlock() + if snapshot != nil { + return snapshot + } + return &listSnapshot{err: errors.New("奖品列表响应为空")} + } + inflight := make(chan struct{}) + s.listInflight = inflight + s.listMu.Unlock() + + snapshot := fetch() + + s.listMu.Lock() + if snapshot != nil && snapshot.err == nil && snapshot.resp != nil { + s.listSnapshot = snapshot + s.listCachedAt = time.Now() + } + s.listInflight = nil + s.listMu.Unlock() + close(inflight) + return snapshot +} + +// takeFirstWaveDelay 领取本账号的首发错峰等待时间,每个账号只生效一次。 +func (s *runState) takeFirstWaveDelay(uid int64) time.Duration { + if s.firstWaveStep <= 0 { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + if s.firstWaveSent[uid] { + return 0 + } + s.firstWaveSent[uid] = true + slot := s.firstWaveUsed + s.firstWaveUsed++ + return time.Duration(slot) * s.firstWaveStep +} + +// noteOutcome 记录一个账号最近一次兑换类结果,供任务结束时的逐账号结局输出。 +func (s *runState) noteOutcome(result Result) { + switch result.Action { + case "exchange", "exchange_error", "skip_duplicate", "not_found": + default: + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.stats.accounts == nil { + s.stats.accounts = map[int64]*AccountOutcome{} + } + outcome := s.stats.accounts[result.UID] + if outcome == nil { + outcome = &AccountOutcome{UID: result.UID} + s.stats.accounts[result.UID] = outcome + } + if result.Action != "skip_duplicate" { + outcome.Attempts++ + } + outcome.Category = result.Category + outcome.Message = result.Message + if result.Category == CategorySuccess || result.Done { + outcome.Done = true + } +} + +// requestKind 标识一次在途请求的类型,用于汇总日志里的请求次数。 +type requestKind int + +const ( + reqList requestKind = iota + reqExchange + reqScore + reqRecord +) + +// String 返回接口类型的中文名,用于「接口统计」日志。 +func (k requestKind) String() string { + switch k { + case reqList: + return "奖品列表" + case reqExchange: + return "兑换" + case reqScore: + return "积分" + case reqRecord: + return "兑换记录" + default: + return "未知" + } +} + +// acquire 申请一个请求额度;ctx 结束时会返回 false,调用方应直接放弃这次请求。 +func (s *runState) acquire(ctx context.Context) bool { + if s.sem == nil { + return ctx.Err() == nil + } + select { + case s.sem <- struct{}{}: + return true + case <-ctx.Done(): + return false + } +} + +func (s *runState) release() { + if s.sem == nil { + return + } + <-s.sem +} + +// recordRequest 记录一次请求的次数、耗时与错误。 +// +// 除了原来的分类计数,还累计耗时/失败/最后错误,任务结束时输出「接口统计」行, +// 这样复盘时能一眼看出是「请求打不出去(慢/超时)」还是「打出去被拒(限流)」。 +func (s *runState) recordRequest(kind requestKind, cost time.Duration, err error) { + s.mu.Lock() + defer s.mu.Unlock() + switch kind { + case reqList: + s.stats.listReqs++ + case reqExchange: + s.stats.exchangeReqs++ + case reqScore: + s.stats.scoreReqs++ + case reqRecord: + s.stats.recordReqs++ + } + if s.stats.requests == nil { + s.stats.requests = map[requestKind]*requestStat{} + } + stat := s.stats.requests[kind] + if stat == nil { + stat = &requestStat{} + s.stats.requests[kind] = stat + } + stat.observe(cost, err) +} + +// recordOutcome 累计一条结局分类,供任务结束时的汇总日志使用。 +func (s *runState) recordOutcome(category string) { + if category == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.stats.outcomes == nil { + s.stats.outcomes = map[string]int{} + } + s.stats.outcomes[category]++ +} + +func (s *runState) markSuccessUID(uid int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.stats.successUIDs == nil { + s.stats.successUIDs = map[int64]bool{} + } + s.stats.successUIDs[uid] = true +} + +func (s *runState) snapshotStats() runStats { + s.mu.Lock() + defer s.mu.Unlock() + out := runStats{ + listReqs: s.stats.listReqs, + exchangeReqs: s.stats.exchangeReqs, + scoreReqs: s.stats.scoreReqs, + recordReqs: s.stats.recordReqs, + outcomes: make(map[string]int, len(s.stats.outcomes)), + successUIDs: make(map[int64]bool, len(s.stats.successUIDs)), + requests: make(map[requestKind]*requestStat, len(s.stats.requests)), + accounts: make(map[int64]*AccountOutcome, len(s.stats.accounts)), + } + for key, value := range s.stats.outcomes { + out.outcomes[key] = value + } + for uid := range s.stats.successUIDs { + out.successUIDs[uid] = true + } + for kind, stat := range s.stats.requests { + if stat == nil { + continue + } + copied := *stat + out.requests[kind] = &copied + } + for uid, outcome := range s.stats.accounts { + if outcome == nil { + continue + } + copied := *outcome + out.accounts[uid] = &copied + } + return out +} + +// prizeExchanged 判断该账号是否已经成功兑换过这个奖品。 +func (s *runState) prizeExchanged(uid, prizeID int64) bool { + if prizeID <= 0 { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.exchanged[uid][prizeID] +} + +func (s *runState) markPrizeExchanged(uid, prizeID int64) { + if prizeID <= 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.exchanged[uid] == nil { + s.exchanged[uid] = map[int64]bool{} + } + s.exchanged[uid][prizeID] = true +} + +func (s *runState) activeTargets(uid int64, targetCount int) []int { + s.mu.Lock() + defer s.mu.Unlock() + account := s.accounts[uid] + active := make([]int, 0, targetCount) + if account == nil || account.done { + return active + } + for i := 0; i < targetCount; i++ { + if i < len(s.targetDone) && s.targetDone[i] { + continue + } + if i >= len(account.targets) || account.targets[i].Done { + continue + } + active = append(active, i) + } + return active +} + +func (s *runState) markDone(uid int64, index int) { + s.mu.Lock() + defer s.mu.Unlock() + if account := s.accounts[uid]; account != nil && index >= 0 && index < len(account.targets) { + account.targets[index].Done = true + } +} + +// markAccountDone 一次性结束该账号的所有目标。 +func (s *runState) markAccountDone(uid int64) { + s.mu.Lock() + defer s.mu.Unlock() + if account := s.accounts[uid]; account != nil { + account.done = true + for index := range account.targets { + account.targets[index].Done = true + } + } +} + +func (s *runState) markTargetDone(index int) { + s.mu.Lock() + defer s.mu.Unlock() + if index >= 0 && index < len(s.targetDone) { + s.targetDone[index] = true + } +} + +func (s *runState) allDone() bool { + s.mu.Lock() + defer s.mu.Unlock() + for index, globallyDone := range s.targetDone { + if globallyDone { + continue + } + for _, account := range s.accounts { + if account == nil || account.done { + continue + } + if index < len(account.targets) && !account.targets[index].Done { + return false + } + } + } + return true +} diff --git a/internal/sniper/runner.go b/internal/sniper/runner.go new file mode 100644 index 0000000..1df437f --- /dev/null +++ b/internal/sniper/runner.go @@ -0,0 +1,368 @@ +package sniper + +import ( + "context" + "errors" + "sync" + "time" + + "huya-go/internal/huya" +) + +func (r *Runner) Run(ctx context.Context, accounts []Account, target Target, cfg Config, emit func(Result)) error { + return r.RunTargets(ctx, accounts, []Target{target}, cfg, emit) +} + +func (r *Runner) RunTargets(ctx context.Context, accounts []Account, targets []Target, cfg Config, emit func(Result)) error { + cfg = normalizeConfig(cfg) + targets = normalizeTargets(targets) + if len(accounts) == 0 { + return errors.New("账号不能为空") + } + if len(targets) == 0 { + return errors.New("目标奖品不能为空") + } + client := r.client() + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + if cfg.Prewarm { + if err := r.Prewarm(ctx, accounts, cfg, emit); err != nil { + return err + } + } + + state := newRunState(accounts, len(targets), cfg.Concurrency, cfg) + startedAt := time.Now() + // emit 包装一层,顺手记录每账号最终结局;emit 为 nil 时保持 nil, + // 否则成功后的异步刷新会在没有上报通道时也发请求(旧行为不允许)。 + emitResult := emit + if emit != nil { + emitResult = func(result Result) { + state.noteOutcome(result) + emit(result) + } + } + // 成功后的刷新是异步的,等它们结束再返回,避免调用方提前关闭连接; + // 汇总放在最后,才能把刷新产生的请求次数一起带上。 + defer func() { + state.refresh.Wait() + r.emitSummary(state, len(accounts), len(targets), state.maxAttempt(), time.Since(startedAt)) + }() + + runWave := func(loop int, waveAccounts []Account) { + var wg sync.WaitGroup + for _, account := range waveAccounts { + account := account + wg.Add(1) + go func() { + defer wg.Done() + // 并发额度由每次请求自己申请(见 runState.acquire), + // 账号在退避等待期间不占额度。 + r.runAccountRound(ctx, client, state, account, targets, cfg, loop, emitResult, cancel) + }() + } + wg.Wait() + } + + // 开抢前只预打一轮(探是否已开放、顺带验证通道),随后静默等到窗口起点。 + // 旧实现把提前量整段都用来循环重试,代价是先把服务端令牌桶烧掉: + // 2026-10-16 pc-1 实测 1421 次请求里 82% 被限流挡回。 + if state.needsPrefire() { + state.markPrefire() + // 预打只需要探活,不需要把所有账号都打一遍:账号多且网络慢时, + // 全账号预打可能一直打到开抢之后,反而阻塞正式窗口。 + probeAccounts := accounts + if limit := prefireProbeCount(cfg.Concurrency, len(probeAccounts)); limit < len(probeAccounts) { + probeAccounts = probeAccounts[:limit] + } + runWave(1, probeAccounts) + if ctx.Err() != nil { + return ctx.Err() + } + } + + // 无论是否预打,正式请求都不能早于「开抢 − StartAhead」。 + // 非预打分支过去直接进入循环,固定提前量在 250~310ms 时会产生窗口外请求。 + if !state.windowed() || !state.windowExpired() { + if wait := time.Until(state.windowStart()); wait > 0 { + if err := sleepContext(ctx, wait); err != nil { + return err + } + } + } + + // 正式窗口:每个账号在自己的 goroutine 里独立循环到窗口结束(或自己完成), + // 互不等待、互不阻塞。旧实现按「一轮打完等所有人」(wg.Wait)同步,某个账号卡在 + // 一次慢请求上时,其余账号这一轮只能干等(账号少的实例尤其吃亏)。 + // 请求速率由请求级并发闸门兜底:在途请求数被限制在 cfg.Concurrency。 + var wg sync.WaitGroup + for _, account := range accounts { + account := account + wg.Add(1) + go func() { + defer wg.Done() + r.runAccountLoop(ctx, client, state, account, targets, cfg, emitResult, cancel) + }() + } + wg.Wait() + if ctx.Err() != nil { + return ctx.Err() + } + return nil +} + +func (r *Runner) Prewarm(ctx context.Context, accounts []Account, cfg Config, emit func(Result)) error { + cfg = normalizeConfig(cfg) + if len(accounts) == 0 { + return errors.New("账号不能为空") + } + client := r.client() + prewarmer, ok := client.(ActivityPrewarmer) + if !ok { + return nil + } + sem := make(chan struct{}, prewarmConcurrency(cfg.Concurrency)) + var wg sync.WaitGroup + for _, account := range accounts { + account := account + wg.Add(1) + go func() { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + err := prewarmer.PrewarmActivity(ctx, account.UID, account.Cookie, cfg.SID) + result := Result{ + UID: account.UID, + Action: "prewarm", + Category: CategoryReady, + Message: "连接已预热", + } + if err != nil { + result.Category = CategoryError + result.Error = err.Error() + result.Message = "连接预热失败" + } + if emit != nil { + emit(result) + } + }() + } + wg.Wait() + return ctx.Err() +} + +// runAccountLoop 是单个账号的独立抢购循环:每轮尝试所有未完成目标, +// 结束后只按账号级退避等待。控制流与 dygou_go 的 runAccountLoop 保持一致: +// 窗口截止、账号完成、显式停止三种条件之外不再维护 target 级定时器。 +func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, emit func(Result), cancel context.CancelFunc) { + if wait := state.takeFirstWaveDelay(account.UID); wait > 0 { + if err := sleepContext(ctx, wait); err != nil { + return + } + } + + attempts := 0 + for { + if ctx.Err() != nil { + return + } + if cfg.maxAttempts > 0 && attempts >= cfg.maxAttempts { + return + } + if state.allDone() || state.windowExpired() || state.accountFinished(account.UID, len(targets)) { + return + } + + outcome := r.runAccountRound(ctx, client, state, account, targets, cfg, attempts+1, emit, cancel) + if outcome.attempted { + attempts++ + state.noteAttempt(attempts) + } + if outcome.accountDone || ctx.Err() != nil || state.allDone() || state.windowExpired() { + return + } + if wait := state.clipToDeadline(outcome.delay); wait > 0 { + if err := sleepContext(ctx, wait); err != nil { + return + } + } + } +} + +type roundOutcome struct { + attempted bool + delay time.Duration + accountDone bool +} + +// runAccountRound 是单账号的一轮尝试:不区分 target 的冷却时间, +// 每次把所有未完成 target 各尝试一次,最后统一计算一次账号级退避。 +func (r *Runner) runAccountRound(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, round int, emit func(Result), cancel context.CancelFunc) roundOutcome { + active := state.activeTargets(account.UID, len(targets)) + if len(active) == 0 { + return roundOutcome{} + } + attempted := false + categories := make([]string, 0, len(active)) + listTargets := make([]int, 0, len(active)) + + finish := func() roundOutcome { + if attempted { + state.completeRound(account.UID, categories, sessionLostLimit(cfg)) + } + return roundOutcome{ + attempted: attempted, + delay: state.roundDelay(account.UID, cfg, categories), + accountDone: state.accountFinished(account.UID, len(targets)) || ctx.Err() != nil, + } + } + + for _, index := range active { + target := targets[index] + if !shouldDirectExchange(target, cfg) { + listTargets = append(listTargets, index) + continue + } + if state.prizeExchanged(account.UID, int64(target.PID)) { + state.emitSkippedDuplicate(emit, round, account.UID, index, int64(target.PID), target) + continue + } + result := r.exchangeTarget(ctx, client, state, account, target, index, round, cfg.SID, nil) + if result.Action == actionExchangeCanceled { + return finish() + } + attempted = true + result = r.applyExchangeDecision(state, account, index, cfg, result, cancel) + categories = append(categories, result.Category) + if result.Category == CategorySuccess { + state.markPrizeExchanged(account.UID, int64(target.PID)) + } + if emit != nil { + emit(result) + } + r.refreshAfterSuccess(state, client, account, target, index, round, cfg, result, emit) + if ctx.Err() != nil || state.accountFinished(account.UID, len(targets)) { + return finish() + } + } + if len(listTargets) == 0 || ctx.Err() != nil { + return finish() + } + + list := r.listForAccount(ctx, client, account, cfg, state) + if ctx.Err() != nil || state.windowExpired() { + return finish() + } + attempted = true + if list.err != nil || list.resp == nil { + errText := "奖品列表响应为空" + if list.err != nil { + errText = list.err.Error() + } + categories = append(categories, CategoryError) + for _, index := range listTargets { + state.recordOutcome(CategoryError) + result := Result{ + Loop: round, + UID: account.UID, + TargetIndex: index, + Target: targets[index], + Action: "list_error", + Category: CategoryError, + Message: "查询奖品列表失败", + Error: errText, + } + if emit != nil { + emit(result) + } + } + return finish() + } + + prizes := list.resp.FilterByCategory(cfg.Category) + for _, index := range listTargets { + target := targets[index] + prize := matchPrize(prizes, target) + if prize == nil { + state.recordOutcome(CategoryNotFound) + categories = append(categories, CategoryNotFound) + if emit != nil { + emit(Result{ + Loop: round, + UID: account.UID, + TargetIndex: index, + Target: target, + Action: "not_found", + Category: CategoryNotFound, + Message: notFoundMessage(cfg.Category), + }) + } + continue + } + if state.prizeExchanged(account.UID, prize.ID) { + state.emitSkippedDuplicate(emit, round, account.UID, index, prize.ID, target) + continue + } + result := r.exchangeTarget(ctx, client, state, account, target, index, round, cfg.SID, prize) + if result.Action == actionExchangeCanceled { + return finish() + } + result = r.applyExchangeDecision(state, account, index, cfg, result, cancel) + categories = append(categories, result.Category) + if result.Category == CategorySuccess { + state.markPrizeExchanged(account.UID, prize.ID) + } + if emit != nil { + emit(result) + } + r.refreshAfterSuccess(state, client, account, target, index, round, cfg, result, emit) + if ctx.Err() != nil || state.accountFinished(account.UID, len(targets)) { + return finish() + } + } + return finish() +} + +func (r *Runner) client() huya.ActivityClient { + if r.Client != nil { + return r.Client + } + return huya.NewHTTPClient(nil) +} + +// prefireProbeCount 限制开抢前探活账号数:探活只需要少量样本, +// 即使抢购并发设到 100,也不能在开抢前齐射一百个请求。 +func prefireProbeCount(configured, total int) int { + if total <= 0 { + return 0 + } + if configured <= 0 { + configured = 1 + } + if configured > 8 { + configured = 8 + } + if configured > total { + return total + } + return configured +} + +// prewarmConcurrency 给建连预热单独限流:抢购请求仍然严格按 Concurrency, +// 但预热必须在开抢前 2 秒内完成。沿用 5 个并发时,几十上百个账号很容易 +// 预热到开抢之后才返回,反而错过整个窗口。 +func prewarmConcurrency(configured int) int { + if configured < 8 { + configured = 8 + } + if configured > 16 { + configured = 16 + } + return configured +} diff --git a/internal/sniper/sniper.go b/internal/sniper/sniper.go index dd041d8..01c769e 100644 --- a/internal/sniper/sniper.go +++ b/internal/sniper/sniper.go @@ -1,13 +1,19 @@ +// Package sniper 实现虎牙活动抢购。 +// +// 控制流参考 dygou_go,按职责拆分为: +// - runner.go: 任务入口与每账号独立 attempt-loop +// - run_control.go: 窗口、首发错峰、并发闸门与账号状态 +// - retry.go: 账号级连续失败计数和统一退避 +// - exchange.go: 单次兑换、成功刷新和共享列表 +// - classify.go: 响应文案与目标匹配 +// +// 每个账号每轮尝试所有未完成目标,结束后只等待一次;不存在 target 级重试定时器, +// 也不再有全局自适应降速器。 package sniper import ( "context" - "errors" - "fmt" - "math/rand" "sort" - "strconv" - "strings" "sync" "time" @@ -65,25 +71,17 @@ const ( // (参考 dygou_go:把任何一次等待都压在 2 秒以内)。 maxRetryDelay = 1500 * time.Millisecond - // governorMinRequests / governorStallWindow / governorMaxFactor 控制全局自适应降速。 - // - // 触发信号刻意选「无进展」而不是「限流占比」:2026-10-16 的日志显示,即使成功率 - // 97.6% 的那一次,被限流的请求也占 67%,用占比阈值会把正常运行一起拖慢。 - // 真正的危险信号是「发了一堆请求、连一个业务响应都拿不到」,说明整机已经压在 - // 服务端令牌桶上(pc-1 峰值 122 req/s)。此时按 1.5 倍逐级降速,一旦恢复进展就快速回退。 - governorMinRequests = 12 - governorStallWindow = 800 * time.Millisecond - governorMaxFactor = 4.0 - governorStep = 1.5 - - // defaultRetryBackoff 是配置缺失时的退避基准(界面默认值同样是 300ms)。 + // defaultRetryBackoff 是分类没有给出更具体退避值时的基准。 defaultRetryBackoff = 300 * time.Millisecond // prefireMinGap 是「还值得单独预打一轮」的最小时间余量:小于它就说明已经在窗口起点附近, // 直接进入窗口循环即可,避免把首发齐射打两遍。 prefireMinGap = 250 * time.Millisecond // defaultSessionLostLimit 是「连续会话失效多少次后停止该账号」的默认阈值。 - // 实测同一账号下一轮就能成功,所以阈值不能太小(3 次足以排除偶发抖动)。 - defaultSessionLostLimit = 3 + // + // 「账号未绑定!」在开抢洪峰里经常只是服务端限流/路由抖动:2026-10-17 的 24 个 + // 任务里,CK 预检全部可用,却出现 372 次该提示。旧阈值 3 会让一个有效 CK 在 + // 连续三次洪峰回包后被提前踢出整场抢购,因此默认抬高到 8;显式配置仍可覆盖。 + defaultSessionLostLimit = 8 // sharedListTTL 是共享奖品列表快照(SharePrizeList)的存活时间。 // @@ -93,86 +91,6 @@ const ( sharedListTTL = 1000 * time.Millisecond ) -// TargetSummary 把目标列表渲染成「13437(冠军之刃)」这样的单行摘要,供启动日志使用。 -// -// 2026-10-16 的复盘正卡在「日志里没有目标 pid」:三台机器都只记了 sid 与 targets=1, -// 看不出各自打的是哪个奖品,也就无法解释为什么一台售罄、另一台库存完好。 -func TargetSummary(targets []Target) string { - parts := make([]string, 0, len(targets)) - for _, target := range targets { - name := strings.TrimSpace(target.Name) - switch { - case target.PID > 0 && name != "": - parts = append(parts, fmt.Sprintf("%d(%s)", target.PID, name)) - case target.PID > 0: - parts = append(parts, strconv.FormatInt(int64(target.PID), 10)) - case name != "": - parts = append(parts, name) - case target.Auto: - parts = append(parts, "自动选择") - } - } - if len(parts) == 0 { - return "未指定" - } - return strings.Join(parts, ",") -} - -// CategoryLabel 返回结局分类的中文短标签,供任务结束时的结局日志使用。 -func CategoryLabel(category string) string { - switch category { - case CategorySuccess: - return "已抢到" - case CategorySoldOut: - return "售罄" - case CategoryTooFrequent: - return "被限流" - case CategoryNotOpen: - return "未开放" - case CategoryNotEnough: - return "积分不足" - case CategoryAlreadyDone: - return "已兑换过" - case CategoryOffShelf: - return "已下架" - case CategorySessionLost: - return "会话失效" - case CategoryProtocol: - return "协议异常" - case CategoryCondition: - return "条件未达成" - case CategoryNotFound: - return "未匹配到奖品" - case CategoryError: - return "请求异常" - default: - return "失败" - } -} - -// CategoryText 把类目限制渲染成日志文案(空串表示不限类目)。 -func CategoryText(category string) string { - key := strings.TrimSpace(category) - if key == "" { - return "不限" - } - return key -} - -// LogLevelForCategory 把结果分类映射成日志等级,供界面按等级高亮与筛选。 -func LogLevelForCategory(category string) string { - switch category { - case CategorySuccess: - return "success" - case CategoryError, CategoryProtocol: - return "error" - case CategoryFailed, CategoryTooFrequent, CategoryCondition, CategorySessionLost: - return "warn" - default: - return "info" - } -} - type Account struct { UID int64 `json:"uid"` Cookie string `json:"-"` @@ -190,8 +108,10 @@ type Config struct { SID int32 // Category 限定只在某个类目(限量返场/精英专享,或类目 id)里挑选可兑奖品, // 留空表示不限制。见 huya.GetActPrizeListResp.FilterByCategory。 - Category string - Loops int + Category string + // maxAttempts 只供测试和库调用做有限轮次保护;GUI 不再暴露轮次上限, + // 正常抢购始终由 OpenAt + WindowSeconds 的窗口截止时间控制。 + maxAttempts int Delay time.Duration Concurrency int StopAfterSuccess bool @@ -213,9 +133,7 @@ type Config struct { // OpenAt 是本次抢购的开抢时刻(已按服务端时钟对齐的本机时刻)。 // - // 非零即「窗口模式」:终止条件由 OpenAt+WindowSeconds 决定,Loops 只作为可选的 - // 轮次上限。这样账号少时也不会像旧实现那样「20 轮 × 150ms = 3 秒」在开抢前把预算烧光 - // (2026-10-16 pc-3 instance-4 只有一个账号,20 轮全部打在开抢前 0.3 秒,0 成功)。 + // 非零即「窗口模式」:终止条件由 OpenAt+WindowSeconds 决定。 OpenAt time.Time // WindowSeconds 是窗口长度(秒);窗口模式下 <=0 时取 DefaultWindowSeconds。 WindowSeconds int @@ -225,12 +143,7 @@ type Config struct { // FirstWaveSpread<=0 表示不启用错峰(测试与直接调用 RunTargets 的老行为)。 FirstWaveSpread time.Duration FirstWaveMaxStep time.Duration - // DisableAdaptiveThrottle 关闭全局自适应降速。 - // - // 默认开启(Go 零值无法表达「默认开」,所以这里用反向开关):产品入口(GUI/CLI) - // 默认不设置它,也就是开启;只有显式配置才关闭。 - DisableAdaptiveThrottle bool - // SessionLostLimit 是同一账号连续多少次「会话失效」后停止该账号;<=0 时取默认值 3。 + // SessionLostLimit 是同一账号连续多少次「会话失效」后停止该账号;<=0 时取默认值。 SessionLostLimit int // windowOverride 仅供测试:非零时直接作为窗口长度,避免「秒」粒度把单测拖慢。 @@ -250,7 +163,6 @@ type Result struct { Score *huya.GetUserScoreResp `json:"score,omitempty"` Records *huya.GetUserPrizeRecordsResp `json:"records,omitempty"` Done bool `json:"done,omitempty"` - RetryAfter int64 `json:"retry_after,omitempty"` Error string `json:"error,omitempty"` } @@ -264,7 +176,7 @@ type Runner struct { // RunSummary 汇总一次抢购任务的请求次数与结局分布,供日志复盘。 // 形状参考 dygou_go 的 logRunSummary:抢购结束后只打一行,替代逐条刷屏。 type RunSummary struct { - Loops int + Attempts int Accounts int Targets int Duration time.Duration @@ -281,8 +193,6 @@ type RunSummary struct { // WindowSeconds / OpenAt 描述本次窗口;非窗口模式时为零值。 WindowSeconds int OpenAt time.Time - // ThrottleFactor 是自适应降速用到的最大倍率(1 表示全程没有触发降速)。 - ThrottleFactor float64 } // AccountOutcome 是单个账号在一次任务里的最终结局。 @@ -354,7 +264,7 @@ type ActivityPrewarmer interface { type runState struct { mu sync.Mutex - accounts map[int64][]targetState + accounts map[int64]*accountState targetDone []bool // exchanged 记录每个账号已成功兑换的奖品,避免多个 target 命中同一奖品时重复兑换。 exchanged map[int64]map[int64]bool @@ -364,8 +274,7 @@ type runState struct { // refresh 用于等待成功后的异步刷新,确保 RunTargets 返回前不会提前释放连接。 refresh sync.WaitGroup // sem 是请求级并发闸门:一个额度对应一次在途请求,账号在退避等待期间 - // 不再占用额度(参考 dygou_go runControl.enter)。旧实现把额度给整个账号 - // 循环,退避中的账号会一直霸占额度,账号多时互相饿死。 + // 不再占用额度(参考 dygou_go runControl.enter)。 sem chan struct{} // stats 记录请求次数与结局分布,任务结束时汇总成一行日志。 stats runStats @@ -382,10 +291,8 @@ type runState struct { firstWaveStep time.Duration firstWaveUsed int firstWaveSent map[int64]bool - // governor 是全局自适应降速器;DisableAdaptiveThrottle 时为 nil(空操作)。 - governor *throttleGovernor - // rounds 记录所有账号里跑过的最大轮次,供汇总日志使用。 - rounds int + // maxAttemptCount 记录所有账号里跑过的最大实际尝试次数,供汇总日志使用。 + maxAttemptCount int // listMu 保护共享列表快照(SharePrizeList 模式):listInflight 非空表示 // 已有账号在查询,其余账号等它完成后复用结果。 listMu sync.Mutex @@ -409,1710 +316,19 @@ type runStats struct { } type targetState struct { - Done bool - RetryAfter time.Time - // FailStreak 是连续可重试失败的次数,用于放大退避(成功/正常等待类结局会清零)。 - FailStreak int - // SessionStreak 是连续「会话失效」次数,达到阈值即停止该账号该奖品。 - SessionStreak int + Done bool +} + +// accountState 只保存账号级状态。退避不再按 target 分别登记: +// 一个账号一轮的所有失败合并成一次连续失败计数,等待也只有一个账号级时长。 +type accountState struct { + targets []targetState + done bool + failStreak int + sessionStreak int } type listSnapshot struct { resp *huya.GetActPrizeListResp err error } - -func (r *Runner) Run(ctx context.Context, accounts []Account, target Target, cfg Config, emit func(Result)) error { - return r.RunTargets(ctx, accounts, []Target{target}, cfg, emit) -} - -func (r *Runner) RunTargets(ctx context.Context, accounts []Account, targets []Target, cfg Config, emit func(Result)) error { - cfg = normalizeConfig(cfg) - targets = normalizeTargets(targets) - if len(accounts) == 0 { - return errors.New("账号不能为空") - } - if len(targets) == 0 { - return errors.New("目标奖品不能为空") - } - client := r.client() - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - if cfg.Prewarm { - if err := r.Prewarm(ctx, accounts, cfg, emit); err != nil { - return err - } - } - - state := newRunState(accounts, len(targets), cfg.Concurrency, cfg) - startedAt := time.Now() - // emit 包装一层,顺手记录每账号最终结局;emit 为 nil 时保持 nil, - // 否则成功后的异步刷新会在没有上报通道时也发请求(旧行为不允许)。 - emitResult := emit - if emit != nil { - emitResult = func(result Result) { - state.noteOutcome(result) - emit(result) - } - } - // 成功后的刷新是异步的,等它们结束再返回,避免调用方提前关闭连接; - // 汇总放在最后,才能把刷新产生的请求次数一起带上。 - defer func() { - state.refresh.Wait() - r.emitSummary(state, len(accounts), len(targets), state.maxRound(), time.Since(startedAt)) - }() - - runWave := func(loop int, waveAccounts []Account) { - var wg sync.WaitGroup - for _, account := range waveAccounts { - account := account - wg.Add(1) - go func() { - defer wg.Done() - // 并发额度由每次请求自己申请(见 runState.acquire), - // 账号在退避等待期间不占额度。 - r.runAccountRound(ctx, client, state, account, targets, cfg, loop, emitResult, cancel) - }() - } - wg.Wait() - } - - // 开抢前只预打一轮(探是否已开放、顺带验证通道),随后静默等到窗口起点。 - // 旧实现把提前量整段都用来循环重试,代价是先把服务端令牌桶烧掉: - // 2026-10-16 pc-1 实测 1421 次请求里 82% 被限流挡回。 - if state.needsPrefire() { - state.markPrefire() - // 预打只需要探活,不需要把所有账号都打一遍:账号多且网络慢时, - // 全账号预打可能一直打到开抢之后,反而阻塞正式窗口。 - probeAccounts := accounts - if limit := prefireProbeCount(cfg.Concurrency, len(probeAccounts)); limit < len(probeAccounts) { - probeAccounts = probeAccounts[:limit] - } - runWave(1, probeAccounts) - if ctx.Err() != nil { - return ctx.Err() - } - } - - // 无论是否预打,正式请求都不能早于「开抢 − StartAhead」。 - // 非预打分支过去直接进入循环,固定提前量在 250~310ms 时会产生窗口外请求。 - if !state.windowed() || !state.windowExpired() { - if wait := time.Until(state.windowStart()); wait > 0 { - if err := sleepContext(ctx, wait); err != nil { - return err - } - } - } - - // 正式窗口:每个账号在自己的 goroutine 里独立循环到窗口结束(或自己完成), - // 互不等待、互不阻塞。旧实现按「一轮打完等所有人」(wg.Wait)同步,某个账号卡在 - // 一次慢请求上时,其余账号这一轮只能干等(账号少的实例尤其吃亏)。 - // 请求速率由请求级并发闸门兜底:在途请求数被限制在 cfg.Concurrency。 - var wg sync.WaitGroup - for _, account := range accounts { - account := account - wg.Add(1) - go func() { - defer wg.Done() - r.runAccountLoop(ctx, client, state, account, targets, cfg, emitResult, cancel) - }() - } - wg.Wait() - if ctx.Err() != nil { - return ctx.Err() - } - return nil -} - -func (r *Runner) Prewarm(ctx context.Context, accounts []Account, cfg Config, emit func(Result)) error { - cfg = normalizeConfig(cfg) - if len(accounts) == 0 { - return errors.New("账号不能为空") - } - client := r.client() - prewarmer, ok := client.(ActivityPrewarmer) - if !ok { - return nil - } - sem := make(chan struct{}, prewarmConcurrency(cfg.Concurrency)) - var wg sync.WaitGroup - for _, account := range accounts { - account := account - wg.Add(1) - go func() { - defer wg.Done() - select { - case sem <- struct{}{}: - defer func() { <-sem }() - case <-ctx.Done(): - return - } - err := prewarmer.PrewarmActivity(ctx, account.UID, account.Cookie, cfg.SID) - result := Result{ - UID: account.UID, - Action: "prewarm", - Category: CategoryReady, - Message: "连接已预热", - } - if err != nil { - result.Category = CategoryError - result.Error = err.Error() - result.Message = "连接预热失败" - } - if emit != nil { - emit(result) - } - }() - } - wg.Wait() - return ctx.Err() -} - -// runAccountLoop 是单个账号的独立抢购循环:按自己的节奏重试,直到窗口结束、 -// 自己完成或任务被停止。账号之间不互相等待,也不共享节奏。 -func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, emit func(Result), cancel context.CancelFunc) { - // 首发错峰:第 0 个账号立即发,其余按小步长依次错开,避免同机上百个账号 - // 在同一毫秒齐射把整机压上服务端令牌桶。 - if wait := state.takeFirstWaveDelay(account.UID); wait > 0 { - if err := sleepContext(ctx, wait); err != nil { - return - } - } - - for round := 1; ; round++ { - if ctx.Err() != nil { - return - } - // Loops>0 是显式轮次上限(非窗口模式与测试用);窗口模式下留空表示打到窗口结束。 - if cfg.Loops > 0 && round > cfg.Loops { - return - } - if state.allDone() || state.windowExpired() || state.accountFinished(account.UID, len(targets)) { - return - } - state.noteRound(round) - r.runAccountRound(ctx, client, state, account, targets, cfg, round, emit, cancel) - if ctx.Err() != nil || state.allDone() || state.windowExpired() || - state.accountFinished(account.UID, len(targets)) { - return - } - // 本账号自己的节奏:固定等 cfg.Delay。退避中的目标会在下一轮被 activeTargets 跳过, - // 所以这里不需要额外等待退避时间(等待本身也会被窗口末尾裁剪)。 - if wait := state.clipToDeadline(cfg.Delay); wait > 0 { - if err := sleepContext(ctx, wait); err != nil { - return - } - } - } -} - -// runAccountRound 是单账号的一轮尝试:按目标做一次兑换(或多个目标各一次)。 -func (r *Runner) runAccountRound(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, loop int, emit func(Result), cancel context.CancelFunc) { - active := state.activeTargets(account.UID, len(targets), time.Now()) - if len(active) == 0 { - return - } - - listTargets := make([]int, 0, len(active)) - for _, index := range active { - target := targets[index] - if shouldDirectExchange(target, cfg) { - // 直兑路径没有奖品列表,用 target.PID 判断是否已经兑换过同一奖品。 - if state.prizeExchanged(account.UID, int64(target.PID)) { - state.emitSkippedDuplicate(emit, loop, account.UID, index, int64(target.PID), target) - continue - } - result := r.exchangeTarget(ctx, client, state, account, target, index, loop, cfg.SID, nil) - if result.Action == actionExchangeCanceled { - return - } - result = r.applyExchangeDecision(state, account, index, cfg, result, cancel) - if result.Category == CategorySuccess { - state.markPrizeExchanged(account.UID, int64(target.PID)) - } - if emit != nil { - emit(result) - } - r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit) - if ctx.Err() != nil || state.accountFinished(account.UID, len(targets)) { - return - } - continue - } - listTargets = append(listTargets, index) - } - if len(listTargets) == 0 || ctx.Err() != nil { - return - } - - // SharePrizeList 打开时,多账号复用同一份列表快照(带 TTL 的单飞缓存), - // 避免每个账号每一轮都各查一次列表。 - list := r.listForAccount(ctx, client, account, cfg, state) - if ctx.Err() != nil || state.windowExpired() { - // 停止任务或窗口结束时,不要为每个目标补一条 list_error。 - return - } - if list.err != nil || list.resp == nil { - errText := "奖品列表响应为空" - if list.err != nil { - errText = list.err.Error() - } - for _, index := range listTargets { - retryAt := time.Now().Add(jitterDuration(cfg.ErrorBackoff)) - state.setRetryAfter(account.UID, index, retryAt) - state.recordOutcome(CategoryError) - result := Result{ - Loop: loop, - UID: account.UID, - TargetIndex: index, - Target: targets[index], - Action: "list_error", - Category: CategoryError, - Message: "查询奖品列表失败", - RetryAfter: retryAt.UnixMilli(), - Error: errText, - } - if emit != nil { - emit(result) - } - } - return - } - - // 只按目标类目匹配:类目为空表示不限制(限量返场/精英专享都参与)。 - prizes := list.resp.FilterByCategory(cfg.Category) - for _, index := range listTargets { - target := targets[index] - prize := matchPrize(prizes, target) - if prize == nil { - state.recordOutcome(CategoryNotFound) - retryAt := state.scheduleRetry(account.UID, index, cfg, CategoryNotFound) - result := Result{ - Loop: loop, - UID: account.UID, - TargetIndex: index, - Target: target, - Action: "not_found", - Category: CategoryNotFound, - Message: notFoundMessage(cfg.Category), - RetryAfter: retryAt, - } - if emit != nil { - emit(result) - } - continue - } - // 多个 target 可能命中同一个奖品(例如按不同名称匹配),已成功兑换过就跳过。 - if state.prizeExchanged(account.UID, prize.ID) { - state.emitSkippedDuplicate(emit, loop, account.UID, index, prize.ID, target) - continue - } - result := r.exchangeTarget(ctx, client, state, account, target, index, loop, cfg.SID, prize) - if result.Action == actionExchangeCanceled { - return - } - result = r.applyExchangeDecision(state, account, index, cfg, result, cancel) - if result.Category == CategorySuccess { - state.markPrizeExchanged(account.UID, prize.ID) - } - if emit != nil { - emit(result) - } - r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit) - if ctx.Err() != nil || state.accountFinished(account.UID, len(targets)) { - return - } - } -} - -// emitSkippedDuplicate 在跳过同一奖品的重复兑换时给出明确结果,便于界面观察。 -// -// 每个「账号 + 奖品」只提示一次:窗口模式下每轮都会跳过同一个奖品, -// 不记录会刷出一整个窗口的重复提示(但请求数不会增加)。 -func (s *runState) emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, prizeID int64, target Target) { - if s.markDuplicateNotified(uid, prizeID) { - s.recordOutcome(CategoryAlreadyDone) - } - if emit == nil { - return - } - emit(Result{ - Loop: loop, - UID: uid, - TargetIndex: index, - Target: target, - Action: "skip_duplicate", - Category: CategoryAlreadyDone, - Message: "该奖品本账号已兑换成功,跳过重复兑换", - }) -} - -// markDuplicateNotified 记录并返回是否是首次提示。 -func (s *runState) markDuplicateNotified(uid, prizeID int64) bool { - s.mu.Lock() - defer s.mu.Unlock() - if s.duplicateNotified[uid] == nil { - s.duplicateNotified[uid] = map[int64]bool{} - } - if s.duplicateNotified[uid][prizeID] { - return false - } - s.duplicateNotified[uid][prizeID] = true - return true -} - -func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, state *runState, account Account, target Target, index int, loop int, sid int32, prize *huya.PrizeInfo) Result { - pid := target.PID - if prize != nil { - pid = int32(prize.ID) - } - result := Result{ - Loop: loop, - UID: account.UID, - TargetIndex: index, - Target: target, - Action: "exchange", - Prize: prize, - } - if !state.acquire(ctx) { - result.Action = actionExchangeCanceled - result.Category = CategoryError - result.Message = "任务已取消,未发起兑换" - result.Error = ctx.Err().Error() - return result - } - // 排队等额度期间窗口可能已经结束,不能把请求补发到窗口外。 - if state.windowExpired() { - state.release() - result.Action = actionExchangeCanceled - result.Category = CategoryError - result.Message = "窗口已结束,未发起兑换" - return result - } - started := time.Now() - exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid) - state.release() - state.recordRequest(reqExchange, time.Since(started), err) - result.Exchange = exchange - result.Category = ClassifyExchange(exchange, err) - result.Message = ExchangeMessage(exchange) - if err != nil { - result.Action = "exchange_error" - result.Error = err.Error() - if result.Message == "" { - result.Message = "兑换请求失败" - } - } - return result -} - -func (r *Runner) applyExchangeDecision(state *runState, account Account, index int, cfg Config, result Result, cancel context.CancelFunc) Result { - state.recordOutcome(result.Category) - state.observeThrottle(account.UID, result.Category) - if result.Category == CategorySuccess { - state.markSuccessUID(account.UID) - } - switch result.Category { - case CategorySuccess: - if cfg.StopAccountAfterSuccess { - state.markAccountDone(account.UID) - result.Done = true - } else { - state.markDone(account.UID, index) - } - if cfg.StopAfterSuccess { - cancel() - } - case CategoryNotEnough, CategoryAlreadyDone: - if cfg.StopOnNotEnough { - state.markDone(account.UID, index) - result.Done = true - } - // 未开启「积分不足即停」时也要退避,避免每轮都重复撞同一个终态。 - if !result.Done { - result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category) - } - case CategoryOffShelf: - // 已下架 / 奖品不存在:本窗口内不会再出现,直接终止该 target。 - state.markTargetDone(index) - result.Done = true - case CategorySessionLost: - // 会话失效实测是瞬时的(下一轮即可成功),所以先照常重试; - // 只有连续多次才停止整个账号,避免无效 CK 把其他目标也打满整个窗口。 - streak := state.bumpSessionStreak(account.UID, index) - if limit := sessionLostLimit(cfg); streak >= limit { - state.markAccountDone(account.UID) - result.Done = true - result.Message = fmt.Sprintf("会话连续失效 %d 次,停止该账号", streak) - } else { - result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category) - } - case CategorySoldOut, CategoryNotOpen: - if result.Category == CategorySoldOut && cfg.StopOnSoldOut { - state.markTargetDone(index) - result.Done = true - } else { - // 售罄可能回流;未开放通常只是还没到点。两者都继续等待,但必须退避, - // 否则窗口内会以配置的 Delay 持续轰同一个接口。 - result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category) - } - case CategoryTooFrequent: - result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category) - case CategoryError, CategoryProtocol, CategoryFailed, CategoryCondition: - result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category) - } - return result -} - -// sessionLostLimit 返回「连续会话失效多少次后停止该账号」的阈值。 -func sessionLostLimit(cfg Config) int { - if cfg.SessionLostLimit > 0 { - return cfg.SessionLostLimit - } - return defaultSessionLostLimit -} - -// scheduleRetry 计算并登记某个账号下一次尝试的时间,返回毫秒时间戳供日志展示。 -func (s *runState) scheduleRetry(uid int64, index int, cfg Config, category string) int64 { - delay := s.retryDelay(uid, index, cfg, category) - // 窗口模式下退避不能跨过窗口结束,否则这段等待纯属浪费。 - if !s.deadline.IsZero() { - remaining := time.Until(s.deadline) - if remaining <= 0 { - delay = 0 - } else if delay > remaining { - delay = remaining - } - } - when := time.Now().Add(delay) - s.setRetryAfter(uid, index, when) - return when.UnixMilli() -} - -// retryDelay 计算账号下一次尝试前的退避时间。 -// -// 阶梯参考 dygou_go 的 retryDelay: -// 1. 基础值来自界面配置(ErrorBackoff / TooFrequentBackoff,默认 300ms); -// 2. 按「已抢购时长」分档放大:开抢瞬间要快,窗口后段自然放慢(库存回流概率低); -// 3. 同一账号连续失败再翻倍(上限 ×4),避免单个账号被风控盯上; -// 4. 乘全局自适应降速倍率(限流风暴时整体降速); -// 5. 叠加 0-25% 抖动,避免整批账号同一时刻重试形成尖峰; -// 6. 任何一次等待都不超过 maxRetryDelay——超过 1.5 秒就可能正好错过回流库存。 -func (s *runState) retryDelay(uid int64, index int, cfg Config, category string) time.Duration { - base := cfg.ErrorBackoff - switch category { - case CategoryTooFrequent: - base = cfg.TooFrequentBackoff - case CategoryNotOpen: - // 开抢边沿的首个请求可能只早了十几毫秒,不能用 300ms 直接错过先手; - // 先按账号节奏快速重试,连续未开放再逐级放大。 - base = maxDuration(cfg.Delay, 50*time.Millisecond) - } - if base <= 0 { - base = defaultRetryBackoff - } - streak := s.bumpFailStreak(uid, index, category) - delay := time.Duration(stageMultiplier(s.elapsed(), category)) * base - if mult := streakMultiplier(streak); mult > 1 { - delay *= time.Duration(mult) - } - if delay > maxRetryDelay { - delay = maxRetryDelay - } - if factor := s.throttleFactor(); factor > 1 { - delay = time.Duration(float64(delay) * factor) - } - // 全局降速倍率和抖动必须在最后再裁剪,否则 1.5s 的上限会被放大到 6~7s, - // 正好错过库存回流窗口。 - delay = jitterDuration(delay) - if delay > maxRetryDelay { - delay = maxRetryDelay - } - return delay -} - -// elapsed 返回相对开抢时刻的已用时,用于按阶段取退避节奏。 -// 定时模式可能在开抢前数秒就启动(预打/等待),如果按 startedAt 计算, -// 窗口一开始就已经进入「后段降速」,会白白牺牲开抢头几秒。 -func (s *runState) elapsed() time.Duration { - if !s.openAt.IsZero() { - elapsed := time.Since(s.openAt) - if elapsed < 0 { - return 0 - } - return elapsed - } - if s.startedAt.IsZero() { - return 0 - } - return time.Since(s.startedAt) -} - -// stageMultiplier 按已抢购时长返回退避倍率:开抢头 2 秒最激进,5 秒后放慢到 3 倍。 -func stageMultiplier(elapsed time.Duration, category string) int { - if escalatingCategory(category) { - switch { - case elapsed < 2*time.Second: - return 1 - case elapsed < 5*time.Second: - return 2 - default: - return 3 - } - } - // 售罄 / 未开放这类「正常等待」也随窗口放慢,但基数保持 1:库存回流仍要探到。 - if elapsed >= 5*time.Second { - return 2 - } - return 1 -} - -// escalatingCategory 返回该分类是否参与「连续失败翻倍」的统计。 -func escalatingCategory(category string) bool { - switch category { - case CategoryFailed, CategoryTooFrequent, CategoryError, CategoryProtocol, CategorySessionLost, CategoryCondition, CategoryNotOpen: - return true - default: - return false - } -} - -// streakMultiplier 把连续失败次数换算成翻倍系数:第 3 次起 ×2,第 4 次起 ×4。 -// -// 刻意从第 3 次才翻倍:前两次保持配置里的最快节奏,抢购前期不该因为一次失败就变慢。 -func streakMultiplier(streak int) int { - if streak <= 2 { - return 1 - } - mult := 1 - for i := 2; i < streak && mult < 4; i++ { - mult <<= 1 - } - return mult -} - -// bumpFailStreak 累加账号的连续失败次数;非失败类结局会把它清零。 -func (s *runState) bumpFailStreak(uid int64, index int, category string) int { - s.mu.Lock() - defer s.mu.Unlock() - items := s.accounts[uid] - if index < 0 || index >= len(items) { - return 1 - } - if !escalatingCategory(category) { - items[index].FailStreak = 0 - s.accounts[uid] = items - return 1 - } - items[index].FailStreak++ - streak := items[index].FailStreak - s.accounts[uid] = items - return streak -} - -// bumpSessionStreak 累加账号的连续会话失效次数。 -func (s *runState) bumpSessionStreak(uid int64, index int) int { - s.mu.Lock() - defer s.mu.Unlock() - items := s.accounts[uid] - if index < 0 || index >= len(items) { - return 0 - } - items[index].SessionStreak++ - streak := items[index].SessionStreak - s.accounts[uid] = items - return streak -} - -// observeThrottle 把结局交给全局降速器。只有明确拿到业务响应时才清空当前账号的 -// 会话失效连续计数;限流与网络错误不能证明会话有效。 -func (s *runState) observeThrottle(uid int64, category string) { - if sessionAliveCategory(category) { - s.clearSessionStreak(uid) - } - if s.governor != nil { - s.governor.observe(category) - } -} - -// sessionAliveCategory 返回该结局是否能证明当前账号的登录态仍然有效。 -// 网络错误、协议异常和限流都不算:它们没有拿到可信的业务响应。 -func sessionAliveCategory(category string) bool { - switch category { - case CategorySuccess, CategoryNotOpen, CategorySoldOut, CategoryNotEnough, - CategoryAlreadyDone, CategoryOffShelf, CategoryCondition, CategoryFailed: - return true - default: - return false - } -} - -// clearSessionStreak 在某个账号拿到非会话失效结局后,清空该账号的连续失效计数。 -// -// 必须按账号清理,不能全局清理:多账号同时抢购时,其他账号的成功会不断把 -// 一个无效 CK 的 session_lost 计数归零,导致它永远达不到停止阈值。 -// 同理,这段逻辑不能依赖自适应降速器是否开启。 -func (s *runState) clearSessionStreak(uid int64) { - s.mu.Lock() - defer s.mu.Unlock() - items := s.accounts[uid] - changed := false - for index := range items { - if items[index].SessionStreak != 0 { - items[index].SessionStreak = 0 - changed = true - } - } - if changed { - s.accounts[uid] = items - } -} - -// throttleFactor 返回当前的自适应降速倍率(未启用时恒为 1)。 -func (s *runState) throttleFactor() float64 { - if s.governor == nil { - return 1 - } - return s.governor.current() -} - -// throttleGovernor 是全局自适应降速器。 -// -// 触发信号刻意选「无进展」而不是「限流占比」:2026-10-16 实测,即使成功率 97.6% 的 pc-3 -// 也有 67% 的请求被限流,用占比阈值会把正常运行的机器一起拖慢。真正危险的是 -// 「连着十几个请求、连一个业务响应都拿不到」——说明整机已经压在服务端令牌桶上 -// (pc-1 峰值 122 req/s、82% 被限流)。命中后每 800ms 按 1.5 倍逐级降速(上限 4 倍), -// 一旦恢复进展就立刻回退,避免错过库存回流。 -type throttleGovernor struct { - mu sync.Mutex - factor float64 - maxSeen float64 - stall int - lastProgress time.Time - lastEscalate time.Time - // 阈值做成字段是为了让单测能把时间窗口压到毫秒级。 - minRequests int - stallWindow time.Duration - maxFactor float64 -} - -func newThrottleGovernor() *throttleGovernor { - now := time.Now() - return &throttleGovernor{ - factor: 1, - maxSeen: 1, - minRequests: governorMinRequests, - stallWindow: governorStallWindow, - maxFactor: governorMaxFactor, - lastProgress: now, - lastEscalate: now, - } -} - -func (g *throttleGovernor) observe(category string) { - g.mu.Lock() - defer g.mu.Unlock() - now := time.Now() - if category != CategoryTooFrequent { - // 只要服务端确实处理了请求(哪怕是售罄/未开放/协议错误),就算有进展。 - g.stall = 0 - g.lastProgress = now - if g.factor > 1 { - g.factor /= governorStep - if g.factor < 1 { - g.factor = 1 - } - } - return - } - g.stall++ - if g.stall < g.minRequests || now.Sub(g.lastProgress) < g.stallWindow { - return - } - if now.Sub(g.lastEscalate) < g.stallWindow { - return - } - g.lastEscalate = now - g.factor *= governorStep - if g.factor > g.maxFactor { - g.factor = g.maxFactor - } - if g.factor > g.maxSeen { - g.maxSeen = g.factor - } -} - -func (g *throttleGovernor) current() float64 { - g.mu.Lock() - defer g.mu.Unlock() - return g.factor -} - -// peak 返回本次任务用过的最大降速倍率,供汇总日志展示。 -func (g *throttleGovernor) peak() float64 { - g.mu.Lock() - defer g.mu.Unlock() - return g.maxSeen -} - -func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient, account Account, target Target, index int, loop int, cfg Config, result Result, emit func(Result)) { - if !cfg.RefreshAfterSuccess || emit == nil || result.Category != CategorySuccess { - return - } - state.refresh.Add(1) - go func() { - defer state.refresh.Done() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // 刷新请求同样走并发闸门,但用拥有 5 秒预算的独立 ctx。 - score, err := func() (*huya.GetUserScoreResp, error) { - if !state.acquire(ctx) { - return nil, ctx.Err() - } - defer state.release() - started := time.Now() - resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID) - state.recordRequest(reqScore, time.Since(started), err) - return resp, err - }() - scoreResult := Result{ - Loop: loop, - UID: account.UID, - TargetIndex: index, - Target: target, - Action: "refresh_score", - Category: CategoryReady, - Message: "刷新积分完成", - Score: score, - } - if err != nil { - scoreResult.Category = CategoryError - scoreResult.Message = "刷新积分失败" - scoreResult.Error = err.Error() - } else if score != nil { - scoreResult.Message = fmt.Sprintf("剩余积分: %d", score.AvailableScore()) - } - emit(scoreResult) - - records, err := func() (*huya.GetUserPrizeRecordsResp, error) { - if !state.acquire(ctx) { - return nil, ctx.Err() - } - defer state.release() - started := time.Now() - resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID) - state.recordRequest(reqRecord, time.Since(started), err) - return resp, err - }() - recordResult := Result{ - Loop: loop, - UID: account.UID, - TargetIndex: index, - Target: target, - Action: "refresh_records", - Category: CategoryReady, - Message: "刷新兑换记录完成", - Records: records, - } - if err != nil { - recordResult.Category = CategoryError - recordResult.Message = "刷新兑换记录失败" - recordResult.Error = err.Error() - } - emit(recordResult) - }() -} - -// listForAccount 返回本账号本轮要用的奖品列表快照。 -// -// SharePrizeList 打开时多账号复用同一份快照:TTL 内第一个账号查完,其余账号直接复用 -// (单飞:同一时刻只有一个账号在查,避免并发重复查询)。关闭时各查各的。 -func (r *Runner) listForAccount(ctx context.Context, client huya.ActivityClient, account Account, cfg Config, state *runState) *listSnapshot { - fetch := func() *listSnapshot { - if !state.acquire(ctx) { - return &listSnapshot{err: ctx.Err()} - } - if state.windowExpired() { - state.release() - return &listSnapshot{err: errors.New("窗口已结束")} - } - started := time.Now() - resp, err := client.GetActPrizeList(ctx, account.UID, account.Cookie, cfg.SID) - state.release() - state.recordRequest(reqList, time.Since(started), err) - return &listSnapshot{resp: resp, err: err} - } - if !cfg.SharePrizeList { - return fetch() - } - return state.sharedListOnce(ctx, fetch) -} - -func (r *Runner) client() huya.ActivityClient { - if r.Client != nil { - return r.Client - } - return huya.NewHTTPClient(nil) -} - -// prefireProbeCount 限制开抢前探活账号数:探活只需要少量样本, -// 即使抢购并发设到 100,也不能在开抢前齐射一百个请求。 -func prefireProbeCount(configured, total int) int { - if total <= 0 { - return 0 - } - if configured <= 0 { - configured = 1 - } - if configured > 8 { - configured = 8 - } - if configured > total { - return total - } - return configured -} - -// prewarmConcurrency 给建连预热单独限流:抢购请求仍然严格按 Concurrency, -// 但预热必须在开抢前 2 秒内完成。沿用 5 个并发时,几十上百个账号很容易 -// 预热到开抢之后才返回,反而错过整个窗口。 -func prewarmConcurrency(configured int) int { - if configured < 8 { - configured = 8 - } - if configured > 16 { - configured = 16 - } - return configured -} - -func newRunState(accounts []Account, targetCount, concurrency int, cfg Config) *runState { - if concurrency <= 0 { - concurrency = 1 - } - state := &runState{ - startedAt: time.Now(), - accounts: make(map[int64][]targetState, len(accounts)), - targetDone: make([]bool, targetCount), - exchanged: make(map[int64]map[int64]bool, len(accounts)), - duplicateNotified: make(map[int64]map[int64]bool, len(accounts)), - firstWaveSent: make(map[int64]bool, len(accounts)), - sem: make(chan struct{}, concurrency), - stats: runStats{ - outcomes: map[string]int{}, - successUIDs: map[int64]bool{}, - requests: map[requestKind]*requestStat{}, - accounts: map[int64]*AccountOutcome{}, - }, - } - for _, account := range accounts { - state.accounts[account.UID] = make([]targetState, targetCount) - } - state.configureWindow(cfg, len(accounts)) - return state -} - -// configureWindow 按配置初始化窗口与首发错峰参数。 -// -// 只有 OpenAt 非零才是窗口模式:测试与直接调用 RunTargets 的老用法(只给 Loops) -// 保持原来的轮次语义,不会被 30 秒窗口拖住。 -func (s *runState) configureWindow(cfg Config, accountCount int) { - if cfg.OpenAt.IsZero() { - return - } - window := time.Duration(cfg.WindowSeconds) * time.Second - if cfg.WindowSeconds <= 0 { - window = DefaultWindowSeconds * time.Second - } - if cfg.windowOverride > 0 { - window = cfg.windowOverride - } - startAhead := cfg.StartAhead - if startAhead <= 0 { - startAhead = DefaultStartAhead - } - s.openAt = cfg.OpenAt - s.startAhead = startAhead - s.windowLen = window - s.deadline = cfg.OpenAt.Add(window) - - if cfg.FirstWaveSpread > 0 { - step := cfg.FirstWaveSpread / time.Duration(maxInt(accountCount-1, 1)) - maxStep := cfg.FirstWaveMaxStep - if maxStep <= 0 { - maxStep = DefaultFirstWaveMaxStep - } - if step > maxStep { - step = maxStep - } - if step < 0 { - step = 0 - } - s.firstWaveSpread = cfg.FirstWaveSpread - s.firstWaveStep = step - } - if !cfg.DisableAdaptiveThrottle { - s.governor = newThrottleGovernor() - } -} - -// windowed 返回是否处于窗口模式。 -func (s *runState) windowed() bool { - return !s.deadline.IsZero() -} - -// windowExpired 返回窗口是否已经结束(非窗口模式永远为 false)。 -func (s *runState) windowExpired() bool { - if s.deadline.IsZero() { - return false - } - return !time.Now().Before(s.deadline) -} - -// windowStart 返回窗口循环应当开始发请求的时刻(开抢时刻减去先手缓冲)。 -func (s *runState) windowStart() time.Time { - return s.openAt.Add(-s.startAhead) -} - -// needsPrefire 返回是否还欠一轮「开抢前预打」。 -// -// 只有距离窗口起点还有一段时间才值得单独预打一轮:正常情况下触发时刻(开抢 − advance) -// 与窗口起点(开抢 − StartAhead)几乎重合,此时再预打一轮就等于把首发齐射打两遍。 -func (s *runState) needsPrefire() bool { - if s.openAt.IsZero() || s.prefireDone { - return false - } - return time.Now().Add(prefireMinGap).Before(s.windowStart()) -} - -func (s *runState) markPrefire() { - s.prefireDone = true -} - -// clipToDeadline 把等待时间裁剪到窗口结束,保证不会睡过窗口。 -func (s *runState) clipToDeadline(delay time.Duration) time.Duration { - if s.deadline.IsZero() { - return delay - } - remaining := time.Until(s.deadline) - if remaining <= 0 { - return 0 - } - if delay > remaining { - return remaining - } - return delay -} - -// noteRound 记录所有账号里跑过的最大轮次(用于汇总日志)。 -func (s *runState) noteRound(round int) { - s.mu.Lock() - defer s.mu.Unlock() - if round > s.rounds { - s.rounds = round - } -} - -// maxRound 返回所有账号里跑过的最大轮次。 -func (s *runState) maxRound() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.rounds -} - -// accountFinished 返回该账号是否已经没有可尝试的目标(全部成功或进入终态)。 -func (s *runState) accountFinished(uid int64, targetCount int) bool { - s.mu.Lock() - defer s.mu.Unlock() - items := s.accounts[uid] - for index := 0; index < targetCount; index++ { - if index < len(s.targetDone) && s.targetDone[index] { - continue - } - if index < len(items) && !items[index].Done { - return false - } - } - return true -} - -// sharedListOnce 保证同一时刻只有一个账号去查询奖品列表,其余账号等它完成后复用结果。 -// -// 只缓存成功的快照:失败结果不缓存,否则一次抖动会让所有账号一起失败。 -func (s *runState) sharedListOnce(ctx context.Context, fetch func() *listSnapshot) *listSnapshot { - s.listMu.Lock() - if s.listSnapshot != nil && time.Since(s.listCachedAt) <= sharedListTTL { - snapshot := s.listSnapshot - s.listMu.Unlock() - return snapshot - } - if inflight := s.listInflight; inflight != nil { - s.listMu.Unlock() - select { - case <-inflight: - case <-ctx.Done(): - return &listSnapshot{err: ctx.Err()} - } - s.listMu.Lock() - snapshot := s.listSnapshot - s.listMu.Unlock() - if snapshot != nil { - return snapshot - } - return &listSnapshot{err: errors.New("奖品列表响应为空")} - } - inflight := make(chan struct{}) - s.listInflight = inflight - s.listMu.Unlock() - - snapshot := fetch() - - s.listMu.Lock() - if snapshot != nil && snapshot.err == nil && snapshot.resp != nil { - s.listSnapshot = snapshot - s.listCachedAt = time.Now() - } - s.listInflight = nil - s.listMu.Unlock() - close(inflight) - return snapshot -} - -// takeFirstWaveDelay 领取本账号的首发错峰等待时间,每个账号只生效一次。 -func (s *runState) takeFirstWaveDelay(uid int64) time.Duration { - if s.firstWaveStep <= 0 { - return 0 - } - s.mu.Lock() - defer s.mu.Unlock() - if s.firstWaveSent[uid] { - return 0 - } - s.firstWaveSent[uid] = true - slot := s.firstWaveUsed - s.firstWaveUsed++ - return time.Duration(slot) * s.firstWaveStep -} - -// noteOutcome 记录一个账号最近一次兑换类结果,供任务结束时的逐账号结局输出。 -func (s *runState) noteOutcome(result Result) { - switch result.Action { - case "exchange", "exchange_error", "skip_duplicate", "not_found": - default: - return - } - s.mu.Lock() - defer s.mu.Unlock() - if s.stats.accounts == nil { - s.stats.accounts = map[int64]*AccountOutcome{} - } - outcome := s.stats.accounts[result.UID] - if outcome == nil { - outcome = &AccountOutcome{UID: result.UID} - s.stats.accounts[result.UID] = outcome - } - if result.Action != "skip_duplicate" { - outcome.Attempts++ - } - outcome.Category = result.Category - outcome.Message = result.Message - if result.Category == CategorySuccess || result.Done { - outcome.Done = true - } -} - -// requestKind 标识一次在途请求的类型,用于汇总日志里的请求次数。 -type requestKind int - -const ( - reqList requestKind = iota - reqExchange - reqScore - reqRecord -) - -// String 返回接口类型的中文名,用于「接口统计」日志。 -func (k requestKind) String() string { - switch k { - case reqList: - return "奖品列表" - case reqExchange: - return "兑换" - case reqScore: - return "积分" - case reqRecord: - return "兑换记录" - default: - return "未知" - } -} - -// acquire 申请一个请求额度;ctx 结束时会返回 false,调用方应直接放弃这次请求。 -func (s *runState) acquire(ctx context.Context) bool { - if s.sem == nil { - return ctx.Err() == nil - } - select { - case s.sem <- struct{}{}: - return true - case <-ctx.Done(): - return false - } -} - -func (s *runState) release() { - if s.sem == nil { - return - } - <-s.sem -} - -// recordRequest 记录一次请求的次数、耗时与错误。 -// -// 除了原来的分类计数,还累计耗时/失败/最后错误,任务结束时输出「接口统计」行, -// 这样复盘时能一眼看出是「请求打不出去(慢/超时)」还是「打出去被拒(限流)」。 -func (s *runState) recordRequest(kind requestKind, cost time.Duration, err error) { - s.mu.Lock() - defer s.mu.Unlock() - switch kind { - case reqList: - s.stats.listReqs++ - case reqExchange: - s.stats.exchangeReqs++ - case reqScore: - s.stats.scoreReqs++ - case reqRecord: - s.stats.recordReqs++ - } - if s.stats.requests == nil { - s.stats.requests = map[requestKind]*requestStat{} - } - stat := s.stats.requests[kind] - if stat == nil { - stat = &requestStat{} - s.stats.requests[kind] = stat - } - stat.observe(cost, err) -} - -// recordOutcome 累计一条结局分类,供任务结束时的汇总日志使用。 -func (s *runState) recordOutcome(category string) { - if category == "" { - return - } - s.mu.Lock() - defer s.mu.Unlock() - if s.stats.outcomes == nil { - s.stats.outcomes = map[string]int{} - } - s.stats.outcomes[category]++ -} - -func (s *runState) markSuccessUID(uid int64) { - s.mu.Lock() - defer s.mu.Unlock() - if s.stats.successUIDs == nil { - s.stats.successUIDs = map[int64]bool{} - } - s.stats.successUIDs[uid] = true -} - -func (s *runState) snapshotStats() runStats { - s.mu.Lock() - defer s.mu.Unlock() - out := runStats{ - listReqs: s.stats.listReqs, - exchangeReqs: s.stats.exchangeReqs, - scoreReqs: s.stats.scoreReqs, - recordReqs: s.stats.recordReqs, - outcomes: make(map[string]int, len(s.stats.outcomes)), - successUIDs: make(map[int64]bool, len(s.stats.successUIDs)), - requests: make(map[requestKind]*requestStat, len(s.stats.requests)), - accounts: make(map[int64]*AccountOutcome, len(s.stats.accounts)), - } - for key, value := range s.stats.outcomes { - out.outcomes[key] = value - } - for uid := range s.stats.successUIDs { - out.successUIDs[uid] = true - } - for kind, stat := range s.stats.requests { - if stat == nil { - continue - } - copied := *stat - out.requests[kind] = &copied - } - for uid, outcome := range s.stats.accounts { - if outcome == nil { - continue - } - copied := *outcome - out.accounts[uid] = &copied - } - return out -} - -// emitSummary 在任务结束时回调一次汇总;OnSummary 为 nil 时不做任何事。 -func (r *Runner) emitSummary(state *runState, accounts, targets, loops int, cost time.Duration) { - if r.OnSummary == nil { - return - } - stats := state.snapshotStats() - summary := RunSummary{ - Loops: loops, - Accounts: accounts, - Targets: targets, - Duration: cost, - ListReqs: stats.listReqs, - ExchangeReqs: stats.exchangeReqs, - ScoreReqs: stats.scoreReqs, - RecordReqs: stats.recordReqs, - SuccessUIDs: len(stats.successUIDs), - Outcomes: stats.outcomes, - } - if state.windowed() { - summary.OpenAt = state.openAt - summary.WindowSeconds = int(state.windowLen / time.Second) - } - if state.governor != nil { - summary.ThrottleFactor = state.governor.peak() - } - for _, kind := range []requestKind{reqList, reqExchange, reqScore, reqRecord} { - stat := stats.requests[kind] - if stat == nil || stat.count == 0 { - continue - } - summary.RequestStats = append(summary.RequestStats, RequestStat{ - Kind: kind.String(), - Count: stat.count, - Failed: stat.failed, - Total: stat.total, - Slowest: stat.slowest, - LastErr: stat.lastErr, - }) - } - uids := make([]int64, 0, len(stats.accounts)) - for uid := range stats.accounts { - uids = append(uids, uid) - } - sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] }) - for _, uid := range uids { - summary.AccountOutcomes = append(summary.AccountOutcomes, *stats.accounts[uid]) - } - r.OnSummary(summary) -} - -// prizeExchanged 判断该账号是否已经成功兑换过这个奖品。 -func (s *runState) prizeExchanged(uid, prizeID int64) bool { - if prizeID <= 0 { - return false - } - s.mu.Lock() - defer s.mu.Unlock() - return s.exchanged[uid][prizeID] -} - -func (s *runState) markPrizeExchanged(uid, prizeID int64) { - if prizeID <= 0 { - return - } - s.mu.Lock() - defer s.mu.Unlock() - if s.exchanged[uid] == nil { - s.exchanged[uid] = map[int64]bool{} - } - s.exchanged[uid][prizeID] = true -} - -func (s *runState) activeTargets(uid int64, targetCount int, now time.Time) []int { - s.mu.Lock() - defer s.mu.Unlock() - items := s.accounts[uid] - active := make([]int, 0, targetCount) - for i := 0; i < targetCount; i++ { - if i < len(s.targetDone) && s.targetDone[i] { - continue - } - if i >= len(items) || items[i].Done { - continue - } - if !items[i].RetryAfter.IsZero() && items[i].RetryAfter.After(now) { - continue - } - active = append(active, i) - } - return active -} - -func (s *runState) markDone(uid int64, index int) { - s.mu.Lock() - defer s.mu.Unlock() - if items := s.accounts[uid]; index >= 0 && index < len(items) { - items[index].Done = true - items[index].RetryAfter = time.Time{} - s.accounts[uid] = items - } -} - -// markAccountDone 一次性结束该账号的所有目标。 -// 「账号成功停」和会话连续失效都是账号级终态;只停当前 target 会让同一账号 -// 继续打其他奖品,既违背开关语义,也可能造成重复扣分。 -func (s *runState) markAccountDone(uid int64) { - s.mu.Lock() - defer s.mu.Unlock() - items := s.accounts[uid] - for index := range items { - items[index].Done = true - items[index].RetryAfter = time.Time{} - } - s.accounts[uid] = items -} - -func (s *runState) markTargetDone(index int) { - s.mu.Lock() - defer s.mu.Unlock() - if index >= 0 && index < len(s.targetDone) { - s.targetDone[index] = true - } -} - -func (s *runState) setRetryAfter(uid int64, index int, when time.Time) { - s.mu.Lock() - defer s.mu.Unlock() - if items := s.accounts[uid]; index >= 0 && index < len(items) { - items[index].RetryAfter = when - s.accounts[uid] = items - } -} - -func (s *runState) allDone() bool { - s.mu.Lock() - defer s.mu.Unlock() - for index, globallyDone := range s.targetDone { - if globallyDone { - continue - } - for _, items := range s.accounts { - if index < len(items) && !items[index].Done { - return false - } - } - } - return true -} - -func normalizeConfig(cfg Config) Config { - cfg.Category = strings.TrimSpace(cfg.Category) - if cfg.Loops <= 0 && cfg.OpenAt.IsZero() { - // 非窗口模式必须有个有限轮次,否则会变成死循环。 - cfg.Loops = 1 - } - // 窗口模式下 Loops<=0 表示「一直打到窗口结束」,由 deadline 兜底,这里不做限制。 - if cfg.Delay <= 0 { - cfg.Delay = 150 * time.Millisecond - } - if cfg.Concurrency <= 0 { - cfg.Concurrency = 1 - } - if cfg.TooFrequentBackoff <= 0 { - cfg.TooFrequentBackoff = maxDuration(cfg.Delay*2, defaultRetryBackoff) - } - if cfg.ErrorBackoff <= 0 { - cfg.ErrorBackoff = maxDuration(cfg.Delay, defaultRetryBackoff) - } - // 首发错峰给了 spread 就至少要有 1ms 步长,否则等于没启用。 - if cfg.FirstWaveSpread > 0 && cfg.FirstWaveMaxStep <= 0 { - cfg.FirstWaveMaxStep = DefaultFirstWaveMaxStep - } - // 积分不足是终态:积分不会自己变多,继续打只是浪费请求,默认停手。 - if !cfg.ContinueOnNotEnough { - cfg.StopOnNotEnough = true - } - // 售罄刻意不给默认值:库存可能因别人取消 / 锁单过期而回流,默认继续探测到 - // 窗口结束;需要「售罄即停」时显式设置 StopOnSoldOut。 - if !cfg.ListBeforeExchange { - cfg.DirectExchange = true - } - return cfg -} - -// normalizeTargets 去掉空目标与重复目标。 -// -// 除了完全相同的 target,还按 pid / 名称去重:界面选品会同时提交 pid、名称, -// 以及 targetText 里的同一个 pid,不去重就会对同一奖品重复兑换(实测踩过)。 -func normalizeTargets(targets []Target) []Target { - out := make([]Target, 0, len(targets)) - seenKey := map[string]bool{} - seenPID := map[int32]bool{} - seenName := map[string]bool{} - for _, target := range targets { - target.Name = strings.TrimSpace(target.Name) - if target.PID <= 0 && target.Name == "" && !target.Auto { - continue - } - if target.PID > 0 && seenPID[target.PID] { - continue - } - if target.Name != "" && seenName[target.Name] { - continue - } - key := targetKey(target) - if seenKey[key] { - continue - } - seenKey[key] = true - if target.PID > 0 { - seenPID[target.PID] = true - } - if target.Name != "" { - seenName[target.Name] = true - } - out = append(out, target) - } - return out -} - -func shouldDirectExchange(target Target, cfg Config) bool { - // 设置了价格/库存门槛时必须先查列表,否则直兑会绕过过滤条件, - // 商品临时涨价或库存不足时仍会直接下单。 - return cfg.DirectExchange && - !cfg.ListBeforeExchange && - target.PID > 0 && - !target.Auto && - target.MaxPrice <= 0 && - target.MinLeft <= 0 -} - -// notFoundMessage 在未命中时把类目限制写进提示,便于区分「被类目过滤掉」与「列表里确实没有」。 -func notFoundMessage(category string) string { - if strings.TrimSpace(category) == "" { - return "未查询到所需物品,继续监控" - } - return fmt.Sprintf("类目[%s]内未查询到所需物品,继续监控", strings.TrimSpace(category)) -} - -func matchPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo { - if target.Auto { - return autoPrize(prizes, target) - } - for i := range prizes { - if target.PID > 0 && int32(prizes[i].ID) != target.PID { - continue - } - if target.PID == 0 && target.Name != "" && !strings.Contains(prizes[i].Name, target.Name) { - continue - } - if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice { - continue - } - if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft { - continue - } - return &prizes[i] - } - return nil -} - -func autoPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo { - var fallback *huya.PrizeInfo - for i := range prizes { - if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice { - continue - } - if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft { - continue - } - if fallback == nil { - fallback = &prizes[i] - } - if prizes[i].EstimatedLeft() > 0 { - return &prizes[i] - } - } - return fallback -} - -// exchangeSignals 汇总一次兑换响应里可用于判定的信号。 -// -// 虎牙把失败原因分散在 msg / actPreCondition(Tip/ButtonDesc/ConditionParams) -// 和 exchangeInfo.desc 里,status 除 200 外没有可依据的公开语义, -// 所以判定顺序是:文案 -> 成功结构(status=200 且有 orderId)-> 结构门禁 -> 兜底失败。 -type exchangeSignals struct { - text string - status int32 - hasOrderID bool - conditionType int32 -} - -func buildExchangeSignals(resp *huya.ScoreExchangePrizeResp) exchangeSignals { - if resp == nil { - return exchangeSignals{} - } - parts := []string{ - resp.Msg, - resp.ActPreCondition.Tip, - resp.ActPreCondition.ButtonDesc, - resp.ExchangeInfo.Desc, - } - for _, value := range resp.ActPreCondition.ConditionParams { - parts = append(parts, value) - } - texts := make([]string, 0, len(parts)) - for _, part := range parts { - if item := strings.TrimSpace(part); item != "" { - texts = append(texts, item) - } - } - return exchangeSignals{ - text: strings.Join(texts, " "), - status: resp.Status, - hasOrderID: strings.TrimSpace(resp.OrderID) != "", - conditionType: resp.ActPreCondition.ConditionType, - } -} - -// classifyExchangeText 只依据文案判定失败原因,命中时返回分类。 -// -// 判定顺序固定:未开放 → 售罄 → 货币/次数 → 会话失效 → 下架 → 限流。 -// 顺序很关键:限流关键词里有「稍后重试」「稍后」,如果排在前面会把更具体的原因吃掉。 -// 2026-10-16 的日志里 244 条「领取频率限制,请稍后重试!!」和 917 条「活动太火爆了, -// 请稍后重试」过去全落到 failed:既看不出是被限流,也享受不到限流该有的退避语义。 -func classifyExchangeText(text string) (string, bool) { - switch { - case containsAny(text, "暂未开放", "未开放", "未开始", "活动未开始"): - return CategoryNotOpen, true - case containsAny(text, "已抢完", "已抢光", "已兑完", "已售完", "库存不足", "没有库存", "领完了", "库存已耗完"): - return CategorySoldOut, true - case containsAny(text, "货币不足", "积分不足", "余额不足", "点券不足", "不足"): - return CategoryNotEnough, true - case containsAny(text, "已兑换", "重复兑换", "已领取", "超过兑换次数", "达到上限", "限购"): - return CategoryAlreadyDone, true - case containsAny(text, "账号未绑定", "未绑定账号", "未绑定手机", "登录态", "登录失效", "请先登录", "凭证失效", "token 失效"): - return CategorySessionLost, true - case containsAny(text, "奖品已下架", "已下架", "奖品不存在", "活动不存在"): - return CategoryOffShelf, true - case containsAny(text, "操作频繁", "太频繁", "稍后再试", "稍后重试", "火爆", "频繁", "频率", "限流", "繁忙", "人多", "排队", "手慢", "点击太快", "请求过多", "行为异常"): - return CategoryTooFrequent, true - } - return "", false -} - -func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string { - if err != nil { - // 协议层错误(缺少 tRsp/tResp、解码失败)单独归类:它不是业务失败, - // 通常是服务端在高并发下回了错误信封,需要单独统计与排查。 - if errors.Is(err, huya.ErrProtocolResponse) { - return CategoryProtocol - } - return CategoryError - } - if resp == nil { - return CategoryFailed - } - if resp.Success() { - return CategorySuccess - } - signals := buildExchangeSignals(resp) - if category, ok := classifyExchangeText(signals.text); ok { - return category - } - // 成功回包也可能不带中文文案:status=200 且有 orderId 视为成功。 - if signals.status == 200 && signals.hasOrderID { - return CategorySuccess - } - // 没有文案但服务端回了 precondition 门禁,归为条件未达成,按可重试失败处理。 - if signals.conditionType != 0 { - return CategoryCondition - } - return CategoryFailed -} - -// ExchangeMessage 返回响应里最有代表性的提示文案,供日志与 GUI 展示。 -func ExchangeMessage(resp *huya.ScoreExchangePrizeResp) string { - if resp == nil { - return "" - } - for _, text := range []string{ - resp.Msg, - resp.ActPreCondition.Tip, - resp.ActPreCondition.ButtonDesc, - resp.ExchangeInfo.Desc, - } { - if strings.TrimSpace(text) != "" { - return strings.TrimSpace(text) - } - } - return "" -} - -func containsAny(text string, needles ...string) bool { - for _, needle := range needles { - if strings.Contains(text, needle) { - return true - } - } - return false -} - -func targetKey(target Target) string { - return strings.Join([]string{ - strings.TrimSpace(target.Name), - int32String(target.PID), - strconv.FormatBool(target.Auto), - int64String(target.MaxPrice), - int64String(target.MinLeft), - }, "\x00") -} - -func int32String(value int32) string { - return strconv.FormatInt(int64(value), 10) -} - -func int64String(value int64) string { - return strconv.FormatInt(value, 10) -} - -func sleepContext(ctx context.Context, d time.Duration) error { - timer := time.NewTimer(d) - defer timer.Stop() - select { - case <-timer.C: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func maxDuration(a, b time.Duration) time.Duration { - if a > b { - return a - } - return b -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - -// jitterDuration 在基准退避上叠加 0-25% 的随机抖动。 -// -// 多个账号同时失败时,固定退避会让它们在同一个时刻整批重试,形成周期性尖峰, -// 更容易撞上限频(参考 dygou_go 的 jitterFraction)。这里只往长了抖,不缩短, -// 保证配置值始终是退避下限。 -func jitterDuration(base time.Duration) time.Duration { - if base <= 0 { - return base - } - span := int64(base) / 4 - if span <= 0 { - return base - } - return base + time.Duration(rand.Int63n(span+1)) -} diff --git a/internal/sniper/sniper_test.go b/internal/sniper/sniper_test.go index cb314cd..a439b0c 100644 --- a/internal/sniper/sniper_test.go +++ b/internal/sniper/sniper_test.go @@ -139,7 +139,7 @@ func TestRunTargetsDirectExchangeSkipsListAndStopsPerAccount(t *testing.T) { err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 5, + maxAttempts: 5, Delay: time.Millisecond, Concurrency: 2, StopAccountAfterSuccess: true, @@ -165,9 +165,9 @@ func TestRunTargetsTerminalNotEnoughStopsAccountTarget(t *testing.T) { runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ - SID: 2203, - Loops: 4, - Delay: time.Millisecond, + SID: 2203, + maxAttempts: 4, + Delay: time.Millisecond, }, nil) if err != nil { t.Fatal(err) @@ -178,7 +178,7 @@ func TestRunTargetsTerminalNotEnoughStopsAccountTarget(t *testing.T) { } } -func TestRunTargetsTooFrequentBackoffSkipsNextLoop(t *testing.T) { +func TestRunTargetsTooFrequentBackoffDelaysActualAttempts(t *testing.T) { client := &fakeActivityClient{ exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "操作频繁,请稍后再试"}, nil @@ -186,9 +186,10 @@ func TestRunTargetsTooFrequentBackoffSkipsNextLoop(t *testing.T) { } runner := Runner{Client: client} + started := time.Now() err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 2, + maxAttempts: 2, Delay: time.Millisecond, TooFrequentBackoff: 50 * time.Millisecond, }, nil) @@ -196,8 +197,11 @@ func TestRunTargetsTooFrequentBackoffSkipsNextLoop(t *testing.T) { t.Fatal(err) } _, exchangeCount, _ := client.counts() - if exchangeCount != 1 { - t.Fatalf("exchangeCount=%d, want 1", exchangeCount) + if exchangeCount != 2 { + t.Fatalf("exchangeCount=%d, want 2(maxAttempts 应统计真实尝试)", exchangeCount) + } + if elapsed := time.Since(started); elapsed < 45*time.Millisecond { + t.Fatalf("退避没有生效,耗时=%v", elapsed) } } @@ -214,7 +218,7 @@ func TestRunTargetsSharedListForNameTargets(t *testing.T) { err := runner.RunTargets(context.Background(), accounts, []Target{{Name: "小黄鸭"}}, Config{ SID: 2203, - Loops: 1, + maxAttempts: 1, Concurrency: 2, StopAccountAfterSuccess: true, ListBeforeExchange: true, @@ -251,7 +255,7 @@ func TestRunTargetsAutoSelectPrefersPrizeWithLeft(t *testing.T) { runner := Runner{Client: client} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Auto: true}}, Config{ SID: 2203, - Loops: 1, + maxAttempts: 1, StopAccountAfterSuccess: true, }, nil) if err != nil { @@ -287,7 +291,7 @@ func TestRunTargetsCategoryFilter(t *testing.T) { err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Auto: true}}, Config{ SID: 2277, Category: "限量返场", - Loops: 1, + maxAttempts: 1, StopAccountAfterSuccess: true, }, nil) if err != nil { @@ -313,7 +317,7 @@ func TestRunTargetsCategoryFilterMisses(t *testing.T) { err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Name: "雪山"}}, Config{ SID: 2277, Category: "限量返场", - Loops: 1, + maxAttempts: 1, ListBeforeExchange: true, }, func(result Result) { results = append(results, result) @@ -340,7 +344,7 @@ func TestRunTargetsRefreshAfterSuccessEmitsRefreshResults(t *testing.T) { err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 1, + maxAttempts: 1, StopAccountAfterSuccess: true, RefreshAfterSuccess: true, }, func(result Result) { @@ -377,7 +381,7 @@ func TestRunTargetsConditionGateKeepsRetrying(t *testing.T) { err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 3, + maxAttempts: 3, Delay: 10 * time.Millisecond, ErrorBackoff: time.Millisecond, }, nil) @@ -414,7 +418,7 @@ func TestRunTargetsExchangesPrizeOnceWhenTargetsDuplicated(t *testing.T) { {PID: 13409}, }, Config{ SID: 2277, - Loops: 3, + maxAttempts: 3, Delay: time.Millisecond, StopAccountAfterSuccess: true, }, nil) @@ -435,7 +439,7 @@ func TestRunTargetsWaitsForRefreshBeforeReturn(t *testing.T) { seen := map[string]bool{} err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 13409}}, Config{ SID: 2277, - Loops: 1, + maxAttempts: 1, StopAccountAfterSuccess: true, RefreshAfterSuccess: true, }, func(result Result) { @@ -469,7 +473,7 @@ func TestRunTargetsDoesNotExchangeSamePrizeTwiceViaNameMatch(t *testing.T) { {Name: "礼包"}, }, Config{ SID: 2277, - Loops: 2, + maxAttempts: 2, Delay: time.Millisecond, DirectExchange: false, StopAccountAfterSuccess: true, @@ -504,7 +508,7 @@ func TestRunTargetsSoldOutKeepsProbingUntilSuccess(t *testing.T) { err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 6, + maxAttempts: 6, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, StopAccountAfterSuccess: true, @@ -529,7 +533,7 @@ func TestRunTargetsStopOnSoldOutTerminatesTarget(t *testing.T) { err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 5, + maxAttempts: 5, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, StopOnSoldOut: true, @@ -554,7 +558,7 @@ func TestRunTargetsOffShelfTerminatesTarget(t *testing.T) { err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 4, + maxAttempts: 4, Delay: time.Millisecond, ErrorBackoff: time.Millisecond, }, nil) @@ -593,7 +597,7 @@ func TestRunTargetsConcurrencyLimitsInFlightRequests(t *testing.T) { err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 1, + maxAttempts: 1, Delay: time.Millisecond, Concurrency: 2, TooFrequentBackoff: 10 * time.Millisecond, @@ -619,7 +623,7 @@ func TestRunTargetsEmitsSummary(t *testing.T) { // 异步刷新只在有 emit 回调时才跑(没地方上报就不发请求),这里给个空回调。 err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{ SID: 2203, - Loops: 3, + maxAttempts: 3, Delay: time.Millisecond, Concurrency: 2, StopAccountAfterSuccess: true, diff --git a/internal/sniper/summary.go b/internal/sniper/summary.go new file mode 100644 index 0000000..115db0c --- /dev/null +++ b/internal/sniper/summary.go @@ -0,0 +1,53 @@ +package sniper + +import ( + "sort" + "time" +) + +// emitSummary 在任务结束时回调一次汇总;OnSummary 为 nil 时不做任何事。 +func (r *Runner) emitSummary(state *runState, accounts, targets, attempts int, cost time.Duration) { + if r.OnSummary == nil { + return + } + stats := state.snapshotStats() + summary := RunSummary{ + Attempts: attempts, + Accounts: accounts, + Targets: targets, + Duration: cost, + ListReqs: stats.listReqs, + ExchangeReqs: stats.exchangeReqs, + ScoreReqs: stats.scoreReqs, + RecordReqs: stats.recordReqs, + SuccessUIDs: len(stats.successUIDs), + Outcomes: stats.outcomes, + } + if state.windowed() { + summary.OpenAt = state.openAt + summary.WindowSeconds = int(state.windowLen / time.Second) + } + for _, kind := range []requestKind{reqList, reqExchange, reqScore, reqRecord} { + stat := stats.requests[kind] + if stat == nil || stat.count == 0 { + continue + } + summary.RequestStats = append(summary.RequestStats, RequestStat{ + Kind: kind.String(), + Count: stat.count, + Failed: stat.failed, + Total: stat.total, + Slowest: stat.slowest, + LastErr: stat.lastErr, + }) + } + uids := make([]int64, 0, len(stats.accounts)) + for uid := range stats.accounts { + uids = append(uids, uid) + } + sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] }) + for _, uid := range uids { + summary.AccountOutcomes = append(summary.AccountOutcomes, *stats.accounts[uid]) + } + r.OnSummary(summary) +} diff --git a/internal/version/version.go b/internal/version/version.go index df9b74d..93f70b5 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -5,10 +5,10 @@ package version // // 构建时可用 ldflags 覆盖(构建脚本就是这么做的): // -// go build -ldflags "-X huya-go/internal/version.Version=0.4.1" -var Version = "0.4.1" +// go build -ldflags "-X huya-go/internal/version.Version=0.5.0" +var Version = "0.5.0" -// Display 返回带 v 前缀的展示版本号,例如 v0.4.1。 +// Display 返回带 v 前缀的展示版本号,例如 v0.5.0。 func Display() string { return "v" + Version } diff --git a/wails.json b/wails.json index 184f970..616a52a 100644 --- a/wails.json +++ b/wails.json @@ -9,7 +9,7 @@ "info": { "companyName": "huya-go", "productName": "虎牙抢购助手", - "productVersion": "0.4.1", + "productVersion": "0.5.0", "copyright": "", "comments": "虎牙活动积分兑换/抢购工具" }