Files
yml2213 f89b54dc49 feat(gui): 优化双时间日志呈现与账号兑换记录弹窗明细查看
- 参考斗鱼重构日志双时间展示:本地时间与虎牙估算时间拆分,青色高亮 Tag 醒目显示 H:xx:xx:xx.xxx,排版自适应不折行;新增一键复制带双时间格式日志与清空功能
- 增强账号兑换记录交互:账号列表新增专属「记录」按钮,点击弹出专属兑换记录明细 Modal(支持查看全部历史订单、缩略图、积分、格式化时间,单号一键复制)
- 优化顶部批量「兑换记录」流程:查询完成后自动切至「兑换记录」Tab(由原'复盘'重命名),并显示总记录条数与友好空状态提示
- 验证:Vite 前端打包与 Go 全套单测均干净通过
2026-10-17 19:23:19 +08:00

1006 lines
32 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 2277,
DelayMS: 120,
// 线程是「同时在途请求数」上限。单机单实例默认 30,
// 5 个多开实例整机约 150 并发(与斗鱼单机 150 吞吐相当)。
// 配合首发 300ms 错峰(10ms 步长),既能确保 30 个账号免排队在
// 0~300ms 黄金期内各打出第 1 枪,又避免瞬间拥堵,兼顾成功率与并发度。
Concurrency: 30,
WindowSeconds: 30,
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))
totalRecords := 0
var recordMu sync.Mutex
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
recordMu.Lock()
totalRecords += len(resp.Records)
recordMu.Unlock()
}
results[i] = item
})
a.addLog("info", "查询兑换记录完成: accounts=%d 总记录=%d", len(accounts), totalRecords)
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 = 120
}
if cfg.WindowSeconds <= 0 {
cfg.WindowSeconds = sniper.DefaultWindowSeconds
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 30
}
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
}