v0.4.0:窗口调度/限流分类/可观测性,抢购改每账号独立循环,移除 CLI 只保留 GUI
This commit is contained in:
+150
-31
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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("普通错误不该被判定为协议错误")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+970
-89
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user