package gui import ( "context" "errors" "fmt" "strconv" "strings" "sync" "time" "github.com/wailsapp/wails/v2/pkg/runtime" "huya-go/internal/huya" "huya-go/internal/instance" "huya-go/internal/logger" "huya-go/internal/sniper" "huya-go/internal/version" ) const ( eventLog = "gui:log" eventMonitorResult = "gui:monitor-result" eventMonitorState = "gui:monitor-state" // maxLogEntries 内存里保留的日志条数,界面按自增 ID 增量拉取。 maxLogEntries = 2000 // 日志分类,便于在界面上区分应用、网络、监控与账号维度的日志。 categoryApp = "应用" categoryNet = "网络" categoryMonitor = "监控" ) type App struct { ctx context.Context // logStore 是内存日志缓冲;界面通过 GetLogs(sinceID) 增量拉取。 logStore *logger.Store // closeLog 由 logger.Init 返回,用于在退出时把异步队列刷盘。 closeLog func() logInitErr error // instanceLease 在 wails.Run 之前由 main 占用并注入(WebView2 目录需要提前确定)。 instanceLease *instance.Lease instanceErr error instanceName string instanceID string dataDir string runMu sync.Mutex monitorCancel context.CancelFunc monitorRunning bool } type Config struct { Transport string `json:"transport"` WSSHost string `json:"wssHost"` SID int32 `json:"sid"` PID int32 `json:"pid"` // Category 是类目(限量返场/精英专享,或类目 id):只影响监控时的自动选品范围。 Category string `json:"category"` Name string `json:"name"` TargetText string `json:"targetText"` MaxPrice int64 `json:"maxPrice"` MinLeft int64 `json:"minLeft"` CookieText string `json:"cookieText"` SelectedAccountLines []int `json:"selectedAccountLines"` DelayMS int `json:"delayMs"` Concurrency int `json:"concurrency"` StopAfterSuccess bool `json:"stopAfterSuccess"` StopAccountAfterSuccess bool `json:"stopAccountAfterSuccess"` Prewarm bool `json:"prewarm"` DirectExchange bool `json:"directExchange"` SharePrizeList bool `json:"sharePrizeList"` AutoSelect bool `json:"autoSelect"` RefreshAfterSuccess bool `json:"refreshAfterSuccess"` LeadMS int `json:"leadMs"` // TimeSync 表示定时抢购前先用 pingSignal 校准服务端时间,并把 StartAt 当服务端时间对齐。 TimeSync bool `json:"timeSync"` FrequentBackoffMS int `json:"frequentBackoffMs"` ErrorBackoffMS int `json:"errorBackoffMs"` TimeoutSec int `json:"timeoutSec"` StartAt string `json:"startAt"` // WindowSeconds 是抢购窗口长度(秒):跑到「开抢 + 窗口」才收工。 WindowSeconds int `json:"windowSeconds"` } type AccountPreview struct { Line int `json:"line"` UID int64 `json:"uid"` Valid bool `json:"valid"` Error string `json:"error,omitempty"` } // LogStats 描述当前日志运行时状态,供界面展示与排查。 type LogStats struct { Level string `json:"level"` FilePath string `json:"filePath"` Dir string `json:"dir"` Dropped uint64 `json:"dropped"` Count int `json:"count"` } // InstanceInfo 描述当前进程占用的实例,供界面展示与本地存储分片。 type InstanceInfo struct { Name string `json:"name"` ID string `json:"id"` DataDir string `json:"dataDir"` LogDir string `json:"logDir"` LogFile string `json:"logFile"` } type PrizeListResult struct { UID int64 `json:"uid"` Status int32 `json:"status"` Msg string `json:"msg"` Prizes []huya.PrizeInfo `json:"prizes"` // Categories 是服务端下发的类目字典(限量返场/精英专享),CategoryStats 是对应的商品数统计。 Categories []huya.PrizeTag `json:"categories"` CategoryStats []huya.CategoryStat `json:"categoryStats"` Raw *huya.GetActPrizeListResp `json:"raw,omitempty"` } type ScoreResult struct { UID int64 `json:"uid"` Score int64 `json:"score"` Resp *huya.GetUserScoreResp `json:"resp,omitempty"` Error string `json:"error,omitempty"` } type RecordResult struct { UID int64 `json:"uid"` Records []huya.UserPrizeInfo `json:"records"` Resp *huya.GetUserPrizeRecordsResp `json:"resp,omitempty"` Error string `json:"error,omitempty"` } type ExchangeResult struct { UID int64 `json:"uid"` Resp *huya.ScoreExchangePrizeResp `json:"resp,omitempty"` Category string `json:"category,omitempty"` Message string `json:"message,omitempty"` Error string `json:"error,omitempty"` } type MonitorState struct { Running bool `json:"running"` Message string `json:"message"` } type cookieLine struct { lineNo int text string } // NewApp 创建 App 实例。lease 由 main 在 wails.Run 之前占用并注入。 // // 为什么要在 wails.Run 之前占位:WebView2 的用户数据目录必须在创建窗口之前确定, // 而它必须按实例区分(见 instance.Lease.WebviewUserDataDir)。 // 用构造器参数而不是导出的 setter,是为了避免它被 Wails 当成前端可调用方法生成绑定。 func NewApp(lease *instance.Lease, instanceErr error) *App { return &App{instanceLease: lease, instanceErr: instanceErr} } func (a *App) Startup(ctx context.Context) { a.ctx = ctx lease, leaseErr := a.instanceLease, a.instanceErr if lease == nil { // 其它入口(测试、非 Wails 启动)没有提前占位时在这里补一次。 lease, leaseErr = instance.Acquire() } logDir := "" if lease != nil { a.instanceLease = lease a.instanceName = lease.Name() a.instanceID = lease.ID() a.dataDir = lease.Paths().DataDir logDir = lease.Paths().LogDir } if a.instanceName == "" { a.instanceName = "默认实例" } if a.instanceID == "" { a.instanceID = "default" } a.logStore = logger.NewStore(maxLogEntries) // 日志先注册界面推送,再初始化文件输出,这样启动阶段的日志也能实时到达界面。 logger.SetEmitter(func(entry logger.Entry) { a.emit(eventLog, entry) }) closeLog, logErr := logger.Init(a.logStore, logDir, a.instanceName, a.instanceID, a.dataDir) a.closeLog = closeLog a.logInitErr = logErr if leaseErr != nil { // 数据目录仍然是本实例独享的(临时实例),但隔离能力已降级,必须让人看见。 logger.LogEvent("系统", logger.LevelWarn, "实例隔离已降级(%s,数据目录仍独立): %v", a.instanceName, leaseErr) } else { logger.LogEvent("系统", logger.LevelSuccess, "已启动%s,数据目录: %s", a.instanceName, a.dataDir) } if logErr != nil { logger.LogEvent(categoryApp, logger.LevelWarn, "日志文件不可用,日志仅保留在界面: %v", logErr) } } // GetLogFilePath 返回当前日志文件路径(供界面展示/复制)。 func (a *App) GetLogFilePath() string { return logger.FilePath() } // GetLogs 返回自增 ID 大于 sinceID 的日志,界面据此增量拉取。 func (a *App) GetLogs(sinceID int64) []logger.Entry { if a.logStore == nil { return []logger.Entry{} } return a.logStore.Since(sinceID) } // GetLogStats 返回日志运行时状态(等级、文件、丢弃条数)。 func (a *App) GetLogStats() LogStats { return LogStats{ Level: logger.MinLevel(), FilePath: logger.FilePath(), Dir: logger.Dir(), Dropped: logger.Dropped(), Count: a.logStoreCount(), } } // SetLogLevel 调整日志最低输出等级,返回归一化后的等级。 func (a *App) SetLogLevel(level string) string { normalized := logger.LevelFromString(level) logger.SetMinLevel(normalized) logger.LogEvent(categoryApp, logger.LevelSuccess, "日志等级已切换为 %s", normalized) return normalized } // GetInstanceInfo 返回当前实例信息(名称、标识、数据与日志路径)。 func (a *App) GetInstanceInfo() InstanceInfo { name := a.instanceName if name == "" { name = "默认实例" } id := a.instanceID if id == "" { id = "default" } info := InstanceInfo{Name: name, ID: id, DataDir: a.dataDir, LogFile: logger.FilePath()} if a.instanceLease != nil { info.LogDir = a.instanceLease.Paths().LogDir } return info } // GetVersion 返回应用版本号(与 exe 文件名、编译期版本号同源)。 func (a *App) GetVersion() string { return version.Display() } // OpenLogDir 用系统文件管理器打开日志目录。 func (a *App) OpenLogDir() error { dir := logger.Dir() if dir == "" { if a.logInitErr != nil { return fmt.Errorf("日志目录不可用: %w", a.logInitErr) } return errors.New("日志目录不可用") } if err := openDirectory(dir); err != nil { return fmt.Errorf("打开日志目录失败: %w", err) } return nil } func (a *App) logStoreCount() int { if a.logStore == nil { return 0 } return a.logStore.Count() } func (a *App) Shutdown(ctx context.Context) { a.StopMonitor() logger.SetEmitter(nil) if a.closeLog != nil { a.closeLog() a.closeLog = nil } if a.instanceLease != nil { a.instanceLease.Release() a.instanceLease = nil } } func (a *App) DefaultConfig() Config { return Config{ Transport: "wss", WSSHost: "", SID: 2203, DelayMS: 120, // 线程是「同时在途请求数」,不是账号槽位;多开时同一台机器的总请求速率 // 约为 线程 × 实例数 / 平均 RTT。2026-10-17 的 24 个任务用了旧默认 5, // 峰值合集约 400~600 请求/秒、84% 被限流,所以默认压到 2,优先换取 // 服务端真正受理请求的概率。账号很多时仍可在界面显式调高。 Concurrency: 2, StopAfterSuccess: false, StopAccountAfterSuccess: true, Prewarm: true, DirectExchange: true, SharePrizeList: true, AutoSelect: false, RefreshAfterSuccess: true, LeadMS: 0, TimeSync: true, FrequentBackoffMS: 300, ErrorBackoffMS: 300, TimeoutSec: 15, } } func (a *App) PreviewAccounts(cookieText string) []AccountPreview { previews := make([]AccountPreview, 0) for _, line := range splitCookieLines(cookieText) { uid := huya.ResolveUID(line.text) item := AccountPreview{Line: line.lineNo, UID: uid, Valid: uid > 0} if uid <= 0 { item.Error = "无法解析 yyuid/udb_uid" } previews = append(previews, item) } return previews } func (a *App) IsRunning() bool { a.runMu.Lock() defer a.runMu.Unlock() return a.monitorRunning } func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) { cfg = normalizeConfig(cfg) accounts, err := parseAccounts(cfg) if err != nil { return PrizeListResult{}, err } if cfg.SID <= 0 { return PrizeListResult{}, errors.New("SID 不能为空") } client, closeClient, err := a.newClient(cfg) if err != nil { return PrizeListResult{}, err } defer closeClient() ctx, cancel := requestContext(cfg.TimeoutSec) defer cancel() resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, cfg.SID) if err != nil { a.addLog("error", "查询奖品失败: %v", err) return PrizeListResult{}, err } left, total, leftKnown, totalKnown := prizeInventorySummary(resp.Prizes) a.addLog("info", "查询商品完成: uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 类目=%s msg=%s", accounts[0].UID, cfg.SID, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.CategorySummary(), resp.Msg) return PrizeListResult{ UID: accounts[0].UID, Status: resp.Status, Msg: resp.Msg, Prizes: resp.Prizes, Categories: resp.Tags, CategoryStats: resp.CategoryStats(), Raw: resp, }, nil } func (a *App) QueryScores(cfg Config) ([]ScoreResult, error) { cfg = normalizeConfig(cfg) accounts, err := parseAccounts(cfg) if err != nil { return nil, err } if cfg.SID <= 0 { return nil, errors.New("SID 不能为空") } client, closeClient, err := a.newClient(cfg) if err != nil { return nil, err } defer closeClient() results := make([]ScoreResult, len(accounts)) a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) { ctx, cancel := requestContext(cfg.TimeoutSec) defer cancel() resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID) item := ScoreResult{UID: account.UID, Resp: resp, Error: errorString(err)} if resp != nil { item.Score = resp.AvailableScore() } results[i] = item }) a.addLog("info", "查询积分完成: accounts=%d", len(accounts)) return results, nil } func (a *App) GetRecords(cfg Config) ([]RecordResult, error) { cfg = normalizeConfig(cfg) accounts, err := parseAccounts(cfg) if err != nil { return nil, err } if cfg.SID <= 0 { return nil, errors.New("SID 不能为空") } client, closeClient, err := a.newClient(cfg) if err != nil { return nil, err } defer closeClient() results := make([]RecordResult, len(accounts)) a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) { ctx, cancel := requestContext(cfg.TimeoutSec) defer cancel() resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID) item := RecordResult{UID: account.UID, Resp: resp, Error: errorString(err)} if resp != nil { item.Records = resp.Records } results[i] = item }) a.addLog("info", "查询兑换记录完成: accounts=%d", len(accounts)) return results, nil } func (a *App) Exchange(cfg Config) ([]ExchangeResult, error) { cfg = normalizeConfig(cfg) accounts, err := parseAccounts(cfg) if err != nil { return nil, err } if cfg.SID <= 0 || cfg.PID <= 0 { return nil, errors.New("SID 和 PID 都不能为空") } client, closeClient, err := a.newClient(cfg) if err != nil { return nil, err } defer closeClient() results := make([]ExchangeResult, len(accounts)) a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) { ctx, cancel := requestContext(cfg.TimeoutSec) defer cancel() resp, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, cfg.SID, cfg.PID) results[i] = ExchangeResult{ UID: account.UID, Resp: resp, Category: sniper.ClassifyExchange(resp, err), Message: sniper.ExchangeMessage(resp), Error: errorString(err), } }) a.addLog("info", "直接兑换完成: sid=%d pid=%d accounts=%d", cfg.SID, cfg.PID, len(accounts)) return results, nil } func (a *App) StartMonitor(cfg Config) error { cfg = normalizeConfig(cfg) accounts, err := parseAccounts(cfg) if err != nil { return err } if cfg.SID <= 0 { return errors.New("SID 不能为空") } targets := parseTargets(cfg) if len(targets) == 0 { return errors.New("PID 和名称至少填写一个") } startAt, err := parseStartTime(cfg.StartAt) if err != nil { return err } a.runMu.Lock() if a.monitorRunning { a.runMu.Unlock() return errors.New("监控已在运行") } ctx, cancel := context.WithCancel(context.Background()) a.monitorCancel = cancel a.monitorRunning = true a.runMu.Unlock() client, closeClient, err := a.newClient(cfg) if err != nil { cancel() a.finishMonitor("启动失败") return err } a.emitState(true, "运行中") a.addLog("info", "启动监控: sid=%d 类目=%s targets=%d 目标=%s accounts=%d prewarm=%v direct=%v 窗口=%s", cfg.SID, sniper.CategoryText(cfg.Category), len(targets), sniper.TargetSummary(targets), len(accounts), cfg.Prewarm, cfg.DirectExchange, sniper.WindowDuration(cfg.WindowSeconds)) go func() { defer closeClient() defer a.finishMonitor("已停止") runner := sniper.Runner{Client: client, OnSummary: a.logRunSummary} emit := func(result sniper.Result) { a.emit(eventMonitorResult, result) a.logAccount(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) } 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 } // 校准最多占用到触发时刻:服务器繁忙时 pingSignal 可能连续超时, // 不能让它把真正的开抢时间整体拖后。 calCtx, calCancel := context.WithDeadline(ctx, plan.TriggerAt) calibration, calErr := huya.TryCalibrateServerTime(calCtx, client, accounts[0].UID, accounts[0].Cookie, huya.DefaultTimeCalibrationRounds) calCancel() if ctx.Err() != nil { return } if calErr != nil { logger.SetServerOffset(0) a.addLog("warn", "时间校准失败,按本机时钟触发: %v", calErr) } else { plan = sniper.PlanTrigger(startAt, calibration, lead, window) logger.SetServerOffset(calibration.Offset) a.addLog("info", "时间校准完成: %s", calibration.Summary()) } } if cfg.Prewarm { // 预热在独立后台协程运行,绝不阻塞主发令枪: // 无论预热成功与否、耗时多久,主定时器到达 TriggerAt 立即开抢。 prewarmCtx, prewarmCancel := context.WithDeadline(ctx, plan.TriggerAt) go func() { defer prewarmCancel() if err := a.waitFor(prewarmCtx, plan.WarmupAt, "预热"); err != nil { return } a.addLog("info", "开始预热连接: accounts=%d(距触发 %s)", len(accounts), time.Until(plan.TriggerAt).Round(time.Millisecond)) if err := runner.Prewarm(prewarmCtx, accounts, runnerCfg, emit); err != nil && prewarmCtx.Err() == nil { a.addLog("warn", "预热未在触发前完成,继续抢购(未完成账号将在首次请求时建连): %v", err) } }() } runnerCfg.OpenAt = plan.OpenAt a.addLog("info", "等待定时开始: 触发=%s 开抢=%s 窗口结束=%s 提前=%dms 首发错峰=%dms", 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 } if late := time.Since(plan.TriggerAt); late > 50*time.Millisecond { a.addLog("warn", "实际触发比计划晚 %s(触发=%s)", late.Round(time.Millisecond), plan.TriggerAt.Format("15:04:05.000")) } a.runMonitor(ctx, runner, accounts, targets, runnerCfg, emit) }() return nil } // 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 是界面上的「直接兑换」开关:关掉时表示先查列表再兑换, // 也就是 ListBeforeExchange=true,这样价格/库存过滤才会生效。 // 旧实现把 directExchange 单独透传,而引擎在 ListBeforeExchange=false 时会 // 强制回落到直兑(见 sniper.normalizeConfig),界面开关因此形同失效。 func runnerConfig(cfg Config) sniper.Config { return sniper.Config{ SID: cfg.SID, Category: cfg.Category, Delay: time.Duration(cfg.DelayMS) * time.Millisecond, Concurrency: cfg.Concurrency, StopAfterSuccess: cfg.StopAfterSuccess, StopAccountAfterSuccess: cfg.StopAccountAfterSuccess, Prewarm: false, DirectExchange: cfg.DirectExchange, ListBeforeExchange: !cfg.DirectExchange, SharePrizeList: cfg.SharePrizeList, 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, } } // logRunSummary 输出任务汇总:一行总量 + 结局分布 + 接口统计 + 每账号结局。 // // 复盘的痛点是「只有一堆逐条日志」:2026-10-16 的日志里既看不出限流占了多少、 // 也不知道每个账号最后是什么下场,只能手工 grep 统计。这里一次给全。 func (a *App) logRunSummary(summary sniper.RunSummary) { 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%%) 请求[列表=%d 兑换=%d 积分=%d 记录=%d]%s", summary.Attempts, summary.Accounts, summary.Targets, summary.SuccessUIDs, summary.Duration.Round(time.Millisecond), throttled, total, percentOf(throttled, total), summary.ListReqs, summary.ExchangeReqs, summary.ScoreReqs, summary.RecordReqs, window) for _, category := range summary.Categories() { a.logEvent(categoryMonitor, sniper.LogLevelForCategory(category), "结局 %s ×%d(%s)", 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 } func (a *App) StopMonitor() bool { a.runMu.Lock() cancel := a.monitorCancel a.runMu.Unlock() if cancel == nil { return false } cancel() return true } // newClient 创建活动客户端,并把逐条网络交互接到 debug 级日志。 func (a *App) newClient(cfg Config) (huya.ActivityClient, func(), error) { return huya.NewActivityClient(cfg.Transport, cfg.WSSHost, time.Duration(cfg.TimeoutSec)*time.Second, func(msg string) { a.logEvent(categoryNet, logger.LevelDebug, "%s", msg) }) } func (a *App) runConcurrent(accounts []sniper.Account, concurrency int, fn func(i int, account sniper.Account)) { if concurrency <= 0 { concurrency = 1 } 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 }() fn(i, account) }() } wg.Wait() } func (a *App) finishMonitor(message string) { a.runMu.Lock() a.monitorCancel = nil a.monitorRunning = false a.runMu.Unlock() a.emitState(false, message) } // addLog 记录一条应用级日志。 func (a *App) addLog(level, format string, args ...any) { logger.LogEvent(categoryApp, level, format, args...) } // logEvent 记录一条带分类的日志。 func (a *App) logEvent(category, level, format string, args ...any) { logger.LogEvent(category, level, format, args...) } // logAccount 记录一条带账号标签的日志,便于界面按账号观察抢购链路。 func (a *App) logAccount(category, level, account string, format string, args ...any) { logger.LogAccountEvent(category, level, account, format, args...) } func (a *App) emitState(running bool, message string) { a.emit(eventMonitorState, MonitorState{Running: running, Message: message}) } func (a *App) emit(event string, data any) { if a.ctx == nil { return } runtime.EventsEmit(a.ctx, event, data) } func normalizeConfig(cfg Config) Config { if strings.TrimSpace(cfg.Transport) == "" { cfg.Transport = "wss" } // WSSHost 留空表示走 launch.queryHttpDns 自动解析,不在这里补默认值。 if cfg.DelayMS <= 0 { cfg.DelayMS = 150 } if cfg.WindowSeconds <= 0 { cfg.WindowSeconds = sniper.DefaultWindowSeconds } if cfg.Concurrency <= 0 { cfg.Concurrency = 1 } if cfg.LeadMS < 0 { cfg.LeadMS = 0 } if cfg.FrequentBackoffMS <= 0 { cfg.FrequentBackoffMS = 300 } if cfg.ErrorBackoffMS <= 0 { cfg.ErrorBackoffMS = 300 } if cfg.TimeoutSec <= 0 { cfg.TimeoutSec = 15 } cfg.Name = strings.TrimSpace(cfg.Name) cfg.Category = strings.TrimSpace(cfg.Category) cfg.TargetText = strings.TrimSpace(cfg.TargetText) cfg.CookieText = strings.TrimSpace(cfg.CookieText) cfg.StartAt = strings.TrimSpace(cfg.StartAt) return cfg } func parseTargets(cfg Config) []sniper.Target { targets := make([]sniper.Target, 0) if cfg.PID > 0 || strings.TrimSpace(cfg.Name) != "" { targets = append(targets, sniper.Target{PID: cfg.PID, Name: strings.TrimSpace(cfg.Name)}) } if cfg.AutoSelect { targets = append(targets, sniper.Target{Auto: true}) } for _, item := range splitTargetText(cfg.TargetText) { if item == "auto" || item == "自动" || item == "自动选择" { targets = append(targets, sniper.Target{Auto: true}) continue } if pid, err := strconv.ParseInt(item, 10, 32); err == nil && pid > 0 { targets = append(targets, sniper.Target{PID: int32(pid)}) continue } targets = append(targets, sniper.Target{Name: item}) } // 价格/库存过滤对所有目标统一生效,避免逐处重复赋值。 for i := range targets { targets[i].MaxPrice = cfg.MaxPrice targets[i].MinLeft = cfg.MinLeft } return targets } func splitTargetText(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 parseAccounts(cfg Config) ([]sniper.Account, error) { lines := splitCookieLines(cfg.CookieText) if len(lines) == 0 { return nil, errors.New("Cookie 不能为空") } selected := selectedLineSet(cfg.SelectedAccountLines) if len(selected) == 0 { return nil, errors.New("未选择账号") } accounts := make([]sniper.Account, 0, len(lines)) for _, cookie := range lines { if !selected[cookie.lineNo] { continue } uid := huya.ResolveUID(cookie.text) if uid <= 0 { return nil, fmt.Errorf("第 %d 行 Cookie 无法解析 yyuid/udb_uid", cookie.lineNo) } accounts = append(accounts, sniper.Account{UID: uid, Cookie: cookie.text}) } if len(accounts) == 0 { return nil, errors.New("未选择有效账号") } return accounts, nil } func selectedLineSet(lines []int) map[int]bool { out := make(map[int]bool, len(lines)) for _, line := range lines { if line > 0 { out[line] = true } } return out } func splitCookieLines(cookieText string) []cookieLine { lines := strings.Split(normalizeCookieText(cookieText), "\n") out := make([]cookieLine, 0, len(lines)) for i, line := range lines { item := strings.TrimSpace(line) if item == "" || strings.HasPrefix(item, "#") { continue } out = append(out, cookieLine{lineNo: i + 1, text: item}) } return out } func normalizeCookieText(cookieText string) string { normalized := strings.NewReplacer( "\r\n", "\n", "\r", "\n", `\r\n`, "\n", `\r`, "\n", `\n`, "\n", ).Replace(cookieText) if strings.Contains(normalized, "\n") || (!strings.Contains(normalized, "/r/n") && !strings.Contains(normalized, "/r")) { return normalized } return strings.NewReplacer( "/r/n", "\n", "/r", "\n", "/n", "\n", ).Replace(normalized) } func requestContext(timeoutSec int) (context.Context, context.CancelFunc) { if timeoutSec <= 0 { timeoutSec = 15 } return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) } func parseStartTime(text string) (time.Time, error) { text = strings.TrimSpace(text) if text == "" { return time.Time{}, nil } if ts, err := strconv.ParseInt(text, 10, 64); err == nil { return time.Unix(ts, 0), nil } for _, layout := range []string{"15:04:05", "15:04"} { if t, err := time.ParseInLocation(layout, text, time.Local); err == nil { return nextClockTime(time.Now(), t.Hour(), t.Minute(), t.Second()), 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("无法解析开始时间: %s", text) } func nextClockTime(now time.Time, hour, minute, second int) time.Time { target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, second, 0, now.Location()) if !target.After(now) { target = target.Add(24 * time.Hour) } return target } // waitUntil 等到指定时刻。用一次性定时器精确睡眠,避免旧实现 200ms 轮询带来的 // 0-200ms 随机延迟(抢购时这段时间正好是关键窗口)。 // // 长等待拆成最多 1 秒一段:部分 Windows 机器上几小时的单个定时器会被系统合并/延迟, // 今天日志里就出现过“等待 2 小时后复校晚到 5~8 秒”的实例。 func waitUntil(ctx context.Context, when time.Time) error { for { remaining := time.Until(when) if remaining <= 0 { return nil } chunk := remaining if chunk > time.Second { chunk = time.Second } timer := time.NewTimer(chunk) select { case <-timer.C: case <-ctx.Done(): timer.Stop() return ctx.Err() } } } func errorString(err error) string { if err == nil { return "" } return err.Error() } func prizeInventorySummary(prizes []huya.PrizeInfo) (left, total int64, leftKnown, totalKnown int) { for _, prize := range prizes { if prize.Num > 0 { total += prize.Num totalKnown++ } if itemLeft, ok := prize.InventoryLeft(); ok { left += itemLeft leftKnown++ } } return left, total, leftKnown, totalKnown }