From 8b3b14654fb89a062cfbc36a56100a1437befdbf Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 16 Oct 2026 18:14:30 +0800 Subject: [PATCH] =?UTF-8?q?v0.4.0=EF=BC=9A=E7=AA=97=E5=8F=A3=E8=B0=83?= =?UTF-8?q?=E5=BA=A6/=E9=99=90=E6=B5=81=E5=88=86=E7=B1=BB/=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E6=80=A7=EF=BC=8C=E6=8A=A2=E8=B4=AD=E6=94=B9?= =?UTF-8?q?=E6=AF=8F=E8=B4=A6=E5=8F=B7=E7=8B=AC=E7=AB=8B=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=20CLI=20=E5=8F=AA=E4=BF=9D=E7=95=99?= =?UTF-8?q?=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 141 ++-- cmd/huya-go/main.go | 445 ----------- docs/seckill-optimization-plan.md | 262 ++++++ frontend/src/App.tsx | 36 +- frontend/wailsjs/go/models.ts | 4 + frontend/wailsjs/runtime/package.json | 0 frontend/wailsjs/runtime/runtime.d.ts | 0 frontend/wailsjs/runtime/runtime.js | 0 internal/gui/app.go | 181 ++++- internal/huya/diagnose.go | 29 + internal/huya/diagnose_test.go | 43 + internal/huya/http_client.go | 5 +- internal/huya/wss_client.go | 8 +- internal/sniper/optimization_test.go | 531 +++++++++++++ internal/sniper/sniper.go | 1059 ++++++++++++++++++++++--- internal/sniper/timing.go | 79 ++ internal/version/version.go | 6 +- internal/wup/wup.go | 88 ++ internal/wup/wup_test.go | 54 ++ wails.json | 2 +- 20 files changed, 2309 insertions(+), 664 deletions(-) delete mode 100644 cmd/huya-go/main.go create mode 100644 docs/seckill-optimization-plan.md mode change 100755 => 100644 frontend/wailsjs/runtime/package.json mode change 100755 => 100644 frontend/wailsjs/runtime/runtime.d.ts mode change 100755 => 100644 frontend/wailsjs/runtime/runtime.js create mode 100644 internal/huya/diagnose.go create mode 100644 internal/huya/diagnose_test.go create mode 100644 internal/sniper/optimization_test.go create mode 100644 internal/sniper/timing.go diff --git a/README.md b/README.md index e7af78b..b6f3942 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,14 @@ # huya-go -虎牙活动积分兑换/抢购的 Go 复刻版。支持两种传输层,CLI 与 GUI 默认都走 `wss`: +虎牙活动积分兑换/抢购的 Go 复刻版(Wails 桌面应用)。支持两种传输层,默认走 `wss`: - `wss`(默认):浏览器实际使用的通道。先调用 `launch.queryHttpDns` 解析可用 host,再走 TAF `WebSocketCommand` 外层与 WSS 初始化流程(`launch.wsLaunch` → `mobileui.getConfig`)。host 留空即自动解析,解析失败会回退内置默认 host。 -- `http`:走 `https://cdnws.api.huya.com/`,作为可回退通道;CLI 用 `-transport http`,GUI 在设置里切换。 +- `http`:走 `https://cdnws.api.huya.com/`,作为可回退通道,在设置里切换。 ## 构建 -CLI: - ```bash go test ./... -go build -o huya-go ./cmd/huya-go -``` - -GUI: - -```bash cd frontend npm install npm run typecheck @@ -45,43 +37,15 @@ yyuid=...; udb_uid=...; guid=...; ... 不要把 Cookie、token、JWT 提交到仓库或写进文档。 -## 常用命令 +## 常用操作 -GUI 界面提供账号预览、奖品列表、积分查询、兑换记录、直接兑换、监控抢购、实时日志。 +界面提供账号预览、奖品列表、积分查询、兑换记录、直接兑换、监控抢购、实时日志: -CLI 示例: - -查询奖品列表: - -```bash -./huya-go -cmd list -sid 2203 -cookie-file cookie.txt -``` - -查询积分: - -```bash -./huya-go -cmd score -sid 2203 -cookie-file cookie.txt -``` - -直接兑换指定 PID: - -```bash -./huya-go -cmd exchange -sid 2203 -pid 12889 -cookie-file cookie.txt -``` - -按奖品名轮询并抢购: - -```bash -./huya-go -cmd monitor -sid 2203 -name 小黄鸭 -cookie-file cookie.txt -loops 20 -delay 120 -concurrency 5 -``` - -定时开始: - -```bash -./huya-go -cmd monitor -sid 2203 -pid 12889 -cookie-file cookie.txt -at "2026-07-06 20:00:00" -``` - -以上默认都走 `wss`。需要回退到 http 传输时,给任意命令加上 `-transport http`。 +- **查奖品列表**:填好 SID 与账号后点「获取商品」,列表按类目分页签(全部 / 限量返场 / 精英专享,带商品数),未上架商品显示上架时间与倒计时。 +- **查积分 / 兑换记录**:在商品列表里按行查询;行内还能直接对单个 PID 发起兑换。 +- **抢购**:选中账号 + 填写目标(PID 或奖品名,可配合「最高积分」「最少库存」「自动选品」),点「监控抢购」进入窗口循环。 +- **定时抢购**:填写「开始时间」即可,支持 `2026-07-06 20:00:00`、`20:00:00`、RFC3339;开启「时间校准」时按服务端时间理解。 +- **传输**:默认 `wss`;需要回退到 http 时在设置里把「传输」切成 `http`。 ## 多开(多实例) @@ -95,64 +59,78 @@ CLI 示例: ## 日志 -GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签),同时输出到界面与磁盘: +结构化日志(分类 + 等级 + 账号标签)同时输出到界面与磁盘: - 路径:`<数据目录>/logs/huya-go_<实例标识>_<时间戳>.log`。macOS 数据目录为 `~/Library/Application Support/huya-go`,Windows 为 `%AppData%\huya-go`。 - 每次启动创建独立文件(`O_EXCL` 抢占,重名自动加 `-2` 后缀),所以多开或同秒重启都不会写进同一个文件;文件开头写明实例名、数据目录与格式说明。 - 单文件上限 5MB,超出后滚动为 `.1` ~ `.5`,最旧的被丢弃。 -- 每行格式:`时间 [分类][等级][账号] 正文`。逐条网络请求是 `debug` 级,默认不记录,可在界面「日志」页签打开「调试」开关,或 CLI 使用 `-log-level debug`。 +- 每行格式:`时间 [分类][等级][账号] 正文`。逐条网络请求是 `debug` 级,默认不记录,可在「日志」页签用「调试日志」开关打开(等级也可由环境变量 `HUYA_LOG_LEVEL` 覆盖)。 - 写盘是异步且队列有界:队列满时丢弃日志并在文件尾部与界面提示丢弃条数,避免磁盘 I/O 拖慢抢购链路。 - 界面「日志」页签支持按等级筛选、按 ID 增量拉取、一键打开日志目录或复制路径。 - GUI 启动失败(例如 WebView2 建不出环境)时,`<用户缓存目录>/huya-go/startup.log` 会记录原因。 -## 参数说明 +## 设置项说明 -- `-cmd`:`list`、`score`、`records`、`exchange`、`monitor`。 -- `-sid`:活动 SID,必填。 -- `-pid`:奖品 PID。`exchange` 必填,`monitor` 可用 `-pid` 或 `-name`。 -- `-max-price`:目标奖品最高积分,`0` 表示不限制(CLI 与 GUI 设置都支持)。 -- `-min-left`:目标奖品最少剩余库存,`0` 表示不限制(CLI 与 GUI 设置都支持)。 -- `-category`:类目过滤,支持类目名(如 `限量返场`、`精英专享`)或类目 id(如 `99`);留空表示不限。 -- `-category-only`:`list` 只输出该类目的商品(配合 `-category` 使用)。 -- `-cookie`:单账号 Cookie。 -- `-cookie-file`:多账号 Cookie 文件。 -- `-transport`:`wss` 或 `http`,默认 `wss`。 -- `-wss-host`:WebSocket host。默认留空,自动调用 `launch.queryHttpDns` 解析(带 TTL 缓存、多 host 轮询),解析失败时回退内置默认 host;需要固定时可显式指定。 -- `-timeout`:单次调用超时秒数。 -- `-loops`、`-delay`、`-concurrency`:轮询次数、轮询间隔、并发额度。`-concurrency` 限制的是**同时在途的请求数**(不是同时运行的账号数),账号在退避等待期间不占用额度。 -- `-stop-after-success`:`monitor` 中任一账号成功后是否停止。 -- `-at`:定时开始时间,支持 `RFC3339`、`2006-01-02 15:04:05`、`HH:MM:SS`;开启时间校准时按**服务端时间**理解。 -- `-lead`:相对 `-at` 的提前启动量,默认 `2500ms`(用于覆盖网络往返,让请求在开抢瞬间已经在途)。 -- `-time-sync`:默认开启。定时前用 `launch.pingSignal` 校准服务端与本机的钟差,并把触发时刻对齐到服务端时钟(详见「时间校准」)。 -- `-log-dir`:日志目录,默认使用应用数据目录下的 `logs`。 -- `-log-level`:日志最低等级,`debug`/`info`/`warn`/`error`,默认 `info`。 -- `-v`:输出调试日志(等价于 `-log-level debug`,并把网络日志同时打到 stderr)。 +界面设置对应 `internal/gui` 的 `Config`: + +- **传输**:`wss`(默认)或 `http`。 +- **WSS Host**:默认留空,自动调用 `launch.queryHttpDns` 解析(带 TTL 缓存、多 host 轮询),解析失败时回退内置默认 host;需要固定时可显式指定。 +- **SID**:活动 SID,必填。 +- **PID / 目标**:奖品 PID;监控时也可以用奖品名(支持模糊匹配)。 +- **最高积分 / 最少库存**:目标奖品的价格上限与剩余库存下限,`0` 表示不限制。 +- **次数上限**:`0`(默认)表示按窗口运行,由「窗口秒」兜底;填了就作为单个账号的轮次上限。 +- **窗口秒**:抢购窗口长度,默认 `30`。窗口模式下跑到「开抢 + 窗口」才收工(详见「窗口调度」)。 +- **延时**:单个账号两轮之间的间隔(毫秒)。账号之间互不等待,所以它只决定单个账号的节奏。 +- **线程**:限制**同时在途的请求数**(不是同时运行的账号数);账号在退避等待期间不占用额度。 +- **提前**:相对开始时间的提前量,`0` = 按服务端时钟校准误差动态计算(`误差上界 + RTT/2`,下限 50ms);填了就用固定提前量。 +- **预热 / 直兑 / 共享列表 / 自动选品 / 账号成功停 / 成功刷新**:见「抢购调度」。 +- **时间校准**:默认开启。定时前用 `launch.pingSignal` 校准服务端与本机的钟差,并把触发时刻对齐到服务端时钟(详见「时间校准」)。 +- **自适应降速**:默认开启。连续十几个请求都拿不到业务响应时整体降速(最多 4 倍),一旦恢复进展立刻回退。 +- **开始时间**:定时开始时间,支持 `RFC3339`、`2006-01-02 15:04:05`、`HH:MM:SS`;开启时间校准时按**服务端时间**理解。 +- **超时**:单次调用超时秒数。 ## 抢购调度与退避(2026-10-16 调整) 参考 `dygou_go` 的抢购调度经验(该项目的实测复盘结论)做了以下调整: +- **每个账号独立循环**:账号各自在自己的 goroutine 里按「延时」节奏重试,互不等待、互不阻塞。旧实现按「一轮打完等所有人」(`wg.Wait`)同步,某个账号卡在慢请求上时,其余账号这一轮只能干等——账号少的实例尤其吃亏(pc-3 instance-4 因此 0 成功)。请求速率由请求级并发闸门兜底。 - **并发额度按单次请求计**:额度只覆盖一次在途请求,账号在退避等待期间释放额度。旧实现把额度给整个账号循环,退避中的账号会一直霸占额度,账号多时互相饿死。 - **售罄不再默认终止**:服务端回「已抢完 / 库存不足」时继续按退避节奏探测到轮询结束——别人锁单未支付或取消会让库存回流。需要「售罄即停」时显式设置 `Config.StopOnSoldOut`。 - **下架仍然终止**:`已下架 / 奖品不存在` 视为本窗口内的终态,直接停止该奖品(与售罄区别对待)。 - **积分不足仍然终止**:积分不会自己变多,默认 `StopOnNotEnough`,避免无意义的重复请求。 - **退避带抖动**:所有退避(限频 / 失败 / 售罄探测)在配置值基础上叠加 0-25% 随机抖动,避免多个账号在同一时刻整批重试形成周期性尖峰;配置值始终是退避下限。 -- **定时精确触发**:定时开抢改用一次性定时器(旧实现按 200ms 轮询,醒来时刻会随机晚 0-200ms,正好错过开抢窗口)。`-lead` 仍用于按 RTT 提前起跑。 +- **定时精确触发**:定时开抢改用一次性定时器(旧实现按 200ms 轮询,醒来时刻会随机晚 0-200ms,正好错过开抢窗口)。「提前」仍用于按 RTT 提前起跑。 - **任务结束汇总**:每次监控结束输出一行汇总(轮次 / 账号 / 目标 / 成功账号 / 耗时 / 请求次数)与按次数排序的结局分布(`success`、`sold_out`、`too_frequent`、`not_found` 等),便于复盘。 - **HTTP 传输连接池**:`http` 传输补上 `MaxConnsPerHost=64`、`MaxIdleConnsPerHost=64`(Go 默认每主机只有 2 个空闲连接),多账号并发时不再反复重建连接。 +### 0.4.0:窗口调度 / 限流分类 / 可观测性(2026-10-16 三机复盘) + +依据 2026-10-16 三台机器同一场活动的真实日志(247 账号、2000 次兑换请求): + +- **每账号独立循环**:正式窗口里每个账号一个 goroutine 跑自己的循环,直到窗口结束或自己打完(所有目标成功 / 进入终态)。旧实现是「一轮打完等所有人」,慢账号会拖住全部账号的节奏;「延时」从此只决定**单个账号**的节奏。 +- **共享列表 TTL + 单飞**:「共享列表」打开时,同一时刻只有一个账号去查奖品列表,其余账号在 1 秒 TTL 内复用同一份成功快照(失败不缓存,避免一次抖动让所有账号一起失败)。pc-1 的 152 个账号从每轮 152 次查询降到 1 次。 +- **窗口调度取代轮次预算**:终止条件从「轮次 × 间隔」改为「开抢 + 窗口秒数」(默认 30s)。旧实现里 `Delay=150ms` 时 1 个账号的 20 轮只有 3.1 秒,会把预算全部烧在开抢之前(当天 pc-3 instance-4 一轮都没打进窗口,0 成功)。GUI 的「次数上限」退化为可选上限,留空即按窗口运行。 +- **开抢前只预打一轮**:开抢前若还没到点,先补一轮探活请求,随后静默等到「开抢 − 60ms」再进入窗口循环。旧实现把 2.5s 提前量整段用来循环重试,实测 1421 次请求里 82% 被限流挡回。 +- **动态提前量 + 临近复校**:触发提前量 = `误差上界 + RTT/2`(下限 50ms),并在开抢前 3s 复校一次时钟、2s 前预热连接(顺序与 `sniper.PlanTrigger` 统一)。 +- **首发错峰**:第 0 个账号立即发,其余按 `min(300ms/(n-1), 8ms)` 依次错开,避免同机上百账号在同一毫秒齐射。 +- **退避阶梯**:基础退避随时间分档放大(<2s ×1、<5s ×2、≥5s ×3),同一账号连续失败第 3 次起翻倍(上限 ×4),再乘全局降速倍率并叠加 0-25% 抖动;任何一次等待都不超过 1.5s(超过就可能错过库存回流)。 +- **全局自适应降速(默认开)**:连续 12 个以上请求都拿不到业务响应且持续 800ms 时,整体退避按 1.5 倍逐级放大(上限 4 倍),一旦有进展立即回退。触发信号刻意选「无进展」而不是「限流占比」——实测即使成功率 97.6% 的那次也有 67% 请求被限流,用占比阈值会把正常运行的机器一起拖慢。GUI 设置里的「自适应降速」可关。 +- **限流分类补全**:「活动太火爆 / 领取频率限制 / 网络繁忙 / 点击太快 / 排队…」统一归入 `too_frequent`,并按「未开放 → 售罄 → 货币不足 → 会话失效 → 下架 → 限流」的固定顺序判定(过去这些文案全落到 `failed`,看不出被限流)。新增 `session_lost`(「账号未绑定!」,瞬时错误,连续 3 次才停止该账号)与 `protocol`(回包缺 `tRsp/tResp`)两个分类。 +- **协议错误可观测**:`wup.Response.Diagnose()` 把 `iRet / servant / status / context / keys / payload` 一并带进报错;debug 级日志打印原始帧前缀(32B HEX)。此前只有一句「响应缺少 tRsp/tResp」,同一账号连续 13 次命中也无法定性。 +- **复盘日志**:任务结束输出「一行总量(含限流占比与降速倍率)+ 结局分布(带中文标签)+ 接口统计(次数/失败/平均/最慢/最后错误)+ 每个账号一行最终结局」;`启动监控` 带上目标 `pid(名称)`(此前只有 `targets=1`,无法判断三台机器打的是否同一个奖品)。 + ## 时间校准(2026-10-16 新增) -定时抢购默认先做一次服务端时间校准(CLI `-time-sync`,GUI 设置里的「时间校准」开关,默认开): +定时抢购默认先做一次服务端时间校准(GUI 设置里的「时间校准」开关,默认开): - **数据来源**:`launch.pingSignal` 响应里的 `signal_ap_start` / `signal_ap_end`。2026-10-13 抓包确认这两个字段是 Unix 毫秒时间戳(`start` 是服务端收到请求的时刻,`end` 是写出响应的时刻,实测相差 13ms);真实字节夹具见 `internal/huya/launch_test.go` 的 `realPingSignalRspHex`。 - **钟差估计**:`偏移 = signal_ap_start − (发送时刻 + RTT/2)`,误差上界取 `RTT/2`——服务端打时间戳的具体位置落在发送与接收之间,无法再精确,按最坏情况估计。 - **多轮取优**:默认 3 轮采样,取「误差上界最小、其次 RTT 最低」的样本;再对最近 3 次偏移取中位数平滑,压掉单轮网络抖动。 -- **生效方式**:`-at` / `startAt` 按服务端时间理解,触发时刻 = `目标时刻 − 提前量 − 偏移`。本机时钟比服务端慢 300ms 就自动提前 300ms,反之推后。 +- **生效方式**:「开始时间」(`startAt`)按服务端时间理解,触发时刻 = `目标时刻 − 提前量 − 偏移`。本机时钟比服务端慢 300ms 就自动提前 300ms,反之推后。 - **失败降级**:一次样本都拿不到时只告警,仍按本机时钟触发;时间戳明显不合理(解析出来不是这个世纪的毫秒值)会被丢弃,避免按错误偏移整体挪走抢购窗口。 - **日志**:`时间校准完成: 服务端=2026-10-16 20:00:01.234 本机偏移=+123ms 误差<=45ms RTT=90ms 样本=3 已平滑`。 -校准每次只多花几百毫秒(3 轮 pingSignal),发生在预热之后、等待定时之前,因此不会挤占开抢窗口。 +校准每次只多花几百毫秒(3 轮 pingSignal),发生在**开抢前 3 秒**(预热在开抢前 2 秒,触发在开抢前 `advance`),因此既不会挤占开抢窗口,也不会因为启动过早让偏移过期。 ## 已确认的小黄鸭参数 @@ -175,7 +153,7 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签) - 消耗积分:`20` - 订单号格式:`sOrderId_2277_13409__<时间戳>_<随机>` -该活动的列表/积分/兑换接口与 `sid=2203` 走同一套 `webActUI`,可直接用 `-cmd list -sid 2277` 验证;同一次抓包里「开通精英宝典 / 购买精英币」属于商城付费链路(`shopMiddleUI.*` + 支付宝),本项目未实现,详见 `WSS_NOTES.md`。 +该活动的列表/积分/兑换接口与 `sid=2203` 走同一套 `webActUI`,把 SID 改成 `2277` 即可在界面里验证;同一次抓包里「开通精英宝典 / 购买精英币」属于商城付费链路(`shopMiddleUI.*` + 支付宝),本项目未实现,详见 `WSS_NOTES.md`。 ## 商品类目:限量返场 / 精英专享(2026-10-15 抓包) @@ -187,18 +165,11 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签) - 限量返场是「预告上架」类商品:`start_time`(TAF tag25,实测 2026-10-16 17:00:00)与 `end_time`(tag26)决定 可兑换时间窗,页面上的「xx:xx:xx 后上架」倒计时就来自它;`daily_start/daily_end`(tag27/28)是每日可兑时段。 -用法: +用法(界面「商品列表」): -```bash -# 全量列表(日志里会打出「类目=精英专享(101)=18项/限量返场(99)=12项」) -./huya-go -cmd list -sid 2277 -cookie-file cookies.txt +- **全量列表**:SID 填 `2277` 点「获取商品」,日志里会打出「类目=精英专享(101)=18项/限量返场(99)=12项」。 +- **只看某个类目**:在商品列表上方切换类目页签,或点某个类目标签过滤(也支持类目 id 或模糊名,如 `99` / `返场`)。 +- **限定类目抢购**:选中类目并打开「自动选品」,当前类目会一起提交给监控,作为自动选品的范围。 -# 只看限量返场(也支持类目 id 或模糊名,如 -category 99 / -category 返场) -./huya-go -cmd list -sid 2277 -cookie-file cookies.txt -category 限量返场 -category-only - -# 抢购时限定只在该类目里选品(配合 -auto) -./huya-go -cmd monitor -sid 2277 -cookie-file cookies.txt -category 限量返场 -auto -``` - -界面「商品列表」同样按类目分了页签(全部 / 限量返场 / 精英专享,带商品数),每行标注类目, +界面「商品列表」按类目分了页签(全部 / 限量返场 / 精英专享,带商品数),每行标注类目, 未上架的商品显示上架时间与倒计时;当前选中的类目会一起提交给监控,用作自动选品的范围。 diff --git a/cmd/huya-go/main.go b/cmd/huya-go/main.go deleted file mode 100644 index 1e788a1..0000000 --- a/cmd/huya-go/main.go +++ /dev/null @@ -1,445 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "os" - "strconv" - "strings" - "sync" - "time" - - "huya-go/internal/huya" - "huya-go/internal/instance" - "huya-go/internal/logger" - "huya-go/internal/sniper" -) - -// closeLogger 由 logger.Init 返回;os.Exit 不会执行 defer,退出前必须显式刷盘。 -var closeLogger func() - -const ( - categoryNet = "网络" - categoryMonitor = "监控" - categoryCLI = "CLI" -) - -func main() { - var ( - cmd = flag.String("cmd", "list", "命令:list/score/records/exchange/monitor") - cookie = flag.String("cookie", "", "虎牙 Cookie") - cookieFile = flag.String("cookie-file", "", "Cookie 文件,一行一个账号") - sid = flag.Int("sid", 0, "活动 SID") - category = flag.String("category", "", "类目过滤:类目名(如 限量返场)或类目 id,留空表示不限") - categoryOnly = flag.Bool("category-only", false, "list 只输出指定类目的商品") - pid = flag.Int("pid", 0, "奖品 PID") - pids = flag.String("pids", "", "多个奖品 PID,逗号分隔") - name = flag.String("name", "", "按奖品名包含匹配") - names = flag.String("names", "", "多个奖品名,逗号分隔") - maxPrice = flag.Int64("max-price", 0, "目标奖品最高积分,0 表示不限制") - minLeft = flag.Int64("min-left", 0, "目标奖品最少剩余库存,0 表示不限制") - autoSelect = flag.Bool("auto", false, "monitor 自动选择列表里的可兑奖品") - loops = flag.Int("loops", 1, "monitor 循环次数") - delayMS = flag.Int("delay", 150, "monitor 每轮间隔毫秒") - concurrency = flag.Int("concurrency", 5, "monitor/exchange 并发账号数") - stopAfterSuccess = flag.Bool("stop-after-success", false, "monitor 任意账号成功后停止全部") - accountStop = flag.Bool("account-stop", true, "monitor 单账号成功后停止该账号该奖品") - at = flag.String("at", "", "定时开始时间,支持 RFC3339 或 2006-01-02 15:04:05") - leadMS = flag.Int("lead", 2500, "monitor 相对 -at 提前启动毫秒") - timeSync = flag.Bool("time-sync", true, "monitor 定时前用 pingSignal 校准服务端时间,并把 -at 视为服务端时间") - timeoutSec = flag.Int("timeout", 15, "单次请求超时秒数") - transport = flag.String("transport", "wss", "传输层:wss/http,默认 wss") - wssHost = flag.String("wss-host", "", "WSS host,留空则通过 launch.queryHttpDns 自动解析") - prewarm = flag.Bool("prewarm", true, "monitor 开始前预热账号连接") - directExchange = flag.Bool("direct-exchange", true, "monitor 已知 PID 时跳过列表直接兑换") - listBefore = flag.Bool("list-before-exchange", false, "monitor 即使已知 PID 也先查询列表") - shareList = flag.Bool("share-list", true, "monitor 多账号共享同一轮奖品列表") - refreshSuccess = flag.Bool("refresh-after-success", true, "monitor 成功后异步刷新积分和记录") - freqBackoffMS = flag.Int("freq-backoff", 300, "操作频繁后的账号退避毫秒") - errorBackoffMS = flag.Int("error-backoff", 300, "请求异常后的账号退避毫秒") - verbose = flag.Bool("v", false, "输出调试日志(同时打印到 stderr)") - logDir = flag.String("log-dir", "", "日志目录,默认使用应用数据目录下的 logs") - logLevel = flag.String("log-level", "info", "日志最低等级:debug/info/warn/error") - ) - flag.Parse() - - initLogging(*logDir, *logLevel, *verbose) - defer flushLogs() - - if *sid <= 0 { - exitErr(errors.New("必须提供有效 -sid")) - } - - cookies, err := loadCookies(*cookie, *cookieFile) - if err != nil { - exitErr(err) - } - accounts := make([]sniper.Account, 0, len(cookies)) - for _, ck := range cookies { - uid := huya.ResolveUID(ck) - if uid <= 0 { - exitErr(errors.New("无法从 Cookie 解析 yyuid/udb_uid")) - } - accounts = append(accounts, sniper.Account{UID: uid, Cookie: ck}) - } - - clientLogger := func(msg string) { - logger.LogEvent(categoryNet, logger.LevelDebug, "%s", msg) - if *verbose { - fmt.Fprintln(os.Stderr, msg) - } - } - client, closeClient, err := huya.NewActivityClient(*transport, *wssHost, time.Duration(*timeoutSec)*time.Second, clientLogger) - if err != nil { - exitErr(err) - } - defer closeClient() - ctx := context.Background() - - var startAt time.Time - if *at != "" { - when, err := parseTime(*at) - if err != nil { - exitErr(err) - } - startAt = when - } - - switch *cmd { - case "list": - waitForCommand(ctx, startAt) - resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, int32(*sid)) - if err != nil { - exitErr(err) - } - logger.LogEvent(categoryCLI, logger.LevelInfo, "sid=%d 共%d项 类目=%s", *sid, len(resp.Prizes), resp.CategorySummary()) - key := strings.TrimSpace(*category) - if *categoryOnly { - if key == "" { - exitErr(errors.New("-category-only 需要同时提供 -category")) - } - filtered := *resp - filtered.Prizes = resp.FilterByCategory(key) - logger.LogEvent(categoryCLI, logger.LevelInfo, "类目[%s]筛选后 %d/%d 项", key, len(filtered.Prizes), len(resp.Prizes)) - printJSON(filtered) - return - } - printJSON(resp) - case "score": - waitForCommand(ctx, startAt) - results := make([]map[string]any, 0, len(accounts)) - for _, account := range accounts { - resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, int32(*sid)) - results = append(results, map[string]any{ - "uid": account.UID, - "resp": resp, - "score": availableScore(resp), - "error": errString(err), - }) - } - printJSON(results) - case "records": - waitForCommand(ctx, startAt) - results := make([]map[string]any, 0, len(accounts)) - for _, account := range accounts { - resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, int32(*sid)) - results = append(results, map[string]any{ - "uid": account.UID, - "resp": resp, - "error": errString(err), - }) - } - printJSON(results) - case "exchange": - if *pid <= 0 { - exitErr(errors.New("exchange 必须提供 -pid")) - } - waitForCommand(ctx, startAt) - runExchange(ctx, client, accounts, int32(*sid), int32(*pid), *concurrency) - case "monitor": - targets := parseTargets(*pid, *pids, *name, *names, *autoSelect, *maxPrice, *minLeft) - if len(targets) == 0 { - exitErr(errors.New("monitor 必须提供 -pid 或 -name")) - } - runner := sniper.Runner{Client: client, OnSummary: logRunSummary} - var outMu sync.Mutex - emit := func(result sniper.Result) { - outMu.Lock() - defer outMu.Unlock() - _ = json.NewEncoder(os.Stdout).Encode(result) - logger.LogAccountEvent(categoryMonitor, sniper.LogLevelForCategory(result.Category), strconv.FormatInt(result.UID, 10), - "loop=%d action=%s category=%s msg=%s error=%s", result.Loop, result.Action, result.Category, result.Message, result.Error) - } - cfg := sniper.Config{ - SID: int32(*sid), - Category: strings.TrimSpace(*category), - Loops: *loops, - Delay: time.Duration(*delayMS) * time.Millisecond, - Concurrency: *concurrency, - StopAfterSuccess: *stopAfterSuccess, - StopAccountAfterSuccess: *accountStop, - Prewarm: false, - DirectExchange: *directExchange, - ListBeforeExchange: *listBefore, - SharePrizeList: *shareList, - RefreshAfterSuccess: *refreshSuccess, - TooFrequentBackoff: time.Duration(*freqBackoffMS) * time.Millisecond, - ErrorBackoff: time.Duration(*errorBackoffMS) * time.Millisecond, - } - if *prewarm { - if err := runner.Prewarm(ctx, accounts, cfg, emit); err != nil && !errors.Is(err, context.Canceled) { - exitErr(err) - } - } - waitForMonitor(ctx, client, accounts[0], startAt, time.Duration(*leadMS)*time.Millisecond, *timeSync) - err := runner.RunTargets(ctx, accounts, targets, cfg, emit) - if err != nil && !errors.Is(err, context.Canceled) { - exitErr(err) - } - default: - exitErr(fmt.Errorf("未知 -cmd: %s", *cmd)) - } -} - -func waitForCommand(ctx context.Context, startAt time.Time) { - if !startAt.IsZero() { - waitUntil(ctx, startAt) - } -} - -// waitForMonitor 等待到定时开抢时刻。 -// -// -at 里的时间按「服务端时间」理解:开启 timeSync 时先用 launch.pingSignal 校准 -// 服务端与本机的钟差,再按偏移把触发时刻换算到本机时钟上,避免本机时钟偏快/偏慢 -// 导致提前或延后开抢(本机时钟误差常见在几百毫秒量级,正好覆盖开抢瞬间)。 -// 校准失败只告警,仍然按本机时钟触发。 -func waitForMonitor(ctx context.Context, client huya.ActivityClient, account sniper.Account, startAt time.Time, lead time.Duration, timeSync bool) { - if startAt.IsZero() { - return - } - when := startAt.Add(-lead) - if timeSync { - calibration, err := huya.TryCalibrateServerTime(ctx, client, account.UID, account.Cookie, huya.DefaultTimeCalibrationRounds) - if err != nil { - logger.LogEvent(categoryNet, logger.LevelWarn, "时间校准失败,按本机时钟触发: %v", err) - } else { - when = huya.AlignedTriggerTime(startAt, lead, calibration.Offset) - logger.LogEvent(categoryNet, logger.LevelInfo, "时间校准完成: %s", calibration.Summary()) - logger.LogEvent(categoryCLI, logger.LevelInfo, "触发时刻已对齐服务端时钟: 本机=%s 提前=%dms", - when.Format("2006-01-02 15:04:05.000"), lead.Milliseconds()) - } - } - waitUntil(ctx, when) -} - -// logRunSummary 在任务结束后打一行汇总,并按次数列出各类结局。 -// 逐条结果已经在 emit 里输出,这里只补全局视角(参考 dygou_go 的 logRunSummary)。 -func logRunSummary(summary sniper.RunSummary) { - logger.LogEvent(categoryMonitor, logger.LevelInfo, - "任务结束: 轮次=%d 账号=%d 目标=%d 成功账号=%d 耗时=%s 请求[列表=%d 兑换=%d 积分=%d 记录=%d]", - summary.Loops, summary.Accounts, summary.Targets, summary.SuccessUIDs, - summary.Duration.Round(time.Millisecond), - summary.ListReqs, summary.ExchangeReqs, summary.ScoreReqs, summary.RecordReqs) - for _, category := range summary.Categories() { - logger.LogEvent(categoryMonitor, sniper.LogLevelForCategory(category), - "结局 %s ×%d", category, summary.Outcomes[category]) - } -} - -func runExchange(ctx context.Context, client huya.ActivityClient, accounts []sniper.Account, sid, pid int32, concurrency int) { - if concurrency <= 0 { - concurrency = 1 - } - type item struct { - UID int64 `json:"uid"` - Resp *huya.ScoreExchangePrizeResp `json:"resp,omitempty"` - Error string `json:"error,omitempty"` - } - results := make([]item, len(accounts)) - sem := make(chan struct{}, concurrency) - var wg sync.WaitGroup - for i, account := range accounts { - i, account := i, account - wg.Add(1) - go func() { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - resp, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid) - results[i] = item{UID: account.UID, Resp: resp, Error: errString(err)} - }() - } - wg.Wait() - printJSON(results) -} - -func loadCookies(cookie, file string) ([]string, error) { - var out []string - if strings.TrimSpace(cookie) != "" { - out = append(out, strings.TrimSpace(cookie)) - } - if file != "" { - f, err := os.Open(file) - if err != nil { - return nil, err - } - defer f.Close() - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - out = append(out, line) - } - if err := scanner.Err(); err != nil { - return nil, err - } - } - if len(out) == 0 { - return nil, errors.New("必须提供 -cookie 或 -cookie-file") - } - return out, nil -} - -func parseTargets(pid int, pids, name, names string, autoSelect bool, maxPrice, minLeft int64) []sniper.Target { - targets := make([]sniper.Target, 0) - name = strings.TrimSpace(name) - if pid > 0 || name != "" { - targets = append(targets, sniper.Target{PID: int32(pid), Name: name}) - } - if autoSelect { - targets = append(targets, sniper.Target{Auto: true}) - } - for _, text := range splitList(pids) { - value, err := strconv.Atoi(text) - if err == nil && value > 0 { - targets = append(targets, sniper.Target{PID: int32(value)}) - } - } - for _, text := range splitList(names) { - if text == "auto" || text == "自动" || text == "自动选择" { - targets = append(targets, sniper.Target{Auto: true}) - continue - } - targets = append(targets, sniper.Target{Name: text}) - } - // 价格/库存过滤对所有目标统一生效。 - for i := range targets { - targets[i].MaxPrice = maxPrice - targets[i].MinLeft = minLeft - } - return targets -} - -func splitList(text string) []string { - replacer := strings.NewReplacer(",", ",", "\n", ",", "\t", ",", " ", ",") - parts := strings.Split(replacer.Replace(text), ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - item := strings.TrimSpace(part) - if item != "" { - out = append(out, item) - } - } - return out -} - -func parseTime(text string) (time.Time, error) { - text = strings.TrimSpace(text) - if text == "" { - return time.Time{}, errors.New("时间为空") - } - if ts, err := strconv.ParseInt(text, 10, 64); err == nil { - return time.Unix(ts, 0), nil - } - for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04"} { - if t, err := time.ParseInLocation(layout, text, time.Local); err == nil { - return t, nil - } - } - return time.Time{}, fmt.Errorf("无法解析 -at 时间: %s", text) -} - -// waitUntil 等到指定时刻。 -// -// 这里用一次性定时器精确睡眠:旧实现按 200ms 轮询,醒来时刻会随机晚 0-200ms, -// 抢购场景下这段时间正好是最关键的开抢窗口。 -func waitUntil(ctx context.Context, when time.Time) { - for { - remaining := time.Until(when) - if remaining <= 0 { - return - } - timer := time.NewTimer(remaining) - select { - case <-timer.C: - case <-ctx.Done(): - timer.Stop() - return - } - } -} - -func printJSON(value any) { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(value); err != nil { - exitErr(err) - } -} - -func errString(err error) string { - if err == nil { - return "" - } - return err.Error() -} - -func availableScore(resp *huya.GetUserScoreResp) int64 { - if resp == nil { - return 0 - } - return resp.AvailableScore() -} - -// initLogging 初始化 CLI 日志:默认写入应用数据目录下的 logs,和 GUI 一样是 -// 结构化日志(带分类与账号标签),便于事后复盘。 -func initLogging(dir, level string, verbose bool) { - if strings.TrimSpace(dir) == "" { - dir = instance.DefaultLogDir() - } - logger.SetMinLevel(logger.LevelFromString(chooseLevel(level, verbose))) - store := logger.NewStore(1000) - closeFn, err := logger.Init(store, dir, "CLI", "cli", instance.DefaultDataDir()) - closeLogger = closeFn - if err != nil { - fmt.Fprintf(os.Stderr, "日志文件不可用,仅保留在内存: %v\n", err) - } -} - -func chooseLevel(level string, verbose bool) string { - if verbose { - return logger.LevelDebug - } - return level -} - -// flushLogs 把异步日志队列刷盘;必须在 os.Exit 之前调用。 -func flushLogs() { - if closeLogger != nil { - closeLogger() - closeLogger = nil - } -} - -func exitErr(err error) { - logger.LogEvent(categoryCLI, logger.LevelError, "%v", err) - fmt.Fprintln(os.Stderr, "错误:", err) - flushLogs() - os.Exit(1) -} diff --git a/docs/seckill-optimization-plan.md b/docs/seckill-optimization-plan.md new file mode 100644 index 0000000..6b45c9c --- /dev/null +++ b/docs/seckill-optimization-plan.md @@ -0,0 +1,262 @@ +# 抢购引擎优化计划(v0.4.0) + +> 依据:2026-10-16 三台机器(pc-1 / pc-2 / pc-3)同一场活动(sid=2277,17:00 开抢)的真实日志复盘。 +> 参考:`/Users/yml/codes/dygou_go`(斗鱼)的调度设计,**只借鉴结论与参数形态,不照搬链路**(虎牙无锁单/支付,协议也不同)。 + +--- + +## 1. 复盘数据(问题来源) + +| 机器 | 账号 | 成功 | 成功率 | 兑换请求 | 火爆 | 频率限制 | 售罄 | 版本 | +|---|---|---|---|---|---|---|---|---| +| pc-1 | 152 | 85 | 56% | 1421 | 917 | 244 | 19 | 旧版(无汇总 / 无时钟校准 / 售罄即停) | +| pc-2 | 10 | 7 | 70% | 81 | 51 | 1 | 0 | 新版(轮次窗口不足) | +| pc-3 | 85 | 83 | 97.6% | 498 | — | 0 | 0 | 新版(目标未售罄) | +| 合计 | 247 | 175 | 71% | 2000 | — | — | — | — | + +关键事实: + +1. 开抢窗口只有 **约 9 秒**(17:00:00.1 放量 → 17:00:08.2~09.4 售罄)。 +2. pc-1 的 **82%** 兑换请求(1161/1421)被服务端限流挡回(「活动太火爆了」+「领取频率限制」);同机同 IP 峰值约 122 req/s。 +3. `Delay` 默认 150ms 时,**账号越少,每秒轮次越多,越容易在开抢前把轮次烧光**: + + | 实例 | 账号数 | 每轮耗时 | 20 轮覆盖 | 实际打进的开抢窗口 | + |---|---|---|---|---| + | pc-3 instance-4 | 1 | 155ms | 3.1s | **0 秒**(提前量 2.5s 吃掉全部预算 → 0 成功) | + | pc-2 default | 3 | 178ms | 3.6s | ~1.0s | + | pc-2 instance-3 | 3 | 170ms | 3.4s | ~1.2s | + | pc-3 instance-3 | 22 | 586ms | 11.7s | 5.7s | + | pc-1 各实例 | 30 | 830ms | 16.6s | 整窗口(但旧版售罄即停) | + +4. pc-3 default 的账号 `1199662595042` **连续 13 次**返回 `webActUI.scoreExchangePrize 响应缺少 tRsp/tResp`,而同实例另外 28 个账号全部成功;日志里没有任何服务端线索。 +5. 服务端放行速率是稳定的"每秒 4~6 个"(pc-3 六秒曲线),不是瞬间批量 → **覆盖足够的窗口时长**比**更高瞬时并发**更有效。 + +## 2. 四个优化项(已确认全部实施) + +| 编号 | 问题 | 现状证据 | +|---|---|---| +| P1 | 轮次预算被提前量吃掉,账号少的实例根本没打到开抢瞬间 | pc-3 i4 = 0 成功;pc-2 三实例开抢后 1~1.4s 收工 | +| P2 | WUP/协议错误不可观测,且被混入普通失败 | 13 次 `缺少 tRsp/tResp` 只给了一句话,`iRet/status/payload` 全丢 | +| P3 | 限流文案没分类,退避语义缺失 | `火爆`/`领取频率限制` 落到 `failed`,共用同一退避 | +| P4 | 日志缺关键上下文 | `启动监控` 无 pid/名称;`账号未绑定` 与真失败同桶;无接口耗时统计 | + +## 3. 参考方案(dygou_go)与适用边界 + +| dygou_go 做法 | 虎牙适用性 | 说明 | +|---|---|---| +| 窗口 `deadline` + `expired()`(75s)替代轮次 | ✅ 直接借鉴 | 虎牙实测窗口 ~9s,改为可配 `WindowSeconds`(建议默认 30s) | +| 每账号独立循环,不做轮间同步 | ⚠️ 改造后借鉴 | 虎牙已有"请求级闸门 + 账号独立 loop",但外层仍按 loop 同步一轮 | +| `dynamicAdvance = ErrorBound + RTT/2`,下限 50ms | ✅ 直接借鉴 | 替代固定 `LeadMS=2500` | +| 触发前 3s 复校时钟(`resyncBeforeTrigger`) | ✅ 借鉴 | 虎牙已有 `OffsetHistory` 中位数平滑,缺"临近复校" | +| 预热早于触发 2s(`warmupBeforeTrigger`) | ✅ 借鉴 | 改为"预热后静默等触发",不再让提前量循环白打 | +| 首发错峰 `firstWaveSpread=300ms` / `step≤8ms` | ✅ 直接借鉴 | 缓解同机 152 账号同毫秒齐射 | +| 退避阶梯 `retryIntervalFor(按已过时间分档)` + ×2 递增(≤×4) + 抖动,区间 [100ms,1.5s] | ✅ 直接借鉴 | 替代固定 300ms 退避 | +| 售罄不终止、退避自动降速到秒级 | ✅ 已有 | huya-go 已在 0294606 实现,保留并加提示日志 | +| 结局分类 success/rejected/throttled/terminal/expired | ✅ 借鉴 | 用于"窗口结束时"的每账号结局 | +| 限流关键词表 `exchangeRateLimited`(火爆/频繁/繁忙/排队…) | ✅ 直接借鉴 | 补进 `classifyExchangeText` | +| 请求统计(count/failed/avg/slowest/lastErr)+ 网络汇总行 | ✅ 借鉴 | 虎牙已有分类计数,补耗时与最后错误 | +| 目标商品 id 打进日志(`productTargets`) | ✅ 直接借鉴 | 补 `启动监控` 的 pid + 名称 | +| 抢购后积分核对(基线比对) | ✅ 借鉴 | 现有 `refresh_score` 可升级为"扣分=成功"二次确认 | +| 锁单→支付、锁单 TTL/CSRF、支付阶梯、和平小店 | ❌ 不适用 | 虎牙是直接兑换、无锁单无支付 | +| 斗鱼订单错误码表(1644400007 等) | ❌ 不适用 | 虎牙是 TAF/WUP,需按 `iRet / status / 文案` 三类信号自建 | + +## 4. 现状代码坐标 + +| 位置 | 说明 | +|---|---| +| `internal/sniper/sniper.go:198` | `RunTargets` | +| `internal/sniper/sniper.go:227` | `for loop := 1; loop <= cfg.Loops; loop++`(待替换的轮次模型) | +| `internal/sniper/sniper.go:308` | `runAccountLoop` | +| `internal/sniper/sniper.go:444 / 480` | `exchangeTarget` / `applyExchangeDecision`(退避决策) | +| `internal/sniper/sniper.go:734` | `emitSummary` | +| `internal/sniper/sniper.go:838` | `normalizeConfig`(默认 Delay 150ms / Backoff 300ms) | +| `internal/sniper/sniper.go:996` | `classifyExchangeText`(文案分类) | +| `internal/huya/wss_client.go:704` | `callRPC`(:715 丢诊断) | +| `internal/huya/http_client.go:140 / 218` | `CallRPC`(:218 同样的丢诊断) | +| `internal/wup/wup.go:97 / 229` | `Response`(有 `Message/Status/Context/NewData` 未用)、`ReadStruct` | +| `internal/huya/timecal.go:98 / 145` | `AlignedTriggerTime` / `RunTimeCalibration`(已有中位数平滑) | +| `internal/gui/app.go:502 / 522-540` | `启动监控` 日志 / 定时触发路径 | +| ~~`cmd/huya-go/main.go:198 / 220`~~ | CLI `waitForMonitor` —— **CLI 已在 0.4.0 移除**(见文末「实施记录(续)」) | + +## 5. 设计(四个工作包) + +### WP1 窗口预算与首发时序(P1) + +1.1 **终止条件:轮次 → 截止时间** +- `Config` 新增:`WindowSeconds int`(默认 30)、`DeadlineAt time.Time`(内部)、`FirstWaveSpread / FirstWaveMaxStep`。 +- `RunTargets` 计算 `deadline := openAt + WindowSeconds`;循环条件改为 `!deadlineReached()`;`Loops > 0` 时仍作为上限(**向后兼容**:两者先到先止)。 + +1.2 **起跑阶段不消耗预算(消灭 pc-3 i4 的 0 成功)** +- 两段式:阶段 A(预打探活)T-advance 起,每账号只发 1 次用于判断是否已开放;阶段 B(正式窗口)等 `openAt` 后才开始计预算,`deadline = openAt + WindowSeconds`。 +- 引擎自持 `openAt`(由 GUI 传入),不再把"等待"放在 `RunTargets` 之外。 + +1.3 **首发错峰** +- `runState.firstWaveSlot()`:第 0 个账号立即发,其余 `slot × step`;`step = min(spread/(n-1), maxStep)`,`spread=300ms`、`maxStep=8ms`(152 账号 → 2ms/步,总铺开 300ms)。 + +1.4 **触发时序统一(引擎与 GUI 共用一份实现)** +- 新增 `internal/sniper/timing.go`:`PlanTrigger(target, calibration, fallbackLead) Plan{WarmupAt, ResyncAt, TriggerAt, Advance, WindowEnd}`。 +- GUI(`internal/gui/app.go` 的 `StartMonitor`)改为调用同一函数:预热 T-2s、复校 T-3s、触发 T-advance、窗口结束 openAt+WindowSeconds。 +- `advance = max(ErrorBound + RTT/2, 50ms)`;`LeadMS > 0` 时覆盖动态值(兼容旧配置)。 + +1.5 **退避阶梯** +- 新增 `retryDelay(elapsed, failStreak)`:基准按"已过时间"分档(默认 0-2s→150ms、2-5s→400ms、>5s→900ms,可配 `RetryStages`),连续失败 ×2(上限 ×4),抖动 0-25%,区间 [100ms, 1500ms]。 +- 退避等待被 deadline 裁剪(`sleepUntil(deadline, d)`);窗口结束立即收尾,不再产生窗口外的无效失败。 + +### WP2 WUP/协议错误可观测性(P2) + +2.1 `wup.Response` 新增 `Diagnose()`:`iRet=%d servant=%s func=%s status=%v context=%v keys=%v payload=%dB`(value 截断、key 排序,保证日志稳定)。 +2.2 `wss_client.callRPC`(:715)与 `http_client.CallRPC`(:218):tRsp 缺失时错误串带上 `resp.Diagnose()`。 +2.3 新增 debug 级原始帧日志:`cmd=`、帧长度、前 32 字节 HEX(仅在日志等级为 debug 时输出)。 +2.4 新增分类 `CategoryProtocol = "protocol"`(解码失败、缺 tRsp、帧异常)→ 日志等级 error、独立计数。 +2.5 新增分类 `CategorySessionLost = "session_lost"`("账号未绑定"/"登录态"/"token"):瞬时错误照常重试,连续 ≥3 次才升级为账号级终止并打 warn。 +2.6 单测:构造 `iRet≠0 且无 tRsp` 的响应 → 断言错误串含 iRet/status/keys;"账号未绑定" → 断言新分类。 + +### WP3 限流分类与退避(P3) + +3.1 `classifyExchangeText` 关键词扩充(借 dygou 的关键词表) +- 归入 `too_frequent`:`火爆`、`频繁`、`稍后重试`、`太频繁`、`限流`、`繁忙`、`人多`、`排队`、`手慢`、`点击太快`、`请求过多`、`行为异常`。 +- 判定顺序固定:`未开放 → 已抢完/库存 → 货币不足/已兑换 → 限流 → 兜底`,避免"稍后重试"吃掉"售罄"。 + +3.2 汇总口径扩充:`RunSummary` 增 `Throttled / SoldOut / SessionLost / Protocol`;汇总行形如 +`任务结束: 窗口=30.0s 账号=29 成功=28 限流=123(67%) 售罄=19 协议错误=13 耗时=9.1s` + +3.3 退避:`too_frequent` 走 WP1.5 的阶梯,并支持"连续 N 次限流后该账号基础间隔 ×2"(N 默认 3)。 + +3.4 (可选,默认关)**全局自适应降速**:最近 1s 限流占比 > 60% 时,把基础间隔与并发按 1.5 倍衰减,10s 窗口后恢复。开关 `AdaptiveThrottle`,用于缓解 pc-1 那种 122 req/s → 82% 限流的同 IP 风暴。 + +3.5 终态修正:限流/火爆/未绑定一律**不终止**(现状已如此,补测试固化)。 + +### WP4 日志与复盘(P4) + +4.1 `启动监控` 带目标明细: +`启动监控: sid=2277 类目=不限 targets=1 目标=13437(冠军之刃) accounts=29 窗口=30s 提前=动态≈120ms 首发错峰=300ms` +4.2 每账号一行结局(任务结束时):`账号=1234 结局=限流中结束 请求=12 最后=活动太火爆了,请稍后重试` +4.3 结局分布带原因:`结局 sold_out ×19(窗口结束仍售罄)` +4.4 接口统计行:`网络: scoreExchangePrize ×183 失败136 平均=412ms 最慢=2.1s 最后错误=…` +4.5 触发日志补全:`触发=… 复校=… 窗口结束=…` + +## 6. 兼容性与风险 + +- **向后兼容**:`Loops > 0` 仍生效(作为上限);`LeadMS > 0` 覆盖动态提前量;新字段全部有默认值,GUI 都可关闭新行为。 +- **风险 1**:窗口拉长 → 请求量上升。缓解:退避阶梯 + deadline 裁剪 + 可选自适应降速;`WindowSeconds` 可调。 +- **风险 2**:首发错峰牺牲首波瞬时性。缓解:总铺开仅 300ms,而实测窗口有 9s 余量;第 0 个账号仍立即发。 +- **风险 3**:动态提前量下限 50ms 可能压不住慢网络。缓解:`ErrorBound + RTT/2` 已含往返补偿,且保留"预打探活"。 +- **风险 4**:新增分类影响 GUI 配色/筛选。缓解:`LogLevelForCategory` 补映射(protocol→error,session_lost→warn),前端按 level 上色即可。 +- **风险 5**:`classifyExchangeText` 顺序调整可能改变既有用例结论。缓解:先补"售罄/货币不足优先于限流"的回归测试再改。 + +## 7. 验收标准(DoD) + +- `go test ./...` 全绿;新增 ≥12 个用例(窗口预算、首发错峰、退避阶梯、分类、诊断串、临近复校)。 +- 关键回归不破:售罄继续探测、同奖品重复兑换去重、成功后异步刷新、`StopAfterSuccess`。 +- `go build ./...` 与 `wails build`(或 `-debug -nopackage`)通过;版本号升到 `0.4.0`。 +- 日志验收(可用假 client 跑模拟):一次运行必须输出「窗口参数行 / 目标明细行 / 每账号结局行 / 汇总行 / 接口统计行」。 +- 反向验收:账号数 = 1、`Loops=1`、窗口 30s 时,日志必须显示"有效请求覆盖到窗口结束",不再出现"开抢前收工"。 + +## 8. 里程碑与改动清单 + +| 里程碑 | 内容 | 主要文件 | 产出 | +|---|---|---|---| +| M1 | WP2 可观测性(低风险、独立、立即可用于复盘) | `internal/wup/wup.go`、`internal/huya/wss_client.go`、`internal/huya/http_client.go`、`internal/sniper/sniper.go`(+分类)、测试 | 新分类 + 诊断串 | +| M2 | WP3 限流分类与退避 | `internal/sniper/sniper.go`、`sniper_test.go`、`internal/gui/app.go` | 退避阶梯 + 汇总口径 | +| M3 | WP1 窗口预算与首发时序 | `internal/sniper/sniper.go`、新增 `internal/sniper/timing.go`、`internal/gui/app.go`、`internal/huya/timecal.go` | 窗口调度 | +| M4 | WP4 日志补齐 + 版本/文档 | `internal/gui/app.go`、`internal/logger`、`README.md`、`internal/version` | 可复盘日志 | + +顺序理由:先把"看不清"变成"看得清"(M1/M2),再动调度核心(M3),最后收尾(M4)。 + +## 9. 待确认参数 + +| 参数 | 建议值 | 说明 | +|---|---|---| +| `WindowSeconds` | 30s | 实测售罄 9s;30s 覆盖「库存回流/补货」 | +| 首发错峰总量 / 单步上限 | 300ms / 8ms | 152 账号 → step 2ms | +| 退避区间 | [100ms, 1500ms] | 超过 1.5s 的等待可能正好错过回流库存 | +| 「账号未绑定」升级阈值 | 连续 3 次 | 实测同一账号下一轮即可成功(瞬时) | +| 自适应降速(WP3.4) | 默认开 | 由用户确认:默认打开,GUI 可关 | + +--- + +## 10. 实施记录(v0.4.0,2026-10-16) + +四个工作包已全部落地,`go test ./...`、`go vet`、`wails build`、前端 `npm run typecheck && npm run build` 均通过。 + +### 已实现 + +| 工作包 | 落地位置 | +|---|---| +| WP1 窗口预算 | `internal/sniper/sniper.go`(`Config.OpenAt/WindowSeconds/StartAhead`、`runState.configureWindow/windowExpired/windowStart/needsPrefire`、`RunTargets` 重写)、`internal/sniper/timing.go`(`PlanTrigger`)、`internal/gui/app.go` | +| WP1 首发错峰 | `runState.takeFirstWaveDelay` + `runAccountLoop` 首次尝试等待 | +| WP1 退避阶梯 | `runState.retryDelay / scheduleRetry / stageMultiplier / streakMultiplier / bumpFailStreak` | +| WP2 可观测性 | `internal/wup/wup.go`(`Diagnose`、`HexHead`)、`internal/huya/diagnose.go`(`ErrProtocolResponse`、`protocolResponseError`)、`internal/huya/wss_client.go`、`internal/huya/http_client.go` | +| WP2 新分类 | `CategoryProtocol`、`CategorySessionLost` + `LogLevelForCategory` / `CategoryLabel` | +| WP3 限流分类 | `classifyExchangeText` 关键词表 + 固定判定顺序 | +| WP3 汇总口径 | `RunSummary.Throttled`(经 `Outcomes[too_frequent]` 统计)、`RequestStats`、`AccountOutcomes` | +| WP3 自适应降速 | `throttleGovernor` + `runState.observeThrottle/throttleFactor` | +| WP4 日志 | `logRunSummary`(GUI)、`sniper.TargetSummary`、`App.logWindowPlan`、`waitFor` | +| 测试 | `internal/sniper/optimization_test.go`(15 个新用例 + 下文 11.4 再补 4 个)、`internal/wup/wup_test.go`(诊断串 + 原始帧前缀)、`internal/huya/diagnose_test.go`(协议错误判定)、前端 `tsc --noEmit` | + +### 与原计划的三处偏差(有实测依据) + +1. **自适应降速的触发信号改为「无进展」而不是「限流占比 > 60%」**。 + 原计划按限流占比判定,但实测数据显示 pc-3(成功率 97.6%)也有 67% 请求被限流、pc-1 是 82% —— 用占比阈值会把正常运行的机器一起拖慢。改为「连续 ≥12 个请求且 ≥800ms 拿不到任何业务响应」,命中后按 1.5 倍逐级降速(上限 4 倍),有进展立刻回退。 +2. **退避阶梯不引入 `RetryStages` 配置项**,改为在既有 `ErrorBackoff/TooFrequentBackoff` 基础上乘「时间档位 + 连续失败档位」:既不新增配置面,又避免「连续售罄探测」这类正常等待被放大(售罄/未开放档位更保守)。 +3. **窗口模式下 `Loops` 语义调整**:`OpenAt` 非零时 `Loops<=0` 表示跑到窗口结束(GUI 默认留空),`Loops>0` 仍作为单个账号的轮次上限;非窗口模式(测试与直接调用)保持原来的「默认 1 轮」语义,避免老行为回归。 + +### 顺带修掉的两个小问题 + +- 同一「账号 + 奖品」的「已兑换过,跳过重复兑换」提示改为只报一次:窗口模式下每轮都会命中,不限制会刷满整个窗口(请求数不变)。 +- 会话失效计数在任何非会话失效结局后清零,避免历史计数把偶发抖动累积成「连续失效」。 + +--- + +## 11. 实施记录(续):每账号独立循环 + 共享列表 TTL + 移除 CLI(v0.4.0,2026-10-16) + +三机复盘后用户追加两条要求:**每个账号独立抢购**(不再一批一批同步)、**移除 CLI 只保留 GUI**。 + +### 11.1 调度:批次同步 → 每账号独立循环 + +**旧实现**:`RunTargets` 外层是 `for loop := 1..Loops`,每轮把所有账号并发跑一遍再 `wg.Wait()` 等齐,然后按 `nextWaveDelay` 等一个共享间隔进入下一轮。慢账号(一次 2s 的请求)会把所有账号钉在这一轮的终点上,账号少的实例尤其吃亏(pc-3 instance-4 = 0 成功)。 + +**新实现**(`internal/sniper/sniper.go`): + +| 旧 | 新 | +|---|---| +| `for loop` + `runWave(loop)` + 每轮 `wg.Wait()` 同步 | 每个账号一个 goroutine 跑 `runAccountLoop`,各自 `for round := 1; ;` 循环 | +| `nextWaveDelay` 批次间隔 | 账号自己的 `cfg.Delay`(`clipToDeadline` 裁剪到窗口结束) | +| `runAccountLoop(..., loop, sharedList)` 是一轮的尝试体 | 拆成 `runAccountLoop`(循环)+ `runAccountRound`(一轮尝试体) | +| 局部 `loopsRun` 记轮次 | `runState.noteRound/maxRound`(取所有账号的最大轮次) | +| 每轮开头只判断 `allDone()` | `allDone()` + `windowExpired()` + `accountFinished(uid, len(targets))` | + +退出条件(每个账号独立判断):任务取消 → 显式轮次上限(`Loops > 0`)→ 全局目标终止(售罄/下架,`targetDone`)→ 窗口结束 → 本账号所有目标已完成。 + +**保留的批次语义**:开抢前的「预打一轮」(`runWave(1)`)仍是全账号一波并发,用于探活与验证通道;只有正式窗口改成独立循环。 + +**验证**:新增 `TestRunTargetsPerAccountLoopsAreIndependent` —— 账号 1 的第一个兑换请求被阻塞时,账号 2 必须已经独立跑完 ≥3 轮(旧实现下账号 2 会卡在第二轮起点)。 + +### 11.2 共享列表:每轮一个新快照 → TTL + 单飞缓存 + +`SharePrizeList` 原来的语义是「每一轮所有账号共用同一份快照」,在批次调度下等价于每轮查 1 次列表。改成独立循环后「一轮」不再有全局含义,于是把共享口径改为**按时间的快照缓存**(`runState.sharedListOnce`): + +- 锁内优先复用 1 秒 TTL 内的成功快照;没有可用快照时**单飞**——只有一个账号真正发请求,其余账号等它完成后复用结果。 +- **只缓存成功结果**:失败不写缓存,否则一次抖动会让所有账号一起拿着错误快照失败。 +- `sharedListTTL = 1000ms`:单次查询约 100ms,单账号节奏是 `cfg.Delay`(默认 150~300ms)。1 秒既能让同一波账号复用一份快照(pc-1 的 152 个账号从每轮 152 次查询降到 1 次),又能及时看到库存变化(实测库存会回流,快照不能太旧)。 +- 关闭 `SharePrizeList` 时保持原样:各查各的,不做任何缓存。 + +**验证**:新增 `TestRunTargetsSharedListTTLSingleFlight` —— 2 账号 × 3 轮,列表请求数必须为 1(只做单飞、无 TTL 时是 3~6 次)。 + +### 11.3 移除 CLI(只保留 GUI) + +CLI 与 GUI 是两套并行入口(`prepareMonitor` / `logRunSummary` / 参数解析各一份),改动要同步两处,老计划里甚至专门列了「GUI 与 CLI 需保持一致」这一条。既然日常只用 GUI,直接删掉: + +- 删除 `cmd/huya-go/`;`internal/sniper` 的 `Runner`/`Config` 完全由 GUI 复用,删除 CLI 不影响引擎。 +- `internal/gui/app.go` 的 `StartMonitor` 成为唯一触发路径(时间校准 → 窗口计划 → `RunTargets`)。 +- `README.md`:删掉 CLI 构建/示例/参数表,改为「设置项说明」,并把「GUI 与 CLI 一致」等表述收敛为 GUI。 + +### 11.4 本轮新增测试(4 个,全部通过) + +| 用例 | 覆盖 | +|---|---| +| `TestRunTargetsPerAccountLoopsAreIndependent` | 慢账号不阻塞其他账号 | +| `TestRunTargetsSharedListTTLSingleFlight` | 共享列表 TTL + 单飞只查 1 次 | +| `TestRunStateClipToDeadline` | 等待被裁剪到窗口结束(含已过期与非窗口模式) | +| `TestRunStateAccountFinishedAndRoundTracking` | `accountFinished`(含全局目标终止)与 `maxRound` | + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 862f438..1465170 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -97,9 +97,12 @@ type UiConfig = { minLeft: number cookieText: string selectedAccountLines: number[] + // loops 是轮次上限:0(留空)表示按窗口运行到窗口结束。 loops: number delayMs: number concurrency: number + // windowSeconds 是抢购窗口秒数(窗口模式下跑到「开抢+窗口」才收工)。 + windowSeconds: number stopAfterSuccess: boolean stopAccountAfterSuccess: boolean prewarm: boolean @@ -114,6 +117,10 @@ type UiConfig = { errorBackoffMs: number timeoutSec: number startAt: string + // adaptiveThrottle 是「全局自适应降速」开关(表单用正语义)。 + adaptiveThrottle: boolean + // disableAdaptiveThrottle 只发给后端(后端零值=开启,所以用反语义字段),不落本地存储。 + disableAdaptiveThrottle: boolean } type AccountPreview = { @@ -301,7 +308,7 @@ function exchangeOrderID(exchange?: ExchangeResp): string { return exchange?.order_id || exchange?.orderId || '' } -type SavedConfig = Omit +type SavedConfig = Omit type ScheduleCountdown = { targetAt: number @@ -466,6 +473,10 @@ function categoryTag(category?: string): ReactNode { condition: { color: 'geekblue', label: '条件' }, failed: { color: 'warning', label: '失败' }, error: { color: 'error', label: '异常' }, + // protocol 是回包结构异常(缺 tRsp/tResp),单独归类便于排查服务端错误信封。 + protocol: { color: 'error', label: '协议异常' }, + // session_lost 是「账号未绑定」这类瞬时会话失效,通常下一轮就能恢复。 + session_lost: { color: 'volcano', label: '会话失效' }, } if (!category) return 待处理 const item = map[category] || { color: 'default', label: category } @@ -583,9 +594,10 @@ function pickConfig(values: Partial): UiConfig { minLeft: Number(values.minLeft || 0), cookieText: values.cookieText || '', selectedAccountLines: values.selectedAccountLines || [], - loops: Number(values.loops || 1), + loops: Number(values.loops || 0), delayMs: Number(values.delayMs || 150), concurrency: Number(values.concurrency || 1), + windowSeconds: Number(values.windowSeconds || 30), stopAfterSuccess: Boolean(values.stopAfterSuccess), stopAccountAfterSuccess: values.stopAccountAfterSuccess !== false, prewarm: values.prewarm !== false, @@ -599,6 +611,8 @@ function pickConfig(values: Partial): UiConfig { errorBackoffMs: Number(values.errorBackoffMs || 300), timeoutSec: Number(values.timeoutSec || 15), startAt: values.startAt || '', + adaptiveThrottle: values.adaptiveThrottle !== false, + disableAdaptiveThrottle: values.adaptiveThrottle === false, } } @@ -857,7 +871,7 @@ export default function Dashboard() { const errorCount = useMemo( () => results.filter((item) => - ['error', 'failed', 'not_enough', 'off_shelf', 'sold_out', 'condition'].includes( + ['error', 'failed', 'not_enough', 'off_shelf', 'sold_out', 'condition', 'protocol'].includes( item.category || '', ), ).length, @@ -1134,6 +1148,7 @@ export default function Dashboard() { loops: values.loops, delayMs: values.delayMs, concurrency: values.concurrency, + windowSeconds: values.windowSeconds, stopAfterSuccess: values.stopAfterSuccess, stopAccountAfterSuccess: values.stopAccountAfterSuccess, prewarm: values.prewarm, @@ -1147,6 +1162,7 @@ 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('已保存') @@ -1966,8 +1982,11 @@ export default function Dashboard() { - - + + + + + @@ -1975,8 +1994,8 @@ export default function Dashboard() { - - + +
@@ -2001,6 +2020,9 @@ export default function Dashboard() { + + +
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 8a4ce68..90a2062 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -46,6 +46,8 @@ export namespace gui { errorBackoffMs: number; timeoutSec: number; startAt: string; + windowSeconds: number; + disableAdaptiveThrottle: boolean; static createFrom(source: any = {}) { return new Config(source); @@ -80,6 +82,8 @@ export namespace gui { this.errorBackoffMs = source["errorBackoffMs"]; this.timeoutSec = source["timeoutSec"]; this.startAt = source["startAt"]; + this.windowSeconds = source["windowSeconds"]; + this.disableAdaptiveThrottle = source["disableAdaptiveThrottle"]; } } export class ExchangeResult { diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100755 new mode 100644 diff --git a/internal/gui/app.go b/internal/gui/app.go index b1d38fe..370909b 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -83,6 +83,12 @@ type Config struct { ErrorBackoffMS int `json:"errorBackoffMs"` TimeoutSec int `json:"timeoutSec"` StartAt string `json:"startAt"` + // WindowSeconds 是抢购窗口长度(秒):窗口模式下跑到「开抢 + 窗口」才收工, + // 账号少时也不会像旧实现那样在开抢前把轮次烧光。<=0 时取默认 30 秒。 + WindowSeconds int `json:"windowSeconds"` + // DisableAdaptiveThrottle 关闭全局自适应降速(默认开启,故用反向字段): + // 连续十几个请求都拿不到业务响应时整体降速,缓解同机同 IP 的限流风暴。 + DisableAdaptiveThrottle bool `json:"disableAdaptiveThrottle"` } type AccountPreview struct { @@ -499,7 +505,9 @@ func (a *App) StartMonitor(cfg Config) error { } a.emitState(true, "运行中") - a.addLog("info", "启动监控: sid=%d 类目=%s targets=%d accounts=%d prewarm=%v direct=%v", cfg.SID, sniper.CategoryText(cfg.Category), len(targets), len(accounts), cfg.Prewarm, cfg.DirectExchange) + a.addLog("info", "启动监控: sid=%d 类目=%s targets=%d 目标=%s accounts=%d prewarm=%v direct=%v 窗口=%s 自适应降速=%v", + cfg.SID, sniper.CategoryText(cfg.Category), len(targets), sniper.TargetSummary(targets), len(accounts), + cfg.Prewarm, cfg.DirectExchange, sniper.WindowDuration(cfg.WindowSeconds), !cfg.DisableAdaptiveThrottle) go func() { defer closeClient() @@ -512,41 +520,94 @@ func (a *App) StartMonitor(cfg Config) error { "loop=%d action=%s category=%s msg=%s error=%s", result.Loop, result.Action, result.Category, result.Message, result.Error) } runnerCfg := runnerConfig(cfg) + + // 立即抢购:预热完就进入窗口,窗口从当前时刻起算。 + if startAt.IsZero() { + if cfg.Prewarm { + a.addLog("info", "开始预热连接: accounts=%d", len(accounts)) + if err := runner.Prewarm(ctx, accounts, runnerCfg, emit); err != nil { + a.addLog("error", "连接预热异常: %v", err) + return + } + } + runnerCfg.OpenAt = time.Now() + a.logWindowPlan(runnerCfg.OpenAt, sniper.WindowDuration(cfg.WindowSeconds)) + a.runMonitor(ctx, runner, accounts, targets, runnerCfg, emit) + return + } + + // 定时抢购:临近复校 → 预热 → 触发,全部按服务端时钟对齐。 + // 早启动的旧实现会先预热再干等几十分钟,连接和时钟都可能已经漂了。 + lead := time.Duration(cfg.LeadMS) * time.Millisecond + window := sniper.WindowDuration(cfg.WindowSeconds) + plan := sniper.PlanTrigger(startAt, huya.TimeCalibration{}, lead, window) + + // 先复校时钟(开抢前 3s),再预热连接(开抢前 2s),最后等触发。 + // 顺序不能反:复校比预热更早,早做完才能把偏移用在这之后的所有时刻上。 + if cfg.TimeSync { + if err := a.waitFor(ctx, plan.ResyncAt, "复校"); err != nil { + return + } + calibration, calErr := huya.TryCalibrateServerTime(ctx, client, accounts[0].UID, accounts[0].Cookie, huya.DefaultTimeCalibrationRounds) + if calErr != nil { + a.addLog("warn", "时间校准失败,按本机时钟触发: %v", calErr) + } else { + plan = sniper.PlanTrigger(startAt, calibration, lead, window) + a.addLog("info", "时间校准完成: %s", calibration.Summary()) + } + } if cfg.Prewarm { - a.addLog("info", "开始预热连接: accounts=%d", len(accounts)) + 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 } } - if !startAt.IsZero() { - lead := time.Duration(cfg.LeadMS) * time.Millisecond - when := startAt.Add(-lead) - if cfg.TimeSync { - calibration, calErr := huya.TryCalibrateServerTime(ctx, client, accounts[0].UID, accounts[0].Cookie, huya.DefaultTimeCalibrationRounds) - if calErr != nil { - a.addLog("warn", "时间校准失败,按本机时钟触发: %v", calErr) - } else { - when = huya.AlignedTriggerTime(startAt, lead, calibration.Offset) - a.addLog("info", "时间校准完成: %s", calibration.Summary()) - a.addLog("info", "触发时刻已对齐服务端时钟: 本机=%s", when.Format("2006-01-02 15:04:05.000")) - } - } - a.addLog("info", "等待定时开始: %s 提前=%dms", when.Format("2006-01-02 15:04:05.000"), cfg.LeadMS) - if err := waitUntil(ctx, when); err != nil { - a.addLog("info", "定时等待已取消") - return - } - } - - err := runner.RunTargets(ctx, accounts, targets, runnerCfg, emit) - if err != nil && !errors.Is(err, context.Canceled) { - a.addLog("error", "监控结束异常: %v", err) + runnerCfg.OpenAt = plan.OpenAt + a.addLog("info", "等待定时开始: 触发=%s 开抢=%s 窗口结束=%s 提前=%dms 首发错峰=%dms", + plan.TriggerAt.Format("2006-01-02 15:04:05.000"), + plan.OpenAt.Format("2006-01-02 15:04:05.000"), + plan.WindowEnd.Format("2006-01-02 15:04:05.000"), + plan.Advance.Milliseconds(), + sniper.DefaultFirstWaveSpread.Milliseconds()) + if err := waitUntil(ctx, plan.TriggerAt); err != nil { + a.addLog("info", "定时等待已取消") + return } + a.runMonitor(ctx, runner, accounts, targets, runnerCfg, emit) }() return nil } +// runMonitor 执行抢购并把异常写进日志。 +func (a *App) runMonitor(ctx context.Context, runner sniper.Runner, accounts []sniper.Account, targets []sniper.Target, cfg sniper.Config, emit func(sniper.Result)) { + if err := runner.RunTargets(ctx, accounts, targets, cfg, emit); err != nil && !errors.Is(err, context.Canceled) { + a.addLog("error", "监控结束异常: %v", err) + } +} + +// waitFor 等到指定时刻,被取消时记录日志并返回错误。 +func (a *App) waitFor(ctx context.Context, when time.Time, label string) error { + if !when.After(time.Now()) { + return nil + } + if err := waitUntil(ctx, when); err != nil { + a.addLog("info", "%s等待已取消", label) + return err + } + return nil +} + +// logWindowPlan 打印本次抢购的窗口区间,便于复盘「打到什么时候为止」。 +func (a *App) logWindowPlan(openAt time.Time, window time.Duration) { + a.addLog("info", "抢购窗口: 开抢=%s 窗口=%s 结束=%s 提前=%s", + openAt.Format("2006-01-02 15:04:05.000"), window, + openAt.Add(window).Format("2006-01-02 15:04:05.000"), sniper.DefaultStartAhead) +} + // runnerConfig 把界面配置翻译成抢购引擎配置。 // // directExchange 是界面上的「直接兑换」开关:关掉时表示先查列表再兑换, @@ -569,18 +630,72 @@ func runnerConfig(cfg Config) sniper.Config { RefreshAfterSuccess: cfg.RefreshAfterSuccess, TooFrequentBackoff: time.Duration(cfg.FrequentBackoffMS) * time.Millisecond, ErrorBackoff: time.Duration(cfg.ErrorBackoffMS) * time.Millisecond, + WindowSeconds: cfg.WindowSeconds, + // 首发错峰固定用调优过的默认值:同机上百个账号齐射是最容易被限流的姿势。 + FirstWaveSpread: sniper.DefaultFirstWaveSpread, + FirstWaveMaxStep: sniper.DefaultFirstWaveMaxStep, + DisableAdaptiveThrottle: cfg.DisableAdaptiveThrottle, } } -// logRunSummary 输出一行任务汇总,并按次数列出各类结局,便于在「监控」日志里复盘。 +// logRunSummary 输出任务汇总:一行总量 + 结局分布 + 接口统计 + 每账号结局。 +// +// 复盘的痛点是「只有一堆逐条日志」:2026-10-16 的日志里既看不出限流占了多少、 +// 也不知道每个账号最后是什么下场,只能手工 grep 统计。这里一次给全。 func (a *App) logRunSummary(summary sniper.RunSummary) { - a.addLog("info", "任务结束: 轮次=%d 账号=%d 目标=%d 成功账号=%d 耗时=%s 请求[列表=%d 兑换=%d 积分=%d 记录=%d]", + total := 0 + for _, count := range summary.Outcomes { + total += count + } + throttled := summary.Outcomes[sniper.CategoryTooFrequent] + window := "" + 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, summary.Duration.Round(time.Millisecond), - summary.ListReqs, summary.ExchangeReqs, summary.ScoreReqs, summary.RecordReqs) + throttled, total, percentOf(throttled, total), throttleFactorText(summary.ThrottleFactor), + summary.ListReqs, summary.ExchangeReqs, summary.ScoreReqs, summary.RecordReqs, window) for _, category := range summary.Categories() { - a.logEvent(categoryMonitor, sniper.LogLevelForCategory(category), "结局 %s ×%d", category, summary.Outcomes[category]) + a.logEvent(categoryMonitor, sniper.LogLevelForCategory(category), "结局 %s ×%d(%s)", + category, summary.Outcomes[category], sniper.CategoryLabel(category)) } + for _, stat := range summary.RequestStats { + lastErr := "" + if stat.LastErr != "" { + lastErr = " 最后错误=" + stat.LastErr + } + a.logEvent(categoryMonitor, "info", "接口统计: %s ×%d 失败%d 平均=%s 最慢=%s%s", + stat.Kind, stat.Count, stat.Failed, + stat.Average().Round(time.Millisecond), stat.Slowest.Round(time.Millisecond), lastErr) + } + for _, outcome := range summary.AccountOutcomes { + level := logger.LevelInfo + if outcome.Category == sniper.CategorySuccess { + level = "success" + } else if !outcome.Done { + level = logger.LevelWarn + } + a.logAccount(categoryMonitor, level, strconv.FormatInt(outcome.UID, 10), + "最终结局=%s 尝试=%d 最后=%s", sniper.CategoryLabel(outcome.Category), outcome.Attempts, outcome.Message) + } +} + +// percentOf 计算百分比(分母为 0 时返回 0)。 +func percentOf(part, whole int) int { + if whole <= 0 { + return 0 + } + return part * 100 / whole +} + +// throttleFactorText 渲染自适应降速倍率。 +func throttleFactorText(factor float64) float64 { + if factor < 1 { + return 1 + } + return factor } func (a *App) StopMonitor() bool { @@ -659,12 +774,16 @@ func normalizeConfig(cfg Config) Config { cfg.Transport = "wss" } // WSSHost 留空表示走 launch.queryHttpDns 自动解析,不在这里补默认值。 - if cfg.Loops <= 0 { - cfg.Loops = 1 + // Loops 只作为可选上限:0(留空)表示「按窗口运行」,由 WindowSeconds 兜底。 + if cfg.Loops < 0 { + cfg.Loops = 0 } if cfg.DelayMS <= 0 { cfg.DelayMS = 150 } + if cfg.WindowSeconds <= 0 { + cfg.WindowSeconds = sniper.DefaultWindowSeconds + } if cfg.Concurrency <= 0 { cfg.Concurrency = 1 } diff --git a/internal/huya/diagnose.go b/internal/huya/diagnose.go new file mode 100644 index 0000000..6e7dd4d --- /dev/null +++ b/internal/huya/diagnose.go @@ -0,0 +1,29 @@ +package huya + +import ( + "errors" + "fmt" + + "huya-go/internal/wup" +) + +// ErrProtocolResponse 表示服务端响应的结构与协议约定不符(典型是缺少 tRsp/tResp)。 +// +// 这类错误既不是网络错误也不是业务失败:它说明服务端回了一个解不出来的包 +// (风控拦截、错误信封、被截断的帧都可能)。单独标记出来是为了让上层能把它 +// 与普通失败分开统计(见 sniper.CategoryProtocol),并在报错里带上 WUP 诊断信息。 +var ErrProtocolResponse = errors.New("协议响应异常") + +// rawFrameHeadBytes 是 debug 日志里打印的原始帧前缀长度。 +const rawFrameHeadBytes = 32 + +// protocolResponseError 组装「缺少 tRsp/tResp」错误,并带上 WUP 响应诊断。 +// +// 过去这里只有一句文案,服务端返回的 iRet/status/context 全被丢掉, +// 事后只能重放抓包(2026-10-16 pc-3 同一账号连续 13 次命中,无法定性)。 +func protocolResponseError(service, method string, resp *wup.Response) error { + if resp == nil { + return fmt.Errorf("%w: %s.%s 响应缺少 tRsp/tResp", ErrProtocolResponse, service, method) + } + return fmt.Errorf("%w: %s.%s 响应缺少 tRsp/tResp(%s)", ErrProtocolResponse, service, method, resp.Diagnose()) +} diff --git a/internal/huya/diagnose_test.go b/internal/huya/diagnose_test.go new file mode 100644 index 0000000..41c75d5 --- /dev/null +++ b/internal/huya/diagnose_test.go @@ -0,0 +1,43 @@ +package huya + +import ( + "errors" + "strings" + "testing" + + "huya-go/internal/wup" +) + +// TestProtocolResponseErrorCarriesDiagnostics 验证「缺少 tRsp/tResp」错误会带上 WUP 诊断信息, +// 并且可以用 errors.Is 判定——上层据此把它单独归类(sniper.CategoryProtocol), +// 而不是混进普通失败里(2026-10-16 pc-3 同一账号连续 13 次命中该错误)。 +func TestProtocolResponseErrorCarriesDiagnostics(t *testing.T) { + resp := &wup.Response{ + Message: -1, + Status: map[string]string{"STATUS_RESULT_CODE": "-1"}, + NewData: map[string][]byte{"tErr": {0x0c}}, + } + err := protocolResponseError(ServantWebActUI, FuncExchangePrize, resp) + if !errors.Is(err, ErrProtocolResponse) { + t.Fatalf("错误应可用 errors.Is(ErrProtocolResponse) 判定: %v", err) + } + text := err.Error() + for _, want := range []string{ + "响应缺少 tRsp/tResp", + "iRet=-1", + "STATUS_RESULT_CODE=-1", + "keys={tErr(1B)}", + } { + if !strings.Contains(text, want) { + t.Fatalf("错误信息 %q 缺少 %q", text, want) + } + } + + empty := protocolResponseError(ServantWebActUI, FuncExchangePrize, nil) + if !errors.Is(empty, ErrProtocolResponse) || !strings.Contains(empty.Error(), "响应缺少 tRsp/tResp") { + t.Fatalf("空响应错误=%v", empty) + } + if errors.Is(errors.New("timeout"), ErrProtocolResponse) { + t.Fatal("普通错误不该被判定为协议错误") + } +} diff --git a/internal/huya/http_client.go b/internal/huya/http_client.go index 7e844fb..b7cff60 100644 --- a/internal/huya/http_client.go +++ b/internal/huya/http_client.go @@ -204,6 +204,9 @@ func (c *HTTPClient) CallRPC(ctx context.Context, service, method string, reqStr if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { return nil, fmt.Errorf("http status %d: %s", httpResp.StatusCode, string(respBody[:min(len(respBody), 200)])) } + // debug 级打印原始帧前缀:协议异常(缺少 tRsp)时这是唯一的现场证据。 + c.logf("[HTTP] 响应 %s.%s status=%d %dB head=%s", + service, method, httpResp.StatusCode, len(respBody), wup.HexHead(respBody, rawFrameHeadBytes)) var resp wup.Response if err := resp.Decode(respBody); err != nil { @@ -215,7 +218,7 @@ func (c *HTTPClient) CallRPC(ctx context.Context, service, method string, reqStr return nil, err } if !ok { - return nil, fmt.Errorf("%s.%s 响应缺少 tRsp/tResp", service, method) + return nil, protocolResponseError(service, method, &resp) } } return &resp, nil diff --git a/internal/huya/wss_client.go b/internal/huya/wss_client.go index 4c9e3ae..b27e0b5 100644 --- a/internal/huya/wss_client.go +++ b/internal/huya/wss_client.go @@ -448,9 +448,13 @@ func (s *WSSSession) readLoop() { case WSSCmdWupRsp: var resp wup.Response if err := resp.Decode(cmd.Data); err != nil { - s.client.logf("[WSS] 解码 WUP 响应失败: %v", err) + s.client.logf("[WSS] 解码 WUP 响应失败: %v (cmd=%d data=%dB head=%s)", + err, cmd.CmdType, len(cmd.Data), wup.HexHead(cmd.Data, rawFrameHeadBytes)) continue } + // debug 级打印原始帧前缀:协议异常(缺少 tRsp)时这是唯一的现场证据。 + s.client.logf("[WSS] 收到 WUP 响应 requestID=%d data=%dB head=%s", + resp.RequestID, len(cmd.Data), wup.HexHead(cmd.Data, rawFrameHeadBytes)) s.resolveRequest(resp.RequestID, rpcResult{resp: &resp}) case WSSCmdHeartBeatAck, WSSCmdHeartBeatRsp: // 心跳响应无需处理。 @@ -712,7 +716,7 @@ func (s *WSSSession) callRPC(ctx context.Context, service, method string, reqStr return nil, err } if !ok { - return nil, fmt.Errorf("%s.%s 响应缺少 tRsp/tResp", service, method) + return nil, protocolResponseError(service, method, resp) } } return resp, nil diff --git a/internal/sniper/optimization_test.go b/internal/sniper/optimization_test.go new file mode 100644 index 0000000..d3f5df1 --- /dev/null +++ b/internal/sniper/optimization_test.go @@ -0,0 +1,531 @@ +package sniper + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "huya-go/internal/huya" +) + +// TestClassifyExchangeThrottleTexts 覆盖 2026-10-16 三台机器日志里真实出现过的限流文案。 +// +// 这些文案过去全部落到 failed:既看不出被限流,也享受不到限流该有的退避语义 +// (pc-1 的 1421 次请求里 917 次「活动太火爆」+ 244 次「领取频率限制」)。 +func TestClassifyExchangeThrottleTexts(t *testing.T) { + throttleTexts := []string{ + "活动太火爆了,请稍后重试", + "领取频率限制,请稍后重试!!", + "请求太频繁,请稍后重试", + "操作频繁,请稍后再试", + "当前网络繁忙,请稍后重试", + "您点击太快,请稍等再试", + } + for _, text := range throttleTexts { + resp := &huya.ScoreExchangePrizeResp{Status: 500, Msg: text} + if got := ClassifyExchange(resp, nil); got != CategoryTooFrequent { + t.Fatalf("ClassifyExchange(%q)=%s, want %s", text, got, CategoryTooFrequent) + } + } +} + +// TestClassifyExchangeOrderPrefersSpecificReason 验证具体原因优先于限流: +// 「库存不足,请稍后重试」必须判成售罄而不是限流。 +func TestClassifyExchangeOrderPrefersSpecificReason(t *testing.T) { + cases := []struct { + text string + want string + }{ + {text: "库存不足,请稍后重试", want: CategorySoldOut}, + {text: "该奖品已抢完", want: CategorySoldOut}, + {text: "兑换货币不足,请参与活动获得兑换货币", want: CategoryNotEnough}, + {text: "奖品暂未开放兑换", want: CategoryNotOpen}, + {text: "账号未绑定!", want: CategorySessionLost}, + {text: "登录态已失效,请重新登录", want: CategorySessionLost}, + {text: "奖品已下架", want: CategoryOffShelf}, + } + for _, tt := range cases { + resp := &huya.ScoreExchangePrizeResp{Status: 500, Msg: tt.text} + if got := ClassifyExchange(resp, nil); got != tt.want { + t.Fatalf("ClassifyExchange(%q)=%s, want %s", tt.text, got, tt.want) + } + } +} + +// TestClassifyExchangeProtocolError 验证协议层错误与普通失败分开归类。 +func TestClassifyExchangeProtocolError(t *testing.T) { + protocolErr := fmt.Errorf("%w: webActUI.scoreExchangePrize 响应缺少 tRsp/tResp(iRet=-1 keys={tErr})", huya.ErrProtocolResponse) + if got := ClassifyExchange(nil, protocolErr); got != CategoryProtocol { + t.Fatalf("协议错误应归为 %s,实际 %s", CategoryProtocol, got) + } + if got := ClassifyExchange(nil, errors.New("timeout")); got != CategoryError { + t.Fatalf("普通网络错误应归为 %s,实际 %s", CategoryError, got) + } +} + +// TestCategoryLabelAndLogLevel 验证新增分类的标签与日志等级映射。 +func TestCategoryLabelAndLogLevel(t *testing.T) { + if got := CategoryLabel(CategoryProtocol); got != "协议异常" { + t.Fatalf("CategoryLabel(protocol)=%q", got) + } + if got := LogLevelForCategory(CategoryProtocol); got != "error" { + t.Fatalf("协议异常应为 error 级,实际 %s", got) + } + if got := LogLevelForCategory(CategorySessionLost); got != "warn" { + t.Fatalf("会话失效应为 warn 级,实际 %s", got) + } +} + +// TestRetryEscalationMath 验证退避阶梯的两个纯函数。 +func TestRetryEscalationMath(t *testing.T) { + wantStreak := map[int]int{1: 1, 2: 1, 3: 2, 4: 4, 5: 4, 9: 4} + for streak, want := range wantStreak { + if got := streakMultiplier(streak); got != want { + t.Fatalf("streakMultiplier(%d)=%d, want %d", streak, got, want) + } + } + wantStage := map[time.Duration]int{ + 500 * time.Millisecond: 1, + 3 * time.Second: 2, + 6 * time.Second: 3, + } + for elapsed, want := range wantStage { + if got := stageMultiplier(elapsed, CategoryFailed); got != want { + 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) + } +} + +// 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) + } + + before := g.current() + g.observe(CategorySuccess) + if got := g.current(); got >= before { + t.Fatalf("有进展后应回退,factor 从 %v 到 %v", before, got) + } + if got := g.current(); got < 1 { + t.Fatalf("倍率不应小于 1,实际 %v", got) + } +} + +// TestRunStateFirstWaveStagger 验证首发错峰:第 0 个账号立即发,其余按步长错开,每个账号只生效一次。 +func TestRunStateFirstWaveStagger(t *testing.T) { + accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}, {UID: 3, Cookie: "yyuid=3"}} + state := newRunState(accounts, 1, 1, Config{ + OpenAt: time.Now(), + FirstWaveSpread: DefaultFirstWaveSpread, + FirstWaveMaxStep: DefaultFirstWaveMaxStep, + }) + want := []time.Duration{0, DefaultFirstWaveMaxStep, 2 * DefaultFirstWaveMaxStep} + for index, account := range accounts { + if got := state.takeFirstWaveDelay(account.UID); got != want[index] { + t.Fatalf("账号 %d 首发等待=%v, want %v", account.UID, got, want[index]) + } + } + if got := state.takeFirstWaveDelay(1); got != 0 { + t.Fatalf("同一账号不应重复错峰,实际 %v", got) + } +} + +// TestRunTargetsWindowModeKeepsTryingUntilDeadline 是 P1 的核心回归: +// 账号很少时,旧实现会「20 轮 × 150ms」在开抢前把预算烧光(pc-3 instance-4 0 成功), +// 窗口模式必须一直打到窗口结束。 +func TestRunTargetsWindowModeKeepsTryingUntilDeadline(t *testing.T) { + client := &fakeActivityClient{ + exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { + return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil + }, + } + runner := Runner{Client: client} + started := time.Now() + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ + SID: 2203, + OpenAt: started, + windowOverride: 120 * time.Millisecond, + Loops: 0, // 窗口模式:不限制轮次 + Delay: 5 * time.Millisecond, + ErrorBackoff: time.Millisecond, + TooFrequentBackoff: time.Millisecond, + }, nil) + if err != nil { + t.Fatal(err) + } + if elapsed := time.Since(started); elapsed < 100*time.Millisecond { + t.Fatalf("窗口模式应跑到窗口结束,实际只跑了 %v", elapsed) + } + _, exchangeCount, _ := client.counts() + if exchangeCount < 5 { + t.Fatalf("窗口内应持续尝试,实际只有 %d 次", exchangeCount) + } +} + +// TestRunTargetsPrefireOnceBeforeOpen 验证开抢前只预打一轮,然后静默等到窗口起点。 +func TestRunTargetsPrefireOnceBeforeOpen(t *testing.T) { + var mu sync.Mutex + var calls []time.Time + client := &fakeActivityClient{ + exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { + mu.Lock() + calls = append(calls, time.Now()) + mu.Unlock() + return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品暂未开放兑换"}, nil + }, + } + runner := Runner{Client: client} + // 触发时刻远早于窗口起点:应当先预打一轮,再静默等到窗口起点。 + openAt := time.Now().Add(500 * time.Millisecond) + started := time.Now() + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ + SID: 2203, + OpenAt: openAt, + windowOverride: 60 * time.Millisecond, + Loops: 0, + Delay: 5 * time.Millisecond, + StartAhead: 20 * time.Millisecond, + ErrorBackoff: time.Millisecond, + TooFrequentBackoff: time.Millisecond, + }, nil) + if err != nil { + t.Fatal(err) + } + mu.Lock() + captured := append([]time.Time(nil), calls...) + mu.Unlock() + if len(captured) < 2 { + t.Fatalf("应有预打 + 窗口内重试,实际 %d 次", len(captured)) + } + windowStart := openAt.Add(-20 * time.Millisecond) + if !captured[0].Before(windowStart.Add(-50 * time.Millisecond)) { + t.Fatalf("第一次请求应明显早于窗口起点(预打),实际 %v(窗口起点 %v)", captured[0], windowStart) + } + for _, at := range captured[1:] { + if at.Before(windowStart.Add(-30 * time.Millisecond)) { + t.Fatalf("预打之后应等到窗口起点,实际在 %v(窗口起点 %v)", at, windowStart) + } + } + if elapsed := time.Since(started); elapsed < 500*time.Millisecond { + t.Fatalf("应跑到窗口结束(%v),实际 %v", openAt.Add(60*time.Millisecond), elapsed) + } +} + +// TestRunTargetsSkipsPrefireNearWindowStart 验证触发时刻与窗口起点重合时不会把首发齐射打两遍。 +func TestRunTargetsSkipsPrefireNearWindowStart(t *testing.T) { + client := &fakeActivityClient{} + runner := Runner{Client: client} + openAt := time.Now().Add(60 * time.Millisecond) + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ + SID: 2203, + OpenAt: openAt, + windowOverride: 40 * time.Millisecond, + Loops: 1, // 只跑一轮,用于观察首发是否被重复 + StartAhead: 60 * time.Millisecond, + }, nil) + if err != nil { + t.Fatal(err) + } + if _, exchangeCount, _ := client.counts(); exchangeCount != 1 { + t.Fatalf("窗口起点附近不应额外预打,实际请求 %d 次", exchangeCount) + } +} + +// TestRunTargetsWindowAlreadyExpiredSkips 验证窗口已过时不再发请求。 +func TestRunTargetsWindowAlreadyExpiredSkips(t *testing.T) { + client := &fakeActivityClient{} + runner := Runner{Client: client} + started := time.Now() + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ + SID: 2203, + OpenAt: time.Now().Add(-2 * time.Second), + windowOverride: time.Second, + Loops: 0, + }, nil) + if err != nil { + t.Fatal(err) + } + if _, exchangeCount, _ := client.counts(); exchangeCount != 0 { + t.Fatalf("窗口已过不该发请求,实际 %d 次", exchangeCount) + } + if elapsed := time.Since(started); elapsed > 200*time.Millisecond { + t.Fatalf("窗口已过应立即返回,实际 %v", elapsed) + } +} + +// TestRunTargetsSessionLostStopsAfterLimit 验证会话失效连续多次后停止该账号。 +func TestRunTargetsSessionLostStopsAfterLimit(t *testing.T) { + client := &fakeActivityClient{ + exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { + return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "账号未绑定!"}, nil + }, + } + runner := Runner{Client: client} + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{ + SID: 2203, + Loops: 10, + Delay: 2 * time.Millisecond, + ErrorBackoff: time.Millisecond, + SessionLostLimit: 3, + }, nil) + if err != nil { + t.Fatal(err) + } + _, exchangeCount, _ := client.counts() + if exchangeCount != 3 { + t.Fatalf("连续失效达到阈值应停止该账号,实际请求 %d 次", exchangeCount) + } +} + +// TestRunTargetsSummaryIncludesRequestAndAccountStats 验证接口统计与逐账号结局。 +func TestRunTargetsSummaryIncludesRequestAndAccountStats(t *testing.T) { + client := &fakeActivityClient{ + exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { + return nil, errors.New("网络抖动") + }, + } + var summary RunSummary + 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, + Delay: time.Millisecond, + ErrorBackoff: time.Millisecond, + StopAfterSuccess: true, + }, func(Result) {}) + if err != nil { + t.Fatal(err) + } + var exchangeStat *RequestStat + for index := range summary.RequestStats { + if summary.RequestStats[index].Kind == reqExchange.String() { + exchangeStat = &summary.RequestStats[index] + } + } + if exchangeStat == nil { + t.Fatalf("缺少兑换接口统计: %+v", summary.RequestStats) + } + if exchangeStat.Count < 1 || exchangeStat.Failed != exchangeStat.Count || exchangeStat.LastErr == "" { + t.Fatalf("兑换接口统计不正确: %+v", *exchangeStat) + } + if exchangeStat.Slowest <= 0 { + t.Fatalf("应记录最慢耗时: %+v", *exchangeStat) + } + if len(summary.AccountOutcomes) != 1 { + t.Fatalf("应有 1 条账号结局,实际 %+v", summary.AccountOutcomes) + } + outcome := summary.AccountOutcomes[0] + if outcome.UID != 7 || outcome.Category != CategoryError || outcome.Attempts < 1 { + t.Fatalf("账号结局不正确: %+v", outcome) + } +} + +// TestPlanTriggerDynamicAdvance 验证定时触发计划:动态提前量、预热/复校/窗口时刻。 +func TestPlanTriggerDynamicAdvance(t *testing.T) { + target := time.Date(2026, 10, 16, 17, 0, 0, 0, time.Local) + calibration := huya.TimeCalibration{ + Offset: 900 * time.Millisecond, // 本机比服务端慢 0.9s(pc-3 实测) + ErrorBound: 30 * time.Millisecond, + RTT: 60 * time.Millisecond, + } + plan := PlanTrigger(target, calibration, 0, 30*time.Second) + + if want := 60 * time.Millisecond; plan.Advance != want { + t.Fatalf("动态提前量=%v, want %v", plan.Advance, want) + } + wantOpen := target.Add(-900 * time.Millisecond) + if !plan.OpenAt.Equal(wantOpen) { + t.Fatalf("开抢时刻=%v, want %v", plan.OpenAt, wantOpen) + } + if !plan.TriggerAt.Equal(wantOpen.Add(-60 * time.Millisecond)) { + t.Fatalf("触发时刻=%v", plan.TriggerAt) + } + if !plan.WarmupAt.Equal(wantOpen.Add(-WarmupLead)) { + t.Fatalf("预热时刻=%v", plan.WarmupAt) + } + if !plan.ResyncAt.Equal(wantOpen.Add(-ResyncLead)) { + t.Fatalf("复校时刻=%v", plan.ResyncAt) + } + if !plan.WindowEnd.Equal(wantOpen.Add(30 * time.Second)) { + t.Fatalf("窗口结束=%v", plan.WindowEnd) + } + + if got := PlanTrigger(target, calibration, 2500*time.Millisecond, time.Second).Advance; got != 2500*time.Millisecond { + t.Fatalf("显式提前量应覆盖动态值,实际 %v", got) + } + if got := PlanTrigger(target, huya.TimeCalibration{}, 0, time.Second).Advance; got != MinTriggerAdvance { + t.Fatalf("提前量下限=%v, want %v", got, MinTriggerAdvance) + } + if got := WindowDuration(0); got != DefaultWindowSeconds*time.Second { + t.Fatalf("默认窗口=%v", got) + } +} + +// TestRunTargetsPerAccountLoopsAreIndependent 是「每账号独立循环」的核心回归: +// 一个账号卡在慢请求上时,其他账号必须继续按自己的节奏推进。 +// +// 旧实现按「一轮打完等所有人」(wg.Wait)同步,账号 1 的慢请求会把账号 2 的第二轮 +// 死死卡在起跑线上;账号少的实例尤其吃亏(pc-3 instance-4 因此 0 成功)。 +func TestRunTargetsPerAccountLoopsAreIndependent(t *testing.T) { + blocked := make(chan struct{}) + entered := make(chan struct{}) + var enterOnce sync.Once + var slowExchanges, fastExchanges int64 + + client := &fakeActivityClient{ + exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { + if uid == 1 { + atomic.AddInt64(&slowExchanges, 1) + enterOnce.Do(func() { close(entered) }) + <-blocked + return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil + } + atomic.AddInt64(&fastExchanges, 1) + return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil + }, + } + runner := Runner{Client: client} + accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}} + + done := make(chan error, 1) + go func() { + done <- runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{ + SID: 2203, + Loops: 30, + Delay: time.Millisecond, + ErrorBackoff: time.Millisecond, + TooFrequentBackoff: time.Millisecond, + Concurrency: 4, + }, nil) + }() + + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("账号 1 的慢请求未开始") + } + + // 账号 1 仍阻塞在第一个请求里:账号 2 应当已经独立跑完多轮。 + deadline := time.Now().Add(2 * time.Second) + for atomic.LoadInt64(&fastExchanges) < 3 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + got := atomic.LoadInt64(&fastExchanges) + close(blocked) + + if err := <-done; err != nil { + t.Fatal(err) + } + if got < 3 { + t.Fatalf("账号 1 阻塞期间账号 2 只完成了 %d 次兑换,独立循环应继续推进(want >= 3)", got) + } +} + +// TestRunTargetsSharedListTTLSingleFlight 验证共享列表快照是「TTL + 单飞」: +// 多个账号多轮里只查一次列表,既不被单次抖动拖垮,也不把名单打爆。 +// +// 只做单飞(无 TTL)时每轮都会重新查询,2 账号 × 3 轮最坏要 6 次。 +func TestRunTargetsSharedListTTLSingleFlight(t *testing.T) { + client := &fakeActivityClient{ + listResp: &huya.GetActPrizeListResp{ + Status: 200, + Msg: "请求成功", + Prizes: []huya.PrizeInfo{{ID: 12889, SID: 2203, Name: "套装-潮萌小黄鸭", NewScore: 988, LeftNum: 10}}, + }, + exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { + return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil + }, + } + runner := Runner{Client: client} + accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}} + + err := runner.RunTargets(context.Background(), accounts, []Target{{Name: "小黄鸭"}}, Config{ + SID: 2203, + Loops: 3, + Delay: time.Millisecond, + ErrorBackoff: time.Millisecond, + TooFrequentBackoff: time.Millisecond, + Concurrency: 4, + ListBeforeExchange: true, + SharePrizeList: true, + }, nil) + if err != nil { + t.Fatal(err) + } + listCount, _, _ := client.counts() + if listCount != 1 { + t.Fatalf("共享列表应只查一次(TTL 内复用),实际 %d 次", listCount) + } +} + +// TestRunStateClipToDeadline 验证等待时间被裁剪到窗口结束,不会睡过 deadline。 +func TestRunStateClipToDeadline(t *testing.T) { + state := &runState{deadline: time.Now().Add(50 * time.Millisecond)} + if got := state.clipToDeadline(10 * time.Millisecond); got != 10*time.Millisecond { + t.Fatalf("窗口内等待不应被改动,实际 %v", got) + } + if got := state.clipToDeadline(time.Second); got <= 0 || got > 60*time.Millisecond { + t.Fatalf("超出窗口的等待应被裁剪,实际 %v", got) + } + if got := (&runState{deadline: time.Now().Add(-time.Millisecond)}).clipToDeadline(time.Second); got != 0 { + t.Fatalf("窗口已过应不再等待,实际 %v", got) + } + if got := (&runState{}).clipToDeadline(time.Second); got != time.Second { + t.Fatalf("非窗口模式不应裁剪,实际 %v", got) + } +} + +// TestRunStateAccountFinishedAndRoundTracking 覆盖独立循环新增的两个判定: +// accountFinished(账号自己打完就退出,同时尊重全局目标终止)与最大轮次统计。 +func TestRunStateAccountFinishedAndRoundTracking(t *testing.T) { + state := newRunState([]Account{{UID: 1}}, 2, 1, Config{}) + if state.accountFinished(1, 2) { + t.Fatal("目标都没完成时不应判定账号结束") + } + state.accounts[1][0].Done = true + if state.accountFinished(1, 2) { + t.Fatal("还有一个目标未完成时不应判定账号结束") + } + state.accounts[1][1].Done = true + if !state.accountFinished(1, 2) { + t.Fatal("所有目标完成后应判定账号结束") + } + + // 全局目标终止(售罄 / 下架)后,即使本账号状态未落,也算完成。 + global := newRunState([]Account{{UID: 2}}, 1, 1, Config{}) + global.targetDone[0] = true + if !global.accountFinished(2, 1) { + 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 { + t.Fatalf("最大轮次=%d, want 7", got) + } +} diff --git a/internal/sniper/sniper.go b/internal/sniper/sniper.go index 6f91e97..001da0d 100644 --- a/internal/sniper/sniper.go +++ b/internal/sniper/sniper.go @@ -29,12 +29,127 @@ const ( // CategoryCondition 表示服务端返回了 precondition 门禁(条件未达成), // 没有可识别文案,按可重试失败处理但单独分类便于观察。 CategoryCondition = "condition" + // CategoryProtocol 表示服务端回包结构与协议约定不符(缺少 tRsp/tResp、解码失败等)。 + // + // 单列一类是为了把「服务端回了看不懂的包」和普通业务失败分开:2026-10-16 pc-3 + // 日志里同一账号连续 13 次命中它,而同实例另外 28 个账号全部成功。 + CategoryProtocol = "protocol" + // CategorySessionLost 表示会话/身份在服务端被重置(典型文案「账号未绑定!」)。 + // + // 实测是瞬时错误——同一账号下一轮就能成功(pc-1 1199665636376:loop4 未绑定、 + // loop5 success),所以照常重试,只是单独计数,避免和真失败混在一起。 + CategorySessionLost = "session_lost" // actionExchangeCanceled 表示申请并发额度时任务已被取消:这次请求根本没发出去, // 不应产出结果,否则停止监控时每个排队账号都会刷一条错误。 actionExchangeCanceled = "exchange_canceled" + + // DefaultWindowSeconds 是窗口模式下默认的抢购窗口长度。 + // + // 实测 2026-10-16 开抢窗口只有约 9 秒(17:00:00.1 放量 → 17:00:09 售罄),但库存会 + // 因为别人锁单未支付 / 订单释放而回流(pc-2 在 17:02 手动补兑仍成功), + // 所以默认给 30 秒:既能覆盖争抢期,又不会无限重试。 + DefaultWindowSeconds = 30 + // DefaultStartAhead 是窗口循环相对开抢时刻的提前量(先手缓冲), + // 让第一轮请求正好落在开抢瞬间而不是晚一个 RTT。 + DefaultStartAhead = 60 * time.Millisecond + // DefaultFirstWaveSpread / DefaultFirstWaveMaxStep 是首发错峰参数: + // 第 0 个账号立即发,其余按 min(spread/(n-1), maxStep) 依次错开,整批最多铺开 spread。 + // 同机 152 个账号在同一毫秒齐射会把整机压上服务端令牌桶(pc-1 实测 82% 请求被限流)。 + DefaultFirstWaveSpread = 300 * time.Millisecond + DefaultFirstWaveMaxStep = 8 * time.Millisecond + + // maxRetryDelay 是任何一次账号退避的上限。 + // + // 抢购窗口只有十几秒,库存随时可能回流,等待超过 1.5 秒就可能正好错过它 + // (参考 dygou_go:把任何一次等待都压在 2 秒以内)。 + maxRetryDelay = 1500 * time.Millisecond + + // governorMinRequests / governorStallWindow / governorMaxFactor 控制全局自适应降速。 + // + // 触发信号刻意选「无进展」而不是「限流占比」:2026-10-16 的日志显示,即使成功率 + // 97.6% 的那一次,被限流的请求也占 67%,用占比阈值会把正常运行一起拖慢。 + // 真正的危险信号是「发了一堆请求、连一个业务响应都拿不到」,说明整机已经压在 + // 服务端令牌桶上(pc-1 峰值 122 req/s)。此时按 1.5 倍逐级降速,一旦恢复进展就快速回退。 + governorMinRequests = 12 + governorStallWindow = 800 * time.Millisecond + governorMaxFactor = 4.0 + governorStep = 1.5 + + // defaultRetryBackoff 是配置缺失时的退避基准(界面默认值同样是 300ms)。 + defaultRetryBackoff = 300 * time.Millisecond + // prefireMinGap 是「还值得单独预打一轮」的最小时间余量:小于它就说明已经在窗口起点附近, + // 直接进入窗口循环即可,避免把首发齐射打两遍。 + prefireMinGap = 250 * time.Millisecond + // defaultSessionLostLimit 是「连续会话失效多少次后停止该账号」的默认阈值。 + // 实测同一账号下一轮就能成功,所以阈值不能太小(3 次足以排除偶发抖动)。 + defaultSessionLostLimit = 3 + + // sharedListTTL 是共享奖品列表快照(SharePrizeList)的存活时间。 + // + // 单次列表查询约 100ms,而单账号节奏是 cfg.Delay(默认 150~300ms):TTL 取 1 秒既能让 + // 同一波账号复用同一份快照(pc-1 的 152 个账号从 152 次查询降到 1 次),又能在库存 + // 变化时及时刷新(实测库存会因别人锁单未支付而回流,快照不能太旧)。 + sharedListTTL = 1000 * time.Millisecond ) +// TargetSummary 把目标列表渲染成「13437(冠军之刃)」这样的单行摘要,供启动日志使用。 +// +// 2026-10-16 的复盘正卡在「日志里没有目标 pid」:三台机器都只记了 sid 与 targets=1, +// 看不出各自打的是哪个奖品,也就无法解释为什么一台售罄、另一台库存完好。 +func TargetSummary(targets []Target) string { + parts := make([]string, 0, len(targets)) + for _, target := range targets { + name := strings.TrimSpace(target.Name) + switch { + case target.PID > 0 && name != "": + parts = append(parts, fmt.Sprintf("%d(%s)", target.PID, name)) + case target.PID > 0: + parts = append(parts, strconv.FormatInt(int64(target.PID), 10)) + case name != "": + parts = append(parts, name) + case target.Auto: + parts = append(parts, "自动选择") + } + } + if len(parts) == 0 { + return "未指定" + } + return strings.Join(parts, ",") +} + +// CategoryLabel 返回结局分类的中文短标签,供任务结束时的结局日志使用。 +func CategoryLabel(category string) string { + switch category { + case CategorySuccess: + return "已抢到" + case CategorySoldOut: + return "售罄" + case CategoryTooFrequent: + return "被限流" + case CategoryNotOpen: + return "未开放" + case CategoryNotEnough: + return "积分不足" + case CategoryAlreadyDone: + return "已兑换过" + case CategoryOffShelf: + return "已下架" + case CategorySessionLost: + return "会话失效" + case CategoryProtocol: + return "协议异常" + case CategoryCondition: + return "条件未达成" + case CategoryNotFound: + return "未匹配到奖品" + case CategoryError: + return "请求异常" + default: + return "失败" + } +} + // CategoryText 把类目限制渲染成日志文案(空串表示不限类目)。 func CategoryText(category string) string { key := strings.TrimSpace(category) @@ -49,9 +164,9 @@ func LogLevelForCategory(category string) string { switch category { case CategorySuccess: return "success" - case CategoryError: + case CategoryError, CategoryProtocol: return "error" - case CategoryFailed, CategoryTooFrequent, CategoryCondition: + case CategoryFailed, CategoryTooFrequent, CategoryCondition, CategorySessionLost: return "warn" default: return "info" @@ -95,6 +210,31 @@ type Config struct { StopOnSoldOut bool TooFrequentBackoff time.Duration ErrorBackoff time.Duration + + // OpenAt 是本次抢购的开抢时刻(已按服务端时钟对齐的本机时刻)。 + // + // 非零即「窗口模式」:终止条件由 OpenAt+WindowSeconds 决定,Loops 只作为可选的 + // 轮次上限。这样账号少时也不会像旧实现那样「20 轮 × 150ms = 3 秒」在开抢前把预算烧光 + // (2026-10-16 pc-3 instance-4 只有一个账号,20 轮全部打在开抢前 0.3 秒,0 成功)。 + OpenAt time.Time + // WindowSeconds 是窗口长度(秒);窗口模式下 <=0 时取 DefaultWindowSeconds。 + WindowSeconds int + // StartAhead 是窗口循环相对 OpenAt 的提前量;<=0 时取 DefaultStartAhead。 + StartAhead time.Duration + // FirstWaveSpread / FirstWaveMaxStep 是首发错峰参数,参考 dygou_go 的 firstWaveSpread。 + // FirstWaveSpread<=0 表示不启用错峰(测试与直接调用 RunTargets 的老行为)。 + FirstWaveSpread time.Duration + FirstWaveMaxStep time.Duration + // DisableAdaptiveThrottle 关闭全局自适应降速。 + // + // 默认开启(Go 零值无法表达「默认开」,所以这里用反向开关):产品入口(GUI/CLI) + // 默认不设置它,也就是开启;只有显式配置才关闭。 + DisableAdaptiveThrottle bool + // SessionLostLimit 是同一账号连续多少次「会话失效」后停止该账号;<=0 时取默认值 3。 + SessionLostLimit int + + // windowOverride 仅供测试:非零时直接作为窗口长度,避免「秒」粒度把单测拖慢。 + windowOverride time.Duration } type Result struct { @@ -134,6 +274,63 @@ type RunSummary struct { RecordReqs int SuccessUIDs int Outcomes map[string]int + // AccountOutcomes 是每账号一行的最终结局(按 uid 升序),供任务结束后逐行复盘。 + AccountOutcomes []AccountOutcome + // RequestStats 是按接口类型的请求统计(次数/失败/平均/最慢/最后错误)。 + RequestStats []RequestStat + // WindowSeconds / OpenAt 描述本次窗口;非窗口模式时为零值。 + WindowSeconds int + OpenAt time.Time + // ThrottleFactor 是自适应降速用到的最大倍率(1 表示全程没有触发降速)。 + ThrottleFactor float64 +} + +// AccountOutcome 是单个账号在一次任务里的最终结局。 +type AccountOutcome struct { + UID int64 `json:"uid"` + Category string `json:"category"` + Message string `json:"message,omitempty"` + Attempts int `json:"attempts"` + Done bool `json:"done,omitempty"` +} + +// RequestStat 是一种接口的调用统计。 +type RequestStat struct { + Kind string `json:"kind"` + Count int `json:"count"` + Failed int `json:"failed"` + Total time.Duration `json:"total"` + Slowest time.Duration `json:"slowest"` + LastErr string `json:"lastErr,omitempty"` +} + +// Average 返回平均耗时。 +func (s RequestStat) Average() time.Duration { + if s.Count <= 0 { + return 0 + } + return s.Total / time.Duration(s.Count) +} + +// requestStat 是内部的请求统计累加器。 +type requestStat struct { + count int + failed int + total time.Duration + slowest time.Duration + lastErr string +} + +func (s *requestStat) observe(cost time.Duration, err error) { + s.count++ + s.total += cost + if cost > s.slowest { + s.slowest = cost + } + if err != nil { + s.failed++ + s.lastErr = err.Error() + } } // Categories 按次数从多到少返回结局分类,次数相同时按名称排序,保证日志稳定。 @@ -161,6 +358,9 @@ type runState struct { targetDone []bool // exchanged 记录每个账号已成功兑换的奖品,避免多个 target 命中同一奖品时重复兑换。 exchanged map[int64]map[int64]bool + // duplicateNotified 保证「该奖品已兑换过」只提示一次:窗口模式下每轮都会跳过, + // 不记录会刷屏。 + duplicateNotified map[int64]map[int64]bool // refresh 用于等待成功后的异步刷新,确保 RunTargets 返回前不会提前释放连接。 refresh sync.WaitGroup // sem 是请求级并发闸门:一个额度对应一次在途请求,账号在退避等待期间 @@ -169,6 +369,29 @@ type runState struct { sem chan struct{} // stats 记录请求次数与结局分布,任务结束时汇总成一行日志。 stats runStats + + // 窗口模式:deadline 非零表示到点即收工;openAt 用于「开抢前只预打一轮」。 + startedAt time.Time + openAt time.Time + deadline time.Time + windowLen time.Duration + startAhead time.Duration + prefireDone bool + // 首发错峰:第 0 个账号立即发,其余按 step 依次错开,整批最多铺开 spread。 + firstWaveSpread time.Duration + firstWaveStep time.Duration + firstWaveUsed int + firstWaveSent map[int64]bool + // governor 是全局自适应降速器;DisableAdaptiveThrottle 时为 nil(空操作)。 + governor *throttleGovernor + // rounds 记录所有账号里跑过的最大轮次,供汇总日志使用。 + rounds int + // listMu 保护共享列表快照(SharePrizeList 模式):listInflight 非空表示 + // 已有账号在查询,其余账号等它完成后复用结果。 + listMu sync.Mutex + listSnapshot *listSnapshot + listCachedAt time.Time + listInflight chan struct{} } // runStats 是一次任务的运行统计。 @@ -179,11 +402,19 @@ type runStats struct { recordReqs int outcomes map[string]int successUIDs map[int64]bool + // requests 按接口类型记录次数/失败/耗时,任务结束时输出接口统计行。 + requests map[requestKind]*requestStat + // accounts 是每账号的最终结局,任务结束时逐行输出。 + accounts map[int64]*AccountOutcome } type targetState struct { Done bool RetryAfter time.Time + // FailStreak 是连续可重试失败的次数,用于放大退避(成功/正常等待类结局会清零)。 + FailStreak int + // SessionStreak 是连续「会话失效」次数,达到阈值即停止该账号该奖品。 + SessionStreak int } type listSnapshot struct { @@ -215,25 +446,25 @@ func (r *Runner) RunTargets(ctx context.Context, accounts []Account, targets []T } } - state := newRunState(accounts, len(targets), cfg.Concurrency) + state := newRunState(accounts, len(targets), cfg.Concurrency, cfg) startedAt := time.Now() - loopsRun := 0 + // 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), loopsRun, time.Since(startedAt)) + r.emitSummary(state, len(accounts), len(targets), state.maxRound(), time.Since(startedAt)) }() - for loop := 1; loop <= cfg.Loops; loop++ { - loopsRun = loop - if ctx.Err() != nil { - return ctx.Err() - } - if state.allDone() { - return nil - } - sharedList := r.sharedList(ctx, client, accounts, targets, cfg, state) + runWave := func(loop int) { var wg sync.WaitGroup for _, account := range accounts { account := account @@ -242,22 +473,45 @@ func (r *Runner) RunTargets(ctx context.Context, accounts []Account, targets []T defer wg.Done() // 并发额度由每次请求自己申请(见 runState.acquire), // 账号在退避等待期间不占额度。 - r.runAccountLoop(ctx, client, state, account, targets, cfg, loop, sharedList, emit, cancel) + 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() + runWave(1) if ctx.Err() != nil { return ctx.Err() } - if state.allDone() { - return nil - } - if loop < cfg.Loops { - if err := sleepContext(ctx, cfg.Delay); err != nil { + if !state.windowExpired() { + if err := sleepContext(ctx, time.Until(state.windowStart())); 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 } @@ -305,7 +559,45 @@ func (r *Runner) Prewarm(ctx context.Context, accounts []Account, cfg Config, em return ctx.Err() } -func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, loop int, sharedList *listSnapshot, emit func(Result), cancel context.CancelFunc) { +// runAccountLoop 是单个账号的独立抢购循环:按自己的节奏重试,直到窗口结束、 +// 自己完成或任务被停止。账号之间不互相等待,也不共享节奏。 +func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, emit func(Result), cancel context.CancelFunc) { + // 首发错峰:第 0 个账号立即发,其余按小步长依次错开,避免同机上百个账号 + // 在同一毫秒齐射把整机压上服务端令牌桶。 + if wait := state.takeFirstWaveDelay(account.UID); wait > 0 { + if err := sleepContext(ctx, wait); err != nil { + return + } + } + + for round := 1; ; round++ { + if ctx.Err() != nil { + return + } + // Loops>0 是显式轮次上限(非窗口模式与测试用);窗口模式下留空表示打到窗口结束。 + if cfg.Loops > 0 && round > cfg.Loops { + return + } + if state.allDone() || state.windowExpired() || state.accountFinished(account.UID, len(targets)) { + return + } + state.noteRound(round) + r.runAccountRound(ctx, client, state, account, targets, cfg, round, emit, cancel) + if ctx.Err() != nil || state.allDone() || state.windowExpired() { + return + } + // 本账号自己的节奏:固定等 cfg.Delay。退避中的目标会在下一轮被 activeTargets 跳过, + // 所以这里不需要额外等待退避时间(等待本身也会被窗口末尾裁剪)。 + if wait := state.clipToDeadline(cfg.Delay); wait > 0 { + if err := sleepContext(ctx, wait); err != nil { + return + } + } + } +} + +// runAccountRound 是单账号的一轮尝试:按目标做一次兑换(或多个目标各一次)。 +func (r *Runner) runAccountRound(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, loop int, emit func(Result), cancel context.CancelFunc) { active := state.activeTargets(account.UID, len(targets), time.Now()) if len(active) == 0 { return @@ -317,7 +609,7 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, if shouldDirectExchange(target, cfg) { // 直兑路径没有奖品列表,用 target.PID 判断是否已经兑换过同一奖品。 if state.prizeExchanged(account.UID, int64(target.PID)) { - state.emitSkippedDuplicate(emit, loop, account.UID, index, target) + state.emitSkippedDuplicate(emit, loop, account.UID, index, int64(target.PID), target) continue } result := r.exchangeTarget(ctx, client, state, account, target, index, loop, cfg.SID, nil) @@ -343,16 +635,9 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, return } - list := sharedList - if list == nil { - if !state.acquire(ctx) { - return - } - state.countRequest(reqList) - resp, err := client.GetActPrizeList(ctx, account.UID, account.Cookie, cfg.SID) - state.release() - list = &listSnapshot{resp: resp, err: err} - } + // SharePrizeList 打开时,多账号复用同一份列表快照(带 TTL 的单飞缓存), + // 避免每个账号每一轮都各查一次列表。 + list := r.listForAccount(ctx, client, account, cfg, state) if list.err != nil || list.resp == nil { errText := "奖品列表响应为空" if list.err != nil { @@ -403,7 +688,7 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, } // 多个 target 可能命中同一个奖品(例如按不同名称匹配),已成功兑换过就跳过。 if state.prizeExchanged(account.UID, prize.ID) { - state.emitSkippedDuplicate(emit, loop, account.UID, index, target) + state.emitSkippedDuplicate(emit, loop, account.UID, index, prize.ID, target) continue } result := r.exchangeTarget(ctx, client, state, account, target, index, loop, cfg.SID, prize) @@ -425,8 +710,13 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, } // emitSkippedDuplicate 在跳过同一奖品的重复兑换时给出明确结果,便于界面观察。 -func (s *runState) emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, target Target) { - s.recordOutcome(CategoryAlreadyDone) +// +// 每个「账号 + 奖品」只提示一次:窗口模式下每轮都会跳过同一个奖品, +// 不记录会刷出一整个窗口的重复提示(但请求数不会增加)。 +func (s *runState) emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, prizeID int64, target Target) { + if s.markDuplicateNotified(uid, prizeID) { + s.recordOutcome(CategoryAlreadyDone) + } if emit == nil { return } @@ -441,6 +731,20 @@ func (s *runState) emitSkippedDuplicate(emit func(Result), loop int, uid int64, }) } +// 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 { @@ -461,9 +765,10 @@ func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, result.Error = ctx.Err().Error() return result } - state.countRequest(reqExchange) + 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) @@ -478,8 +783,8 @@ func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, } func (r *Runner) applyExchangeDecision(state *runState, account Account, index int, cfg Config, result Result, cancel context.CancelFunc) Result { - now := time.Now() state.recordOutcome(result.Category) + state.observeThrottle(result.Category) if result.Category == CategorySuccess { state.markSuccessUID(account.UID) } @@ -501,28 +806,292 @@ func (r *Runner) applyExchangeDecision(state *runState, account Account, index i // 已下架 / 奖品不存在:本窗口内不会再出现,直接终止该 target。 state.markTargetDone(index) result.Done = true + case CategorySessionLost: + // 会话失效实测是瞬时的(下一轮即可成功),所以先照常重试; + // 只有连续多次才停止该账号,避免真的失效后白打一整个窗口的请求。 + streak := state.bumpSessionStreak(account.UID, index) + if limit := sessionLostLimit(cfg); streak >= limit { + state.markDone(account.UID, index) + result.Done = true + result.Message = fmt.Sprintf("会话连续失效 %d 次,停止该账号", streak) + } else { + result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category) + } case CategorySoldOut: if cfg.StopOnSoldOut { state.markTargetDone(index) result.Done = true } else { // 别人锁单未支付或取消会让库存回流,窗口内继续探测(只是放慢节奏)。 - retryAt := now.Add(jitterDuration(cfg.ErrorBackoff)) - state.setRetryAfter(account.UID, index, retryAt) - result.RetryAfter = retryAt.UnixMilli() + result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category) } case CategoryTooFrequent: - retryAt := now.Add(jitterDuration(cfg.TooFrequentBackoff)) - state.setRetryAfter(account.UID, index, retryAt) - result.RetryAfter = retryAt.UnixMilli() - case CategoryError, CategoryFailed, CategoryCondition: - retryAt := now.Add(jitterDuration(cfg.ErrorBackoff)) - state.setRetryAfter(account.UID, index, retryAt) - result.RetryAfter = retryAt.UnixMilli() + result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category) + case CategoryError, CategoryProtocol, CategoryFailed, CategoryCondition: + result.RetryAfter = state.scheduleRetry(account.UID, index, cfg, result.Category) } return result } +// sessionLostLimit 返回「连续会话失效多少次后停止该账号」的阈值。 +func sessionLostLimit(cfg Config) int { + if cfg.SessionLostLimit > 0 { + return cfg.SessionLostLimit + } + return defaultSessionLostLimit +} + +// scheduleRetry 计算并登记某个账号下一次尝试的时间,返回毫秒时间戳供日志展示。 +func (s *runState) scheduleRetry(uid int64, index int, cfg Config, category string) int64 { + delay := s.retryDelay(uid, index, cfg, category) + // 窗口模式下退避不能跨过窗口结束,否则这段等待纯属浪费。 + if !s.deadline.IsZero() { + remaining := time.Until(s.deadline) + if remaining <= 0 { + delay = 0 + } else if delay > remaining { + delay = remaining + } + } + when := time.Now().Add(delay) + s.setRetryAfter(uid, index, when) + return when.UnixMilli() +} + +// retryDelay 计算账号下一次尝试前的退避时间。 +// +// 阶梯参考 dygou_go 的 retryDelay: +// 1. 基础值来自界面配置(ErrorBackoff / TooFrequentBackoff,默认 300ms); +// 2. 按「已抢购时长」分档放大:开抢瞬间要快,窗口后段自然放慢(库存回流概率低); +// 3. 同一账号连续失败再翻倍(上限 ×4),避免单个账号被风控盯上; +// 4. 乘全局自适应降速倍率(限流风暴时整体降速); +// 5. 叠加 0-25% 抖动,避免整批账号同一时刻重试形成尖峰; +// 6. 任何一次等待都不超过 maxRetryDelay——超过 1.5 秒就可能正好错过回流库存。 +func (s *runState) retryDelay(uid int64, index int, cfg Config, category string) time.Duration { + base := cfg.ErrorBackoff + if category == CategoryTooFrequent { + base = cfg.TooFrequentBackoff + } + if base <= 0 { + base = defaultRetryBackoff + } + streak := s.bumpFailStreak(uid, index, category) + delay := time.Duration(stageMultiplier(s.elapsed(), category)) * base + if mult := streakMultiplier(streak); mult > 1 { + delay *= time.Duration(mult) + } + if delay > maxRetryDelay { + delay = maxRetryDelay + } + if factor := s.throttleFactor(); factor > 1 { + delay = time.Duration(float64(delay) * factor) + } + return jitterDuration(delay) +} + +// elapsed 返回任务开始至今的时长,用于按阶段取退避节奏。 +func (s *runState) elapsed() time.Duration { + if s.startedAt.IsZero() { + return 0 + } + return time.Since(s.startedAt) +} + +// stageMultiplier 按已抢购时长返回退避倍率:开抢头 2 秒最激进,5 秒后放慢到 3 倍。 +func stageMultiplier(elapsed time.Duration, category string) int { + if escalatingCategory(category) { + switch { + case elapsed < 2*time.Second: + return 1 + case elapsed < 5*time.Second: + return 2 + default: + return 3 + } + } + // 售罄 / 未开放这类「正常等待」也随窗口放慢,但基数保持 1:库存回流仍要探到。 + if elapsed >= 5*time.Second { + return 2 + } + return 1 +} + +// escalatingCategory 返回该分类是否参与「连续失败翻倍」的统计。 +func escalatingCategory(category string) bool { + switch category { + case CategoryFailed, CategoryTooFrequent, CategoryError, CategoryProtocol, CategorySessionLost, CategoryCondition: + return true + default: + return false + } +} + +// streakMultiplier 把连续失败次数换算成翻倍系数:第 3 次起 ×2,第 4 次起 ×4。 +// +// 刻意从第 3 次才翻倍:前两次保持配置里的最快节奏,抢购前期不该因为一次失败就变慢。 +func streakMultiplier(streak int) int { + if streak <= 2 { + return 1 + } + mult := 1 + for i := 2; i < streak && mult < 4; i++ { + mult <<= 1 + } + return mult +} + +// bumpFailStreak 累加账号的连续失败次数;非失败类结局会把它清零。 +func (s *runState) bumpFailStreak(uid int64, index int, category string) int { + s.mu.Lock() + defer s.mu.Unlock() + items := s.accounts[uid] + if index < 0 || index >= len(items) { + return 1 + } + if !escalatingCategory(category) { + items[index].FailStreak = 0 + s.accounts[uid] = items + return 1 + } + items[index].FailStreak++ + streak := items[index].FailStreak + s.accounts[uid] = items + return streak +} + +// bumpSessionStreak 累加账号的连续会话失效次数。 +func (s *runState) bumpSessionStreak(uid int64, index int) int { + s.mu.Lock() + defer s.mu.Unlock() + items := s.accounts[uid] + if index < 0 || index >= len(items) { + return 0 + } + items[index].SessionStreak++ + streak := items[index].SessionStreak + s.accounts[uid] = items + return streak +} + +// observeThrottle 把结局交给全局降速器。会话失效等其它结局会重置「无进展」计数。 +func (s *runState) observeThrottle(category string) { + if s.governor == nil { + return + } + if category != CategoryTooFrequent { + s.clearSessionStreak(category) + } + s.governor.observe(category) +} + +// clearSessionStreak 在拿到任何非会话失效结局后清空连续失效计数。 +func (s *runState) clearSessionStreak(category string) { + if category == CategorySessionLost { + return + } + s.mu.Lock() + defer s.mu.Unlock() + for uid, items := range s.accounts { + changed := false + for index := range items { + if items[index].SessionStreak != 0 { + items[index].SessionStreak = 0 + changed = true + } + } + if changed { + s.accounts[uid] = items + } + } +} + +// throttleFactor 返回当前的自适应降速倍率(未启用时恒为 1)。 +func (s *runState) throttleFactor() float64 { + if s.governor == nil { + return 1 + } + return s.governor.current() +} + +// throttleGovernor 是全局自适应降速器。 +// +// 触发信号刻意选「无进展」而不是「限流占比」:2026-10-16 实测,即使成功率 97.6% 的 pc-3 +// 也有 67% 的请求被限流,用占比阈值会把正常运行的机器一起拖慢。真正危险的是 +// 「连着十几个请求、连一个业务响应都拿不到」——说明整机已经压在服务端令牌桶上 +// (pc-1 峰值 122 req/s、82% 被限流)。命中后每 800ms 按 1.5 倍逐级降速(上限 4 倍), +// 一旦恢复进展就立刻回退,避免错过库存回流。 +type throttleGovernor struct { + mu sync.Mutex + factor float64 + maxSeen float64 + stall int + lastProgress time.Time + lastEscalate time.Time + // 阈值做成字段是为了让单测能把时间窗口压到毫秒级。 + minRequests int + stallWindow time.Duration + maxFactor float64 +} + +func newThrottleGovernor() *throttleGovernor { + now := time.Now() + return &throttleGovernor{ + factor: 1, + maxSeen: 1, + minRequests: governorMinRequests, + stallWindow: governorStallWindow, + maxFactor: governorMaxFactor, + lastProgress: now, + lastEscalate: now, + } +} + +func (g *throttleGovernor) observe(category string) { + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now() + if category != CategoryTooFrequent { + // 只要服务端确实处理了请求(哪怕是售罄/未开放/协议错误),就算有进展。 + g.stall = 0 + g.lastProgress = now + if g.factor > 1 { + g.factor /= governorStep + if g.factor < 1 { + g.factor = 1 + } + } + return + } + g.stall++ + if g.stall < g.minRequests || now.Sub(g.lastProgress) < g.stallWindow { + return + } + if now.Sub(g.lastEscalate) < g.stallWindow { + return + } + g.lastEscalate = now + g.factor *= governorStep + if g.factor > g.maxFactor { + g.factor = g.maxFactor + } + if g.factor > g.maxSeen { + g.maxSeen = g.factor + } +} + +func (g *throttleGovernor) current() float64 { + g.mu.Lock() + defer g.mu.Unlock() + return g.factor +} + +// peak 返回本次任务用过的最大降速倍率,供汇总日志展示。 +func (g *throttleGovernor) peak() float64 { + g.mu.Lock() + defer g.mu.Unlock() + return g.maxSeen +} + func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient, account Account, target Target, index int, loop int, cfg Config, result Result, emit func(Result)) { if !cfg.RefreshAfterSuccess || emit == nil || result.Category != CategorySuccess { return @@ -539,8 +1108,10 @@ func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient return nil, ctx.Err() } defer state.release() - state.countRequest(reqScore) - return client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID) + 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, @@ -566,8 +1137,10 @@ func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient return nil, ctx.Err() } defer state.release() - state.countRequest(reqRecord) - return client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID) + 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, @@ -588,32 +1161,25 @@ func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient }() } -func (r *Runner) sharedList(ctx context.Context, client huya.ActivityClient, accounts []Account, targets []Target, cfg Config, state *runState) *listSnapshot { - if !cfg.SharePrizeList || len(accounts) == 0 { - return nil - } - needsList := false - for _, account := range accounts { - for _, index := range state.activeTargets(account.UID, len(targets), time.Now()) { - if !shouldDirectExchange(targets[index], cfg) { - needsList = true - break - } - } - if needsList { - break +// 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()} } + 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 !needsList { - return nil + if !cfg.SharePrizeList { + return fetch() } - if !state.acquire(ctx) { - return &listSnapshot{err: ctx.Err()} - } - state.countRequest(reqList) - resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, cfg.SID) - state.release() - return &listSnapshot{resp: resp, err: err} + return state.sharedListOnce(ctx, fetch) } func (r *Runner) client() huya.ActivityClient { @@ -623,26 +1189,241 @@ func (r *Runner) client() huya.ActivityClient { return huya.NewHTTPClient(nil) } -func newRunState(accounts []Account, targetCount, concurrency int) *runState { +func newRunState(accounts []Account, targetCount, concurrency int, cfg Config) *runState { if concurrency <= 0 { concurrency = 1 } state := &runState{ - accounts: make(map[int64][]targetState, len(accounts)), - targetDone: make([]bool, targetCount), - exchanged: make(map[int64]map[int64]bool, len(accounts)), - sem: make(chan struct{}, concurrency), + startedAt: time.Now(), + accounts: make(map[int64][]targetState, len(accounts)), + targetDone: make([]bool, targetCount), + exchanged: make(map[int64]map[int64]bool, len(accounts)), + duplicateNotified: make(map[int64]map[int64]bool, len(accounts)), + firstWaveSent: make(map[int64]bool, len(accounts)), + sem: make(chan struct{}, concurrency), stats: runStats{ outcomes: map[string]int{}, successUIDs: map[int64]bool{}, + requests: map[requestKind]*requestStat{}, + accounts: map[int64]*AccountOutcome{}, }, } for _, account := range accounts { state.accounts[account.UID] = make([]targetState, targetCount) } + state.configureWindow(cfg, len(accounts)) return state } +// configureWindow 按配置初始化窗口与首发错峰参数。 +// +// 只有 OpenAt 非零才是窗口模式:测试与直接调用 RunTargets 的老用法(只给 Loops) +// 保持原来的轮次语义,不会被 30 秒窗口拖住。 +func (s *runState) configureWindow(cfg Config, accountCount int) { + if cfg.OpenAt.IsZero() { + return + } + window := time.Duration(cfg.WindowSeconds) * time.Second + if cfg.WindowSeconds <= 0 { + window = DefaultWindowSeconds * time.Second + } + if cfg.windowOverride > 0 { + window = cfg.windowOverride + } + startAhead := cfg.StartAhead + if startAhead <= 0 { + startAhead = DefaultStartAhead + } + s.openAt = cfg.OpenAt + s.startAhead = startAhead + s.windowLen = window + s.deadline = cfg.OpenAt.Add(window) + + if cfg.FirstWaveSpread > 0 { + step := cfg.FirstWaveSpread / time.Duration(maxInt(accountCount-1, 1)) + maxStep := cfg.FirstWaveMaxStep + if maxStep <= 0 { + maxStep = DefaultFirstWaveMaxStep + } + if step > maxStep { + step = maxStep + } + if step < 0 { + step = 0 + } + s.firstWaveSpread = cfg.FirstWaveSpread + s.firstWaveStep = step + } + if !cfg.DisableAdaptiveThrottle { + s.governor = newThrottleGovernor() + } +} + +// windowed 返回是否处于窗口模式。 +func (s *runState) windowed() bool { + return !s.deadline.IsZero() +} + +// windowExpired 返回窗口是否已经结束(非窗口模式永远为 false)。 +func (s *runState) windowExpired() bool { + if s.deadline.IsZero() { + return false + } + return !time.Now().Before(s.deadline) +} + +// windowStart 返回窗口循环应当开始发请求的时刻(开抢时刻减去先手缓冲)。 +func (s *runState) windowStart() time.Time { + return s.openAt.Add(-s.startAhead) +} + +// needsPrefire 返回是否还欠一轮「开抢前预打」。 +// +// 只有距离窗口起点还有一段时间才值得单独预打一轮:正常情况下触发时刻(开抢 − advance) +// 与窗口起点(开抢 − StartAhead)几乎重合,此时再预打一轮就等于把首发齐射打两遍。 +func (s *runState) needsPrefire() bool { + if s.openAt.IsZero() || s.prefireDone { + return false + } + return time.Now().Add(prefireMinGap).Before(s.windowStart()) +} + +func (s *runState) markPrefire() { + s.prefireDone = true +} + +// clipToDeadline 把等待时间裁剪到窗口结束,保证不会睡过窗口。 +func (s *runState) clipToDeadline(delay time.Duration) time.Duration { + if s.deadline.IsZero() { + return delay + } + remaining := time.Until(s.deadline) + if remaining <= 0 { + return 0 + } + if delay > remaining { + return remaining + } + return delay +} + +// noteRound 记录所有账号里跑过的最大轮次(用于汇总日志)。 +func (s *runState) noteRound(round int) { + s.mu.Lock() + defer s.mu.Unlock() + if round > s.rounds { + s.rounds = round + } +} + +// maxRound 返回所有账号里跑过的最大轮次。 +func (s *runState) maxRound() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.rounds +} + +// accountFinished 返回该账号是否已经没有可尝试的目标(全部成功或进入终态)。 +func (s *runState) accountFinished(uid int64, targetCount int) bool { + s.mu.Lock() + defer s.mu.Unlock() + items := s.accounts[uid] + for index := 0; index < targetCount; index++ { + if index < len(s.targetDone) && s.targetDone[index] { + continue + } + if index < len(items) && !items[index].Done { + return false + } + } + return true +} + +// sharedListOnce 保证同一时刻只有一个账号去查询奖品列表,其余账号等它完成后复用结果。 +// +// 只缓存成功的快照:失败结果不缓存,否则一次抖动会让所有账号一起失败。 +func (s *runState) sharedListOnce(ctx context.Context, fetch func() *listSnapshot) *listSnapshot { + s.listMu.Lock() + if s.listSnapshot != nil && time.Since(s.listCachedAt) <= sharedListTTL { + snapshot := s.listSnapshot + s.listMu.Unlock() + return snapshot + } + if inflight := s.listInflight; inflight != nil { + s.listMu.Unlock() + select { + case <-inflight: + case <-ctx.Done(): + return &listSnapshot{err: ctx.Err()} + } + s.listMu.Lock() + snapshot := s.listSnapshot + s.listMu.Unlock() + if snapshot != nil { + return snapshot + } + return &listSnapshot{err: errors.New("奖品列表响应为空")} + } + inflight := make(chan struct{}) + s.listInflight = inflight + s.listMu.Unlock() + + snapshot := fetch() + + s.listMu.Lock() + if snapshot != nil && snapshot.err == nil && snapshot.resp != nil { + s.listSnapshot = snapshot + s.listCachedAt = time.Now() + } + s.listInflight = nil + s.listMu.Unlock() + close(inflight) + return snapshot +} + +// takeFirstWaveDelay 领取本账号的首发错峰等待时间,每个账号只生效一次。 +func (s *runState) takeFirstWaveDelay(uid int64) time.Duration { + if s.firstWaveStep <= 0 { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + if s.firstWaveSent[uid] { + return 0 + } + s.firstWaveSent[uid] = true + slot := s.firstWaveUsed + s.firstWaveUsed++ + return time.Duration(slot) * s.firstWaveStep +} + +// noteOutcome 记录一个账号最近一次兑换类结果,供任务结束时的逐账号结局输出。 +func (s *runState) noteOutcome(result Result) { + switch result.Action { + case "exchange", "exchange_error", "skip_duplicate", "not_found": + default: + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.stats.accounts == nil { + s.stats.accounts = map[int64]*AccountOutcome{} + } + outcome := s.stats.accounts[result.UID] + if outcome == nil { + outcome = &AccountOutcome{UID: result.UID} + s.stats.accounts[result.UID] = outcome + } + if result.Action != "skip_duplicate" { + outcome.Attempts++ + } + outcome.Category = result.Category + outcome.Message = result.Message + if result.Category == CategorySuccess || result.Done { + outcome.Done = true + } +} + // requestKind 标识一次在途请求的类型,用于汇总日志里的请求次数。 type requestKind int @@ -653,6 +1434,22 @@ const ( 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 { @@ -673,7 +1470,11 @@ func (s *runState) release() { <-s.sem } -func (s *runState) countRequest(kind requestKind) { +// recordRequest 记录一次请求的次数、耗时与错误。 +// +// 除了原来的分类计数,还累计耗时/失败/最后错误,任务结束时输出「接口统计」行, +// 这样复盘时能一眼看出是「请求打不出去(慢/超时)」还是「打出去被拒(限流)」。 +func (s *runState) recordRequest(kind requestKind, cost time.Duration, err error) { s.mu.Lock() defer s.mu.Unlock() switch kind { @@ -686,6 +1487,15 @@ func (s *runState) countRequest(kind requestKind) { 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 累计一条结局分类,供任务结束时的汇总日志使用。 @@ -720,6 +1530,8 @@ func (s *runState) snapshotStats() runStats { 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 @@ -727,6 +1539,20 @@ func (s *runState) snapshotStats() runStats { 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 } @@ -736,7 +1562,7 @@ func (r *Runner) emitSummary(state *runState, accounts, targets, loops int, cost return } stats := state.snapshotStats() - r.OnSummary(RunSummary{ + summary := RunSummary{ Loops: loops, Accounts: accounts, Targets: targets, @@ -747,7 +1573,37 @@ func (r *Runner) emitSummary(state *runState, accounts, targets, loops int, cost RecordReqs: stats.recordReqs, SuccessUIDs: len(stats.successUIDs), Outcomes: stats.outcomes, - }) + } + if state.windowed() { + summary.OpenAt = state.openAt + summary.WindowSeconds = int(state.windowLen / time.Second) + } + if state.governor != nil { + summary.ThrottleFactor = state.governor.peak() + } + for _, kind := range []requestKind{reqList, reqExchange, reqScore, reqRecord} { + stat := stats.requests[kind] + if stat == nil || stat.count == 0 { + continue + } + summary.RequestStats = append(summary.RequestStats, RequestStat{ + Kind: kind.String(), + Count: stat.count, + Failed: stat.failed, + Total: stat.total, + Slowest: stat.slowest, + LastErr: stat.lastErr, + }) + } + uids := make([]int64, 0, len(stats.accounts)) + for uid := range stats.accounts { + uids = append(uids, uid) + } + sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] }) + for _, uid := range uids { + summary.AccountOutcomes = append(summary.AccountOutcomes, *stats.accounts[uid]) + } + r.OnSummary(summary) } // prizeExchanged 判断该账号是否已经成功兑换过这个奖品。 @@ -837,9 +1693,11 @@ func (s *runState) allDone() bool { func normalizeConfig(cfg Config) Config { cfg.Category = strings.TrimSpace(cfg.Category) - if cfg.Loops <= 0 { + if cfg.Loops <= 0 && cfg.OpenAt.IsZero() { + // 非窗口模式必须有个有限轮次,否则会变成死循环。 cfg.Loops = 1 } + // 窗口模式下 Loops<=0 表示「一直打到窗口结束」,由 deadline 兜底,这里不做限制。 if cfg.Delay <= 0 { cfg.Delay = 150 * time.Millisecond } @@ -847,17 +1705,21 @@ func normalizeConfig(cfg Config) Config { cfg.Concurrency = 1 } if cfg.TooFrequentBackoff <= 0 { - cfg.TooFrequentBackoff = maxDuration(cfg.Delay*2, 300*time.Millisecond) + cfg.TooFrequentBackoff = maxDuration(cfg.Delay*2, defaultRetryBackoff) } if cfg.ErrorBackoff <= 0 { - cfg.ErrorBackoff = maxDuration(cfg.Delay, 300*time.Millisecond) + 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。 + // 窗口结束;需要「售罄即停」时显式设置 StopOnSoldOut。 if !cfg.ListBeforeExchange { cfg.DirectExchange = true } @@ -993,26 +1855,38 @@ func buildExchangeSignals(resp *huya.ScoreExchangePrizeResp) exchangeSignals { } // classifyExchangeText 只依据文案判定失败原因,命中时返回分类。 +// +// 判定顺序固定:未开放 → 售罄 → 货币/次数 → 会话失效 → 下架 → 限流。 +// 顺序很关键:限流关键词里有「稍后重试」「稍后」,如果排在前面会把更具体的原因吃掉。 +// 2026-10-16 的日志里 244 条「领取频率限制,请稍后重试!!」和 917 条「活动太火爆了, +// 请稍后重试」过去全落到 failed:既看不出是被限流,也享受不到限流该有的退避语义。 func classifyExchangeText(text string) (string, bool) { switch { - case containsAny(text, "操作频繁", "太频繁", "稍后再试"): - return CategoryTooFrequent, true case containsAny(text, "暂未开放", "未开放", "未开始", "活动未开始"): return CategoryNotOpen, true - case containsAny(text, "已抢完", "已兑完", "库存不足", "没有库存", "领完了"): + 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 { @@ -1099,6 +1973,13 @@ func maxDuration(a, b time.Duration) time.Duration { return b } +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + // jitterDuration 在基准退避上叠加 0-25% 的随机抖动。 // // 多个账号同时失败时,固定退避会让它们在同一个时刻整批重试,形成周期性尖峰, diff --git a/internal/sniper/timing.go b/internal/sniper/timing.go new file mode 100644 index 0000000..510abb5 --- /dev/null +++ b/internal/sniper/timing.go @@ -0,0 +1,79 @@ +package sniper + +import ( + "time" + + "huya-go/internal/huya" +) + +// MinTriggerAdvance 是触发提前量的下限。 +// +// 动态提前量只补偿「请求在途时间 + 时钟误差」,正常情况下就是几十毫秒; +// 下限 50ms 是为了在时钟校准异常(误差边界为 0)时仍然留一点余量。 +const MinTriggerAdvance = 50 * time.Millisecond + +const ( + // WarmupLead 是预热连接相对开抢时刻的提前量。 + // + // 2 秒足够建连 + 握手(实测单账号建连约 300ms),又不会让连接闲置太久: + // 旧实现一启动就预热、然后干等几十分钟,连接是否还活着只能靠运气。 + WarmupLead = 2 * time.Second + // ResyncLead 是「临近复校」相对开抢时刻的提前量。 + // + // 只做一次校准是不够的:pc-1/pc-2/pc-3 三台机器的本机偏移分别是 ≈0 / -370ms / +900ms, + // 而且本机时钟在等待期间还会漂移。触发前 3 秒再校准一次,能把这部分误差压掉。 + ResyncLead = 3 * time.Second +) + +// TriggerPlan 描述一次定时抢购的关键时刻(全部是「本机时钟」下的时刻)。 +type TriggerPlan struct { + // Advance 是相对开抢时刻的提前量(含 RTT/2 与时钟误差补偿)。 + Advance time.Duration + // OpenAt 是开抢时刻换算到本机时钟后的时刻。 + OpenAt time.Time + // WarmupAt 是应当开始预热连接的时刻。 + WarmupAt time.Time + // ResyncAt 是应当复校服务端时钟的时刻。 + ResyncAt time.Time + // TriggerAt 是应当开始发请求的时刻。 + TriggerAt time.Time + // WindowEnd 是抢购窗口结束时刻。 + WindowEnd time.Time +} + +// PlanTrigger 计算定时抢购的预热 / 复校 / 触发 / 窗口结束时刻。 +// +// target 是界面上填写的时间,按「服务端时间」理解;offset 是「服务端 - 本机」的偏差。 +// advance 默认取 calibration.ErrorBound + calibration.RTT/2(参考 dygou_go 的 +// dynamicAdvance):固定 2500ms 的提前量既浪费又伤身——2026-10-16 实测,开抢前那 2.5 秒 +// 打出去的请求全部被「活动太火爆」挡回,等于先把账号的令牌桶烧掉。 +// overrideLead > 0 时沿用旧行为(兼容界面里显式填写的提前量)。 +func PlanTrigger(target time.Time, calibration huya.TimeCalibration, overrideLead time.Duration, window time.Duration) TriggerPlan { + advance := overrideLead + if advance <= 0 { + advance = calibration.ErrorBound + calibration.RTT/2 + if advance < MinTriggerAdvance { + advance = MinTriggerAdvance + } + } + if window <= 0 { + window = DefaultWindowSeconds * time.Second + } + openAt := target.Add(-calibration.Offset) + return TriggerPlan{ + Advance: advance, + OpenAt: openAt, + WarmupAt: openAt.Add(-WarmupLead), + ResyncAt: openAt.Add(-ResyncLead), + TriggerAt: openAt.Add(-advance), + WindowEnd: openAt.Add(window), + } +} + +// WindowDuration 把「窗口秒数」配置换算成时长;<=0 时取默认值。 +func WindowDuration(seconds int) time.Duration { + if seconds <= 0 { + return DefaultWindowSeconds * time.Second + } + return time.Duration(seconds) * time.Second +} diff --git a/internal/version/version.go b/internal/version/version.go index 8921562..2506815 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -5,10 +5,10 @@ package version // // 构建时可用 ldflags 覆盖(构建脚本就是这么做的): // -// go build -ldflags "-X huya-go/internal/version.Version=0.3.0" -var Version = "0.3.0" +// go build -ldflags "-X huya-go/internal/version.Version=0.4.0" +var Version = "0.4.0" -// Display 返回带 v 前缀的展示版本号,例如 v0.3.0。 +// Display 返回带 v 前缀的展示版本号,例如 v0.4.0。 func Display() string { return "v" + Version } diff --git a/internal/wup/wup.go b/internal/wup/wup.go index 71d6f7d..eff7bc9 100644 --- a/internal/wup/wup.go +++ b/internal/wup/wup.go @@ -3,9 +3,12 @@ package wup import ( "bytes" "encoding/binary" + "encoding/hex" "errors" "fmt" "io" + "sort" + "strings" "huya-go/internal/taf" ) @@ -261,3 +264,88 @@ func NormalizePayload(data []byte) []byte { } return data } + +// 诊断串里的取值上限:服务端错误信息可能很长,日志里只需要够判断原因的前缀。 +const ( + diagnoseValueLimit = 80 + diagnoseFieldLimit = 6 +) + +// Diagnose 汇总一次 WUP 响应里可用于排障的字段。 +// +// 「响应缺少 tRsp/tResp」这种报错过去只有一句文案,服务端的 iRet / status / +// context 全被丢掉,事后只能靠抓包复现(2026-10-16 pc-3 日志里同一账号连续 13 次 +// 命中该错误,却看不出任何原因)。这里把 tag3(iRet)、tag9(context)、tag10(status) +// 与 tResp 数据体的 key 一并带出来,一次日志就能定性。 +func (r *Response) Diagnose() string { + if r == nil { + return "响应为空" + } + parts := []string{fmt.Sprintf("iRet=%d", r.Message)} + if r.Servant != "" || r.Func != "" { + parts = append(parts, fmt.Sprintf("servant=%s.%s", r.Servant, r.Func)) + } + if text := formatDiagnoseMap(r.Status); text != "" { + parts = append(parts, "status="+text) + } + if text := formatDiagnoseMap(r.Context); text != "" { + parts = append(parts, "context="+text) + } + if len(r.NewData) == 0 { + parts = append(parts, "keys=空") + } else { + parts = append(parts, "keys="+formatDiagnoseKeys(r.NewData)) + } + parts = append(parts, fmt.Sprintf("payload=%dB", len(r.Buffer))) + return strings.Join(parts, " ") +} + +// formatDiagnoseMap 按键名排序输出 map,值过长时截断,保证日志稳定且不刷屏。 +func formatDiagnoseMap(values map[string]string) string { + if len(values) == 0 { + return "" + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for index, key := range keys { + if index >= diagnoseFieldLimit { + parts = append(parts, fmt.Sprintf("…(共%d项)", len(keys))) + break + } + parts = append(parts, fmt.Sprintf("%s=%s", key, truncateDiagnoseValue(values[key]))) + } + return "{" + strings.Join(parts, " ") + "}" +} + +// formatDiagnoseKeys 输出 tResp 数据体的键名(值可能是任意结构,只列键)。 +func formatDiagnoseKeys(values map[string][]byte) string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, fmt.Sprintf("%s(%dB)", key, len(values[key]))) + } + sort.Strings(keys) + return "{" + strings.Join(keys, " ") + "}" +} + +func truncateDiagnoseValue(value string) string { + trimmed := strings.TrimSpace(value) + if len(trimmed) <= diagnoseValueLimit { + return trimmed + } + return trimmed[:diagnoseValueLimit] + "…" +} + +// HexHead 把二进制帧的前 n 字节转成十六进制,用于 debug 级日志定位协议问题。 +func HexHead(data []byte, n int) string { + if n <= 0 || len(data) == 0 { + return "" + } + if n > len(data) { + n = len(data) + } + return hex.EncodeToString(data[:n]) +} diff --git a/internal/wup/wup_test.go b/internal/wup/wup_test.go index b6522e0..25a085f 100644 --- a/internal/wup/wup_test.go +++ b/internal/wup/wup_test.go @@ -1,6 +1,7 @@ package wup_test import ( + "strings" "testing" "huya-go/internal/huya" @@ -31,3 +32,56 @@ func TestWupEncodeDecodeHeader(t *testing.T) { t.Fatal("missing tReq") } } + +// TestResponseDiagnoseIncludesServerFields 验证诊断串会把 WUP 响应里的排障字段带出来。 +// +// 2026-10-16 的复盘卡在「响应缺少 tRsp/tResp」只有一句文案:服务端回的 iRet、 +// status、context 与数据体键名全被丢掉,同一账号连续 13 次命中也无法定性。 +func TestResponseDiagnoseIncludesServerFields(t *testing.T) { + resp := &wup.Response{ + Message: -1, + Servant: "webActUI", + Func: "scoreExchangePrize", + Status: map[string]string{"STATUS_RESULT_DESC": "风控拦截", "STATUS_RESULT_CODE": "-1"}, + Context: map[string]string{"uid": "123"}, + NewData: map[string][]byte{"tErr": {0x0c}}, + Buffer: []byte{1, 2, 3}, + } + got := resp.Diagnose() + for _, want := range []string{ + "iRet=-1", + "servant=webActUI.scoreExchangePrize", + "STATUS_RESULT_CODE=-1", + "STATUS_RESULT_DESC=风控拦截", + "context={uid=123}", + "keys={tErr(1B)}", + "payload=3B", + } { + if !strings.Contains(got, want) { + t.Fatalf("Diagnose()=%q,缺少 %q", got, want) + } + } + if got := (*wup.Response)(nil).Diagnose(); got != "响应为空" { + t.Fatalf("空响应诊断=%q", got) + } + if got := (&wup.Response{}).Diagnose(); !strings.Contains(got, "iRet=0") || !strings.Contains(got, "keys=空") { + t.Fatalf("零值响应诊断=%q", got) + } +} + +// TestHexHead 验证原始帧前缀输出的截断与边界。 +func TestHexHead(t *testing.T) { + data := []byte{0x1f, 0x8b, 0x08, 0x00} + if got := wup.HexHead(data, 2); got != "1f8b" { + t.Fatalf("HexHead(2)=%q", got) + } + if got := wup.HexHead(data, 32); got != "1f8b0800" { + t.Fatalf("HexHead 越界应截断到实际长度: %q", got) + } + if got := wup.HexHead(nil, 8); got != "" { + t.Fatalf("空数据=%q", got) + } + if got := wup.HexHead(data, 0); got != "" { + t.Fatalf("长度为 0=%q", got) + } +} diff --git a/wails.json b/wails.json index 93195d4..a325964 100644 --- a/wails.json +++ b/wails.json @@ -9,7 +9,7 @@ "info": { "companyName": "huya-go", "productName": "虎牙抢购助手", - "productVersion": "0.3.0", + "productVersion": "0.4.0", "copyright": "", "comments": "虎牙活动积分兑换/抢购工具" }