refactor(sniper): 对齐 dygou_go 调度架构,增加双时间日志并解耦预热

- 抢购调度重构:参考 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 构建正常,单测覆盖双时间与预热配置
This commit is contained in:
yml2213
2026-10-17 18:29:17 +08:00
parent 2451466bed
commit 6e5beac151
21 changed files with 2142 additions and 2078 deletions
+54 -29
View File
@@ -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<UiConfig, 'cookieText' | 'selectedAccountLines' | 'disableAdaptiveThrottle'>
type SavedConfig = Omit<UiConfig, 'cookieText' | 'selectedAccountLines'>
type ScheduleCountdown = {
targetAt: number
@@ -535,14 +533,47 @@ function readSavedConfig(): Partial<SavedConfig> {
try {
const configKey = scopedKey(configStorageBase)
const saved = JSON.parse(localStorage.getItem(configKey) || '{}') as Partial<SavedConfig>
const migrationKey = scopedKey(leadDefaultMigrationBase)
if (!localStorage.getItem(migrationKey)) {
let dirty = false
// v0.5.0 移除了轮次和全局降速器配置,顺手清掉本地旧字段。
const legacyFields = saved as Record<string, unknown>
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>): 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>): 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() {
<Form.Item name="minLeft" label="最少库存">
<InputNumber min={0} />
</Form.Item>
<Form.Item name="loops" label="次数上限" tooltip="留空或 0 = 按窗口运行到窗口结束;填了就作为轮次上限">
<InputNumber min={0} max={9999} placeholder="0=按窗口" />
</Form.Item>
<Form.Item name="windowSeconds" label="窗口秒" tooltip="窗口模式下跑到「开抢 + 窗口秒数」才收工;开抢后约 9 秒内库存就会见底,默认 30 秒留足库存回流余量">
<Form.Item name="windowSeconds" label="窗口秒" tooltip="跑到「开抢 + 窗口秒数」才收工;账号会在整个窗口内独立重试,直到成功、窗口结束或收到停止指令">
<InputNumber min={1} max={600} addonAfter="s" />
</Form.Item>
<Form.Item name="delayMs" label="延时">
<InputNumber min={10} max={60000} addonAfter="ms" />
</Form.Item>
<Form.Item name="concurrency" label="线程">
<Form.Item name="concurrency" label="线程" tooltip="同时在途请求上限,不是账号槽位;多开时同机总速率约为线程×实例数,限流严重时建议 1~2">
<InputNumber min={1} max={100} />
</Form.Item>
<Form.Item name="leadMs" label="提前" tooltip="留空或 0 = 按服务端时钟校准误差动态计算(通常几十毫秒);填了就用固定提前量">
@@ -2284,7 +2307,12 @@ export default function Dashboard() {
</Form.Item>
</div>
<div className="settings-switch-grid">
<Form.Item name="prewarm" label="预热" valuePropName="checked">
<Form.Item
name="prewarm"
label="预热"
valuePropName="checked"
tooltip="默认关闭。开启时在后台异步建连,绝不阻塞主发令枪;不预热时首发自动建连并配合提前量放行"
>
<Switch />
</Form.Item>
<Form.Item name="directExchange" label="直兑" valuePropName="checked">
@@ -2305,9 +2333,6 @@ export default function Dashboard() {
<Form.Item name="timeSync" label="时间校准" valuePropName="checked" tooltip="定时前用 pingSignal 校准服务端时间,按钟差对齐触发时刻">
<Switch />
</Form.Item>
<Form.Item name="adaptiveThrottle" label="自适应降速" valuePropName="checked" tooltip="连续十几个请求都拿不到业务响应时整体降速,缓解同机同 IP 的限流风暴">
<Switch />
</Form.Item>
</div>
</div>
</Modal>
+52 -56
View File
@@ -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<string, string>;
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<string, string>;
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"];
+52 -44
View File
@@ -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():
+11 -5
View File
@@ -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)
}
+1 -1
View File
@@ -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,
}
+28 -3
View File
@@ -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()
+36
View File
@@ -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))
}
}
+87
View File
@@ -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"
}
}
+191
View File
@@ -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")
}
+88
View File
@@ -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
}
+222
View File
@@ -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)
}
+57
View File
@@ -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))
}
+125 -92
View File
@@ -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(&notFound); got != 1 {
t.Fatalf("not_found 事件=%d, want 1(退避期间应跳过)", got)
if got := atomic.LoadInt32(&notFound); 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)
}
}
+187
View File
@@ -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
}
}
+462
View File
@@ -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
}
+368
View File
@@ -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
}
+38 -1822
View File
File diff suppressed because it is too large Load Diff
+26 -22
View File
@@ -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,
+53
View File
@@ -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)
}
+3 -3
View File
@@ -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
}
+1 -1
View File
@@ -9,7 +9,7 @@
"info": {
"companyName": "huya-go",
"productName": "虎牙抢购助手",
"productVersion": "0.4.1",
"productVersion": "0.5.0",
"copyright": "",
"comments": "虎牙活动积分兑换/抢购工具"
}