v0.4.0:窗口调度/限流分类/可观测性,抢购改每账号独立循环,移除 CLI 只保留 GUI

This commit is contained in:
yml2213
2026-10-16 18:14:30 +08:00
parent 29aed15e4e
commit 8b3b14654f
20 changed files with 2309 additions and 664 deletions
+150 -31
View File
@@ -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
}