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:
+54
-29
@@ -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>
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -9,7 +9,7 @@
|
||||
"info": {
|
||||
"companyName": "huya-go",
|
||||
"productName": "虎牙抢购助手",
|
||||
"productVersion": "0.4.1",
|
||||
"productVersion": "0.5.0",
|
||||
"copyright": "",
|
||||
"comments": "虎牙活动积分兑换/抢购工具"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user