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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user