Files
huya-go/internal/gui/app.go
T
yml2213 36aadae90a 按 10.13 抓包对齐 WSS:host 自动解析、exp 回传、pingSignal 与兑换记录字段
- 新增 launch.queryHttpDns:类型定义 + HTTPClient.QueryHttpDns + WSSClient.resolveHost(TTL 缓存、多 host 轮询、失败回退默认 host);Host 留空或等于历史默认值即视为自动解析
- WSS 握手 baseinfo 补 tag5=exp 与 tag10 空 customHeaders;UpdateUserExpsReq(cmd23) 改为回传 getConfig 的版本 map(缓存供重连复用)
- 新增 launch.pingSignal:HTTP 预热改走 pingSignal,WSS 预热在建连后探测业务通道(失败仅记日志)
- UserPrizeInfo 补 tag12 orderId/tag14 图标/tag16 prizeId/tag17 shopItemId/tag19 itemId/tag20 ext/tag21 exchangeDate,前端复盘表加订单号列
- taf 补 WriteListString/ReadListString/ReadMapStringInt64/ReadMapStringStruct
- 新增 internal/huya/launch_test.go:用抓包真实报文做字节级夹具(baseinfo 122B、queryHttpDns 请求 93B、记录 308B 等)
- CLI -wss-host 默认改为留空;GUI 设置支持留空并给出占位提示;README/WSS_NOTES 同步协议细节
2026-10-13 20:19:14 +08:00

758 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: "",
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"
}
// WSSHost 留空表示走 launch.queryHttpDns 自动解析,不在这里补默认值。
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
}