- 预热时序重构:WarmupLead 从 2s 延长至 10s,ResyncLead 从 3s 延长至 15s;开抢前 10s 开始异步建连,开抢前 5s 全部就绪待命 - 兼顾零建连延迟与发令安全:既彻底杜绝了 2s 极限时间被慢网络超时拖垮发车的问题,又保留了首发 0 建连耗时的长连接极速优势 - 默认配置:Prewarm 恢复为默认开启,并在界面增加明确的调度行为提示 - 验证方式:go test ./... -count=1 全通,npm run build 构建通过
1000 lines
32 KiB
Go
1000 lines
32 KiB
Go
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
|
||
}
|