package gui import ( "context" "errors" "fmt" "strconv" "strings" "sync" "time" "github.com/wailsapp/wails/v2/pkg/runtime" "huya-go/internal/huya" "huya-go/internal/sniper" ) const ( eventLog = "gui:log" eventMonitorResult = "gui:monitor-result" eventMonitorState = "gui:monitor-state" maxLogEntries = 500 ) type App struct { ctx context.Context logMu sync.Mutex logs []LogEntry 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"` Name string `json:"name"` TargetText string `json:"targetText"` CookieText string `json:"cookieText"` SelectedAccountLines []int `json:"selectedAccountLines"` Loops int `json:"loops"` 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"` FrequentBackoffMS int `json:"frequentBackoffMs"` ErrorBackoffMS int `json:"errorBackoffMs"` TimeoutSec int `json:"timeoutSec"` StartAt string `json:"startAt"` } type AccountPreview struct { Line int `json:"line"` UID int64 `json:"uid"` Valid bool `json:"valid"` Error string `json:"error,omitempty"` } type LogEntry struct { Time string `json:"time"` Level string `json:"level"` Message string `json:"message"` } type PrizeListResult struct { UID int64 `json:"uid"` Status int32 `json:"status"` Msg string `json:"msg"` Prizes []huya.PrizeInfo `json:"prizes"` 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"` Error string `json:"error,omitempty"` } type MonitorState struct { Running bool `json:"running"` Message string `json:"message"` } type cookieLine struct { lineNo int text string } func NewApp() *App { return &App{} } func (a *App) Startup(ctx context.Context) { a.ctx = ctx a.addLog("info", "GUI 已启动") } func (a *App) Shutdown(ctx context.Context) { a.StopMonitor() } func (a *App) DefaultConfig() Config { return Config{ Transport: "wss", WSSHost: huya.DefaultWSSHost, SID: 2203, Loops: 20, DelayMS: 120, Concurrency: 5, StopAfterSuccess: false, StopAccountAfterSuccess: true, Prewarm: true, DirectExchange: true, SharePrizeList: true, AutoSelect: false, RefreshAfterSuccess: true, LeadMS: 2500, 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) GetLogs() []LogEntry { a.logMu.Lock() defer a.logMu.Unlock() return append([]LogEntry(nil), a.logs...) } 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 } a.addLog("info", "查询奖品完成: uid=%d sid=%d count=%d", accounts[0].UID, cfg.SID, len(resp.Prizes)) return PrizeListResult{ UID: accounts[0].UID, Status: resp.Status, Msg: resp.Msg, Prizes: resp.Prizes, 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, 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 targets=%d accounts=%d prewarm=%v direct=%v", cfg.SID, len(targets), len(accounts), cfg.Prewarm, cfg.DirectExchange) go func() { defer closeClient() defer a.finishMonitor("已停止") runner := sniper.Runner{Client: client} emit := func(result sniper.Result) { a.emit(eventMonitorResult, result) a.addLog("info", "监控结果: loop=%d uid=%d action=%s category=%s msg=%s error=%s", result.Loop, result.UID, result.Action, result.Category, result.Message, result.Error) } runnerCfg := sniper.Config{ SID: cfg.SID, Loops: cfg.Loops, Delay: time.Duration(cfg.DelayMS) * time.Millisecond, Concurrency: cfg.Concurrency, StopAfterSuccess: cfg.StopAfterSuccess, StopAccountAfterSuccess: cfg.StopAccountAfterSuccess, Prewarm: false, DirectExchange: cfg.DirectExchange, SharePrizeList: cfg.SharePrizeList, RefreshAfterSuccess: cfg.RefreshAfterSuccess, TooFrequentBackoff: time.Duration(cfg.FrequentBackoffMS) * time.Millisecond, ErrorBackoff: time.Duration(cfg.ErrorBackoffMS) * time.Millisecond, } 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 } } if !startAt.IsZero() { when := startAt.Add(-time.Duration(cfg.LeadMS) * time.Millisecond) 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) } }() return nil } func (a *App) StopMonitor() bool { a.runMu.Lock() cancel := a.monitorCancel a.runMu.Unlock() if cancel == nil { return false } cancel() return true } 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.addLog("debug", "%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) } func (a *App) addLog(level, format string, args ...any) { entry := LogEntry{ Time: time.Now().Format("15:04:05.000"), Level: level, Message: fmt.Sprintf(format, args...), } a.logMu.Lock() a.logs = append(a.logs, entry) if len(a.logs) > maxLogEntries { a.logs = append([]LogEntry(nil), a.logs[len(a.logs)-maxLogEntries:]...) } a.logMu.Unlock() a.emit(eventLog, entry) } 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" } if strings.TrimSpace(cfg.WSSHost) == "" { cfg.WSSHost = huya.DefaultWSSHost } if cfg.Loops <= 0 { cfg.Loops = 1 } if cfg.DelayMS <= 0 { cfg.DelayMS = 150 } 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.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}) } 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) accounts := make([]sniper.Account, 0, len(lines)) for _, cookie := range lines { if len(selected) > 0 && !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{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 waitUntil(ctx context.Context, when time.Time) error { for { remaining := time.Until(when) if remaining <= 0 { return nil } timer := time.NewTimer(minDuration(remaining, 200*time.Millisecond)) select { case <-timer.C: case <-ctx.Done(): timer.Stop() return ctx.Err() } } } func minDuration(a, b time.Duration) time.Duration { if a < b { return a } return b } func errorString(err error) string { if err == nil { return "" } return err.Error() }