Files
huya-go/internal/gui/app.go
T
yml2213 e8bb4eb571 补全过滤入口与直接兑换,优化兑换分类和 WSS 建连
- GUI 接线 Exchange 绑定:新增「直接兑换」按钮,结果带 category/message 并进入结果面板
- 补 -max-price/-min-left(CLI 与 GUI 设置),Target 的价格与库存过滤不再悬空
- 删除死代码 sendUpdateUserInfo 及其请求结构体(浏览器流程不含该帧)
- ClassifyExchange 改为多路判定:合并 msg/actPreCondition/exchangeInfo 文案,status=200 且有 orderId 视为成功,新增 condition 分类(precondition 门禁)
- WSS 建连改为锁外 dial、锁内注册,同 uid 并发调用共享一次握手
- 默认传输层统一为 http(CLI/GUI/README),.gitignore 忽略 .DS_Store
- 同步 Wails 依赖升级(2.12.0 -> 2.15.0)并重新生成前端绑定
2026-10-13 19:57:08 +08:00

760 lines
20 KiB
Go
Raw 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"
"sync/atomic"
"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
logQueueSize = 2048
)
type App struct {
ctx context.Context
logMu sync.Mutex
logs []LogEntry
logCh chan LogEntry
logStop chan struct{}
logWorkerOnce sync.Once
logStopOnce sync.Once
droppedLogs atomic.Int64
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"`
MaxPrice int64 `json:"maxPrice"`
MinLeft int64 `json:"minLeft"`
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"`
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
}
func NewApp() *App {
return &App{
logCh: make(chan LogEntry, logQueueSize),
logStop: make(chan struct{}),
}
}
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.startLogWorker()
a.addLog("info", "GUI 已启动")
}
func (a *App) Shutdown(ctx context.Context) {
a.StopMonitor()
a.stopLogWorker()
}
func (a *App) DefaultConfig() Config {
return Config{
Transport: "http",
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
}
left, total, leftKnown, totalKnown := prizeInventorySummary(resp.Prizes)
a.addLog("info", "查询商品完成: uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", accounts[0].UID, cfg.SID, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg)
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,
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 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...),
}
if a.logCh == nil {
a.writeLogEntry(entry)
return
}
select {
case a.logCh <- entry:
default:
a.droppedLogs.Add(1)
}
}
func (a *App) startLogWorker() {
if a.logCh == nil {
a.logCh = make(chan LogEntry, logQueueSize)
}
if a.logStop == nil {
a.logStop = make(chan struct{})
}
a.logWorkerOnce.Do(func() {
go a.logWorker()
})
}
func (a *App) stopLogWorker() {
if a.logStop == nil {
return
}
a.logStopOnce.Do(func() {
close(a.logStop)
})
}
func (a *App) logWorker() {
for {
select {
case entry := <-a.logCh:
a.flushDroppedLogs()
a.writeLogEntry(entry)
case <-a.logStop:
a.drainLogQueue()
a.flushDroppedLogs()
return
}
}
}
func (a *App) drainLogQueue() {
for {
select {
case entry := <-a.logCh:
a.flushDroppedLogs()
a.writeLogEntry(entry)
default:
return
}
}
}
func (a *App) flushDroppedLogs() {
dropped := a.droppedLogs.Swap(0)
if dropped <= 0 {
return
}
a.writeLogEntry(LogEntry{
Time: time.Now().Format("15:04:05.000"),
Level: "warn",
Message: fmt.Sprintf("日志队列繁忙,已异步丢弃 %d 条日志", dropped),
})
}
func (a *App) writeLogEntry(entry LogEntry) {
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 = "http"
}
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})
}
// 价格/库存过滤对所有目标统一生效,避免逐处重复赋值。
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)
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{"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
}
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()
}
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
}