From 41565566c5cd68bd375178626c4f25e70ce58071 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 13 Oct 2026 20:34:43 +0800 Subject: [PATCH] =?UTF-8?q?GUI=20=E6=97=A5=E5=BF=97=E8=90=BD=E7=9B=98?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=90=8C=E4=B8=80=E5=A5=96=E5=93=81?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=85=91=E6=8D=A2=E4=B8=8E=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 日志落盘:新增 internal/gui/logfile.go,写到 <用户配置目录>/huya-go/logs/huya-go.log,单文件 5MB 后滚动保留 5 个备份;「日志」页签顶部显示路径并支持打开目录/复制路径;写盘失败自动降级为仅界面输出 - 修复重复兑换:界面选品会同时提交 pid、名称与 targetText 里的同一 pid,normalizeTargets 现按 pid/名称去重;runState 再按「账号+奖品」记账,多个 target 命中同一奖品时只兑换一次(新增 skip_duplicate 结果) - 修复 use of closed network connection:成功后的异步刷新改为 runState.refresh 等待,RunTargets 返回前不再提前关闭连接 - queryHttpDns 增加 single-flight 与失败冷却,多账号并发建连只解析一次 - 新增 internal/gui/logfile_test.go 与 sniper/huya 回归测试;README 补日志说明 --- README.md | 8 ++ frontend/src/App.tsx | 47 ++++++++-- frontend/src/styles.css | 28 +++++- frontend/wailsjs/go/gui/App.d.ts | 4 + frontend/wailsjs/go/gui/App.js | 8 ++ internal/gui/app.go | 87 ++++++++++++++++++- internal/gui/logfile.go | 145 +++++++++++++++++++++++++++++++ internal/gui/logfile_test.go | 138 +++++++++++++++++++++++++++++ internal/huya/launch.go | 2 + internal/huya/launch_test.go | 90 +++++++++++++++++++ internal/huya/wss_client.go | 42 ++++++++- internal/sniper/sniper.go | 95 ++++++++++++++++++-- internal/sniper/sniper_test.go | 93 ++++++++++++++++++++ 13 files changed, 769 insertions(+), 18 deletions(-) create mode 100644 internal/gui/logfile.go create mode 100644 internal/gui/logfile_test.go diff --git a/README.md b/README.md index 811d81a..82fe79f 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,14 @@ CLI 示例: ./huya-go -cmd monitor -sid 2203 -pid 12889 -cookie-file cookie.txt -transport http -at "2026-07-06 20:00:00" ``` +## 日志 + +GUI 日志同时输出到界面和磁盘: + +- 路径:`<系统用户配置目录>/huya-go/logs/huya-go.log`(macOS 为 `~/Library/Application Support/huya-go/logs/huya-go.log`,Windows 为 `%AppData%\huya-go\logs\huya-go.log`)。 +- 单文件上限 5MB,超出后滚动为 `huya-go.log.1` ~ `huya-go.log.5`,最旧的被丢弃。 +- 界面「日志」页签顶部显示当前路径,可一键打开日志目录或复制路径。 + ## 参数说明 - `-cmd`:`list`、`score`、`records`、`exchange`、`monitor`。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8b3f4f..d3aa494 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,8 @@ import type { ColumnsType } from 'antd/es/table' import { ClipboardList, ClipboardPaste, + Copy, + FolderOpen, ListChecks, Pencil, Play, @@ -36,16 +38,18 @@ import { import { DefaultConfig, Exchange, + GetLogFilePath, GetLogs, GetRecords, IsRunning, ListPrizes, + OpenLogDir, PreviewAccounts, QueryScores, StartMonitor, StopMonitor, } from '../wailsjs/go/gui/App' -import { ClipboardGetText, EventsOn } from '../wailsjs/runtime/runtime' +import { ClipboardGetText, ClipboardSetText, EventsOn } from '../wailsjs/runtime/runtime' const storageKey = 'huya-go.gui.config.v2' const cookieStorageKey = 'huya-go.gui.cookies.v1' @@ -482,6 +486,7 @@ export default function Dashboard() { const [cookieEntries, setCookieEntries] = useState([]) const [prizeCacheUpdatedAt, setPrizeCacheUpdatedAt] = useState(0) const [configLoaded, setConfigLoaded] = useState(false) + const [logPath, setLogPath] = useState('') const applyCookieEntries = async (entries: CookieEntry[], persist = true, refreshLatest = false) => { const normalizedEntries = entries @@ -526,8 +531,8 @@ export default function Dashboard() { useEffect(() => { let mounted = true - Promise.all([DefaultConfig(), GetLogs(), IsRunning()]) - .then(([defaults, logItems, isRunning]) => { + Promise.all([DefaultConfig(), GetLogs(), IsRunning(), GetLogFilePath()]) + .then(([defaults, logItems, isRunning, savedLogPath]) => { if (!mounted) return const savedCookies = readSavedCookies() const savedPrizeCache = readSavedPrizeCache() @@ -540,6 +545,7 @@ export default function Dashboard() { setPrizes(savedPrizeCache.prizes) setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt) setLogs((logItems || []) as LogEntry[]) + setLogPath(String(savedLogPath || '')) setRunning(Boolean(isRunning)) setConfigLoaded(true) if (savedCookies.length > 0) { @@ -882,7 +888,8 @@ export default function Dashboard() { form.setFieldsValue({ pid: selected[0].id, name: selected[0].name, - targetText: selected.map((item) => String(item.id)).join('\n'), + // pid/名称已经代表第一个奖品,这里只补其余奖品,避免同一奖品被提交两次。 + targetText: selected.slice(1).map((item) => String(item.id)).join('\n'), autoSelect: false, }) } @@ -932,6 +939,14 @@ export default function Dashboard() { ) } + const openLogDir = () => runAction('logdir', () => OpenLogDir()) + + const copyLogPath = async () => { + if (!logPath) return + await ClipboardSetText(logPath) + message.success('已复制日志路径') + } + const selectAllAccounts = () => { setSelectedAccountLines(validAccounts.map((item) => item.line)) } @@ -1107,7 +1122,29 @@ export default function Dashboard() { { key: 'logs', label: '日志', - children: , + children: ( +
+
+ + {logPath || '日志文件不可用'} + + + +
+ +
+ ), }, { key: 'results', diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6bcaad2..f6673ac 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -386,8 +386,34 @@ body { margin-bottom: 8px; } +.log-pane { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; + min-height: 0; +} + +.log-tools { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.log-path { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: #6b7280; + font-size: 12px; +} + .log-view { - height: calc(100% - 2px); + flex: 1; + height: auto; min-height: 300px; overflow: auto; border: 1px solid #e6edf3; diff --git a/frontend/wailsjs/go/gui/App.d.ts b/frontend/wailsjs/go/gui/App.d.ts index b56ed75..0aabfa6 100755 --- a/frontend/wailsjs/go/gui/App.d.ts +++ b/frontend/wailsjs/go/gui/App.d.ts @@ -6,6 +6,8 @@ export function DefaultConfig():Promise; export function Exchange(arg1:gui.Config):Promise>; +export function GetLogFilePath():Promise; + export function GetLogs():Promise>; export function GetRecords(arg1:gui.Config):Promise>; @@ -14,6 +16,8 @@ export function IsRunning():Promise; export function ListPrizes(arg1:gui.Config):Promise; +export function OpenLogDir():Promise; + export function PreviewAccounts(arg1:string):Promise>; export function QueryScores(arg1:gui.Config):Promise>; diff --git a/frontend/wailsjs/go/gui/App.js b/frontend/wailsjs/go/gui/App.js index 41c8a3a..2ac8138 100755 --- a/frontend/wailsjs/go/gui/App.js +++ b/frontend/wailsjs/go/gui/App.js @@ -10,6 +10,10 @@ export function Exchange(arg1) { return window['go']['gui']['App']['Exchange'](arg1); } +export function GetLogFilePath() { + return window['go']['gui']['App']['GetLogFilePath'](); +} + export function GetLogs() { return window['go']['gui']['App']['GetLogs'](); } @@ -26,6 +30,10 @@ export function ListPrizes(arg1) { return window['go']['gui']['App']['ListPrizes'](arg1); } +export function OpenLogDir() { + return window['go']['gui']['App']['OpenLogDir'](); +} + export function PreviewAccounts(arg1) { return window['go']['gui']['App']['PreviewAccounts'](arg1); } diff --git a/internal/gui/app.go b/internal/gui/app.go index 82df0cd..adaa237 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -35,6 +35,10 @@ type App struct { logWorkerOnce sync.Once logStopOnce sync.Once droppedLogs atomic.Int64 + // logSink 为 nil 表示当前环境无法写日志文件,日志只留在内存与界面上。 + logSink *logSink + logSinkErr error + logWriteFailures atomic.Int64 runMu sync.Mutex monitorCancel context.CancelFunc @@ -80,6 +84,9 @@ type LogEntry struct { Time string `json:"time"` Level string `json:"level"` Message string `json:"message"` + + // at 只用于落盘,保证文件里带完整日期(不参与 JSON 序列化)。 + at time.Time } type PrizeListResult struct { @@ -123,21 +130,57 @@ type cookieLine struct { } func NewApp() *App { - return &App{ + app := &App{ logCh: make(chan LogEntry, logQueueSize), logStop: make(chan struct{}), } + // 日志文件不可用时只降级为内存日志,不影响主流程。 + sink, err := newLogSink() + if err != nil { + app.logSinkErr = err + } else { + app.logSink = sink + } + return app } func (a *App) Startup(ctx context.Context) { a.ctx = ctx a.startLogWorker() a.addLog("info", "GUI 已启动") + if a.logSink != nil { + a.addLog("info", "日志文件: %s", a.logSink.Path()) + } else if a.logSinkErr != nil { + a.addLog("warn", "日志文件不可用,日志仅保留在界面: %v", a.logSinkErr) + } +} + +// GetLogFilePath 返回当前日志文件路径(供界面展示/复制)。 +func (a *App) GetLogFilePath() string { + if a.logSink == nil { + return "" + } + return a.logSink.Path() +} + +// OpenLogDir 用系统文件管理器打开日志目录。 +func (a *App) OpenLogDir() error { + if a.logSink == nil { + if a.logSinkErr != nil { + return fmt.Errorf("日志目录不可用: %w", a.logSinkErr) + } + return errors.New("日志目录不可用") + } + if err := openDirectory(a.logSink.Dir()); err != nil { + return fmt.Errorf("打开日志目录失败: %w", err) + } + return nil } func (a *App) Shutdown(ctx context.Context) { a.StopMonitor() a.stopLogWorker() + a.closeLogSink() } func (a *App) DefaultConfig() Config { @@ -442,8 +485,10 @@ func (a *App) finishMonitor(message string) { } func (a *App) addLog(level, format string, args ...any) { + now := time.Now() entry := LogEntry{ - Time: time.Now().Format("15:04:05.000"), + Time: now.Format("15:04:05.000"), + at: now, Level: level, Message: fmt.Sprintf(format, args...), } @@ -510,14 +555,21 @@ func (a *App) flushDroppedLogs() { if dropped <= 0 { return } + now := time.Now() a.writeLogEntry(LogEntry{ - Time: time.Now().Format("15:04:05.000"), + Time: now.Format("15:04:05.000"), + at: now, Level: "warn", Message: fmt.Sprintf("日志队列繁忙,已异步丢弃 %d 条日志", dropped), }) } func (a *App) writeLogEntry(entry LogEntry) { + a.emitLogEntry(entry, true) +} + +// emitLogEntry 写入内存缓存并推给界面;persist 为 true 时同时落盘。 +func (a *App) emitLogEntry(entry LogEntry, persist bool) { a.logMu.Lock() a.logs = append(a.logs, entry) if len(a.logs) > maxLogEntries { @@ -525,6 +577,35 @@ func (a *App) writeLogEntry(entry LogEntry) { } a.logMu.Unlock() a.emit(eventLog, entry) + if persist { + a.appendLogFile(entry) + } +} + +func (a *App) appendLogFile(entry LogEntry) { + sink := a.logSink + if sink == nil { + return + } + if err := sink.Write(entry); err != nil { + // 只在第一次失败时提示,且不再落盘,避免刷屏与递归。 + if a.logWriteFailures.Add(1) == 1 { + now := time.Now() + a.emitLogEntry(LogEntry{ + Time: now.Format("15:04:05.000"), + at: now, + Level: "warn", + Message: fmt.Sprintf("日志写入文件失败,已改为仅界面输出: %v", err), + }, false) + } + } +} + +func (a *App) closeLogSink() { + if a.logSink == nil { + return + } + _ = a.logSink.Close() } func (a *App) emitState(running bool, message string) { diff --git a/internal/gui/logfile.go b/internal/gui/logfile.go new file mode 100644 index 0000000..33e0dc4 --- /dev/null +++ b/internal/gui/logfile.go @@ -0,0 +1,145 @@ +package gui + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +const ( + // appDirName 是用户配置目录下的应用目录名。 + appDirName = "huya-go" + logSubDir = "logs" + logName = "huya-go.log" + + defaultMaxLogFileBytes = 5 << 20 // 单个日志文件上限 5MB + defaultMaxLogBackups = 5 // 最多保留 5 个滚动文件 +) + +// logSink 把日志追加写到磁盘,超过大小上限时按 .1/.2/... 滚动,避免长期运行写满磁盘。 +type logSink struct { + dir string + path string + file *os.File + size int64 + maxBytes int64 + backups int +} + +// newLogSink 在系统用户配置目录下创建 /huya-go/logs/huya-go.log。 +func newLogSink() (*logSink, error) { + base, err := os.UserConfigDir() + if err != nil { + return nil, err + } + return newLogSinkAt(filepath.Join(base, appDirName, logSubDir)) +} + +func newLogSinkAt(dir string) (*logSink, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + sink := &logSink{ + dir: dir, + path: filepath.Join(dir, logName), + maxBytes: defaultMaxLogFileBytes, + backups: defaultMaxLogBackups, + } + if err := sink.open(); err != nil { + return nil, err + } + return sink, nil +} + +// Dir 返回日志目录。 +func (s *logSink) Dir() string { return s.dir } + +// Path 返回当前日志文件路径。 +func (s *logSink) Path() string { return s.path } + +func (s *logSink) open() error { + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return err + } + s.file = file + s.size = info.Size() + return nil +} + +// Write 追加一条日志,必要时先滚动。 +func (s *logSink) Write(entry LogEntry) error { + if s.file == nil { + if err := s.open(); err != nil { + return err + } + } + line := formatLogLine(entry) + if s.maxBytes > 0 && s.size+int64(len(line)) > s.maxBytes { + if err := s.rotate(); err != nil { + return err + } + } + n, err := s.file.WriteString(line) + s.size += int64(n) + return err +} + +func (s *logSink) rotate() error { + if s.file != nil { + _ = s.file.Close() + s.file = nil + } + for i := s.backups - 1; i >= 1; i-- { + from := fmt.Sprintf("%s.%d", s.path, i) + if _, err := os.Stat(from); err != nil { + continue + } + _ = os.Rename(from, fmt.Sprintf("%s.%d", s.path, i+1)) + } + if _, err := os.Stat(s.path); err == nil { + _ = os.Rename(s.path, s.path+".1") + } + s.size = 0 + return s.open() +} + +// Close 关闭当前文件。 +func (s *logSink) Close() error { + if s.file == nil { + return nil + } + err := s.file.Close() + s.file = nil + return err +} + +func formatLogLine(entry LogEntry) string { + at := entry.at + if at.IsZero() { + at = time.Now() + } + return fmt.Sprintf("%s [%s] %s\n", at.Format("2006-01-02 15:04:05.000"), entry.Level, entry.Message) +} + +// openDirectory 调用系统文件管理器打开目录。 +func openDirectory(dir string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", dir) + case "windows": + cmd = exec.Command("explorer", dir) + default: + cmd = exec.Command("xdg-open", dir) + } + return cmd.Start() +} diff --git a/internal/gui/logfile_test.go b/internal/gui/logfile_test.go new file mode 100644 index 0000000..4dd2c93 --- /dev/null +++ b/internal/gui/logfile_test.go @@ -0,0 +1,138 @@ +package gui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLogSinkWritesFormattedLines(t *testing.T) { + sink, err := newLogSinkAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + at := time.Date(2026, 10, 13, 20, 21, 57, 822000000, time.Local) + if err := sink.Write(LogEntry{ + Time: "20:21:57.822", + at: at, + Level: "debug", + Message: "[WSS] 请求 launch.pingSignal requestID=2", + }); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(sink.Path()) + if err != nil { + t.Fatal(err) + } + want := "2026-10-13 20:21:57.822 [debug] [WSS] 请求 launch.pingSignal requestID=2\n" + if string(data) != want { + t.Fatalf("日志内容=%q, want %q", data, want) + } + if sink.Dir() != filepath.Dir(sink.Path()) { + t.Fatalf("Dir=%q Path=%q", sink.Dir(), sink.Path()) + } +} + +func TestLogSinkRotatesAndDropsOldest(t *testing.T) { + dir := t.TempDir() + sink, err := newLogSinkAt(dir) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + sink.maxBytes = 200 + sink.backups = 2 + + for i := 0; i < 60; i++ { + if err := sink.Write(LogEntry{Level: "info", Message: fmt.Sprintf("第 %d 条日志,用于触发滚动", i)}); err != nil { + t.Fatal(err) + } + } + + if _, err := os.Stat(sink.Path() + ".1"); err != nil { + t.Fatalf("期望存在滚动文件 .1: %v", err) + } + if _, err := os.Stat(sink.Path() + ".2"); err != nil { + t.Fatalf("期望存在滚动文件 .2: %v", err) + } + if _, err := os.Stat(sink.Path() + ".3"); err == nil { + t.Fatal("backups=2 时不应出现 .3") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + t.Fatal(err) + } + if info.Size() > int64(sink.maxBytes)+200 { + t.Fatalf("%s 体积异常: %d", entry.Name(), info.Size()) + } + } +} + +func TestLogSinkAppendsAcrossReopen(t *testing.T) { + dir := t.TempDir() + sink, err := newLogSinkAt(dir) + if err != nil { + t.Fatal(err) + } + if err := sink.Write(LogEntry{Level: "info", Message: "第一条"}); err != nil { + t.Fatal(err) + } + if err := sink.Close(); err != nil { + t.Fatal(err) + } + reopened, err := newLogSinkAt(dir) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if err := reopened.Write(LogEntry{Level: "info", Message: "第二条"}); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(reopened.Path()) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "第一条") || !strings.Contains(text, "第二条") { + t.Fatalf("追加写入失败: %q", text) + } + if strings.Index(text, "第一条") > strings.Index(text, "第二条") { + t.Fatalf("日志顺序异常: %q", text) + } +} + +func TestAppWritesLogsToFile(t *testing.T) { + sink, err := newLogSinkAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + app := &App{logSink: sink} + app.addLog("info", "日志文件: %s", sink.Path()) + app.addLog("debug", "[WSS] pingSignal 往返=28ms 服务端=17ms") + + data, err := os.ReadFile(sink.Path()) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "日志文件: ") || !strings.Contains(text, "pingSignal") { + t.Fatalf("日志未落盘: %q", text) + } + if !strings.Contains(text, "[info]") || !strings.Contains(text, "[debug]") { + t.Fatalf("日志级别未写入: %q", text) + } +} diff --git a/internal/huya/launch.go b/internal/huya/launch.go index 526c82b..2406449 100644 --- a/internal/huya/launch.go +++ b/internal/huya/launch.go @@ -21,6 +21,8 @@ const ( // DefaultDNSTTL 是响应没有 ttl 时的兜底缓存时间。 DefaultDNSTTL = 10 * time.Minute + // dnsFailureCooldown 是 queryHttpDns 失败后的冷却时间。 + dnsFailureCooldown = 30 * time.Second ) // QueryHttpDnsReq 对应浏览器 launch.queryHttpDns 的 tReq。 diff --git a/internal/huya/launch_test.go b/internal/huya/launch_test.go index 8e80018..d520528 100644 --- a/internal/huya/launch_test.go +++ b/internal/huya/launch_test.go @@ -3,8 +3,14 @@ package huya import ( "context" "encoding/base64" + "encoding/binary" "encoding/hex" + "net/http" + "net/http/httptest" "net/url" + "strings" + "sync" + "sync/atomic" "testing" "time" @@ -347,3 +353,87 @@ func TestNewActivityClientWSSHostSemantics(t *testing.T) { }) } } + +// buildWupResponse 用 TAF 拼一个最小 WUP 响应,用于本地 httptest 桩服务。 +func buildWupResponse(t *testing.T, servant, fn string, requestID int32, payload []byte) []byte { + t.Helper() + inner := taf.NewWriter() + if err := inner.WriteMapStringBytes(0, map[string][]byte{"tRsp": payload}); err != nil { + t.Fatal(err) + } + body := taf.NewWriter() + for _, err := range []error{ + body.WriteInt16(1, 3), + body.WriteInt8(2, 0), + body.WriteInt32(3, 0), + body.WriteInt32(4, int64(requestID)), + body.WriteString(5, servant), + body.WriteString(6, fn), + body.WriteBytes(7, inner.Bytes()), + } { + if err != nil { + t.Fatal(err) + } + } + raw := body.Bytes() + out := make([]byte, 0, len(raw)+4) + var size [4]byte + binary.BigEndian.PutUint32(size[:], uint32(len(raw)+4)) + out = append(out, size[:]...) + return append(out, raw...) +} + +func TestResolveHostSingleFlightAndCache(t *testing.T) { + // 先准备好响应,避免在 handler 里调用 t.Fatal。 + responseBytes := buildWupResponse(t, ServantLaunch, FuncQueryHttpDns, -1, mustDecodeHex(t, realQueryHttpDnsRspHex)) + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + // 留出时间让并发协程都进入等待分支。 + time.Sleep(30 * time.Millisecond) + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(responseBytes) + })) + defer server.Close() + + client := NewWSSClient(nil) + client.DNSEndpoint = server.URL + "/" + + const callers = 4 + hosts := make([]string, callers) + var wg sync.WaitGroup + for i := 0; i < callers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + hosts[i] = client.resolveHost(context.Background(), int64(i+1), "yyuid=1") + }(i) + } + wg.Wait() + + if got := hits.Load(); got != 1 { + t.Fatalf("queryHttpDns 请求次数=%d, want 1(并发建连应共用一次解析)", got) + } + for i, host := range hosts { + if !strings.HasSuffix(host, "-ws.va.huya.com") { + t.Fatalf("caller %d 解析结果异常: %q", i, host) + } + } + + // TTL 内命中缓存,不再请求解析接口。 + if got := client.resolveHost(context.Background(), 99, "yyuid=1"); !strings.HasSuffix(got, "-ws.va.huya.com") { + t.Fatalf("缓存 host 异常: %q", got) + } + if got := hits.Load(); got != 1 { + t.Fatalf("TTL 内重复请求解析接口: %d 次", got) + } + + // 多 host 轮询:连续 4 次应覆盖响应里的全部 4 个 host。 + seen := map[string]bool{} + for i := 0; i < 4; i++ { + seen[client.resolveHost(context.Background(), 99, "yyuid=1")] = true + } + if len(seen) != 4 { + t.Fatalf("host 轮询未覆盖全部节点: %v", seen) + } +} diff --git a/internal/huya/wss_client.go b/internal/huya/wss_client.go index dca2a30..e212622 100644 --- a/internal/huya/wss_client.go +++ b/internal/huya/wss_client.go @@ -54,6 +54,10 @@ type WSSClient struct { dnsHosts []string dnsIndex int dnsExpireAt time.Time + // dnsInflight 保证并发建连时只发起一次 queryHttpDns。 + dnsInflight chan struct{} + // dnsFailedAt 记录解析失败时间,冷却期内不再重复请求。 + dnsFailedAt time.Time } type WSSSession struct { @@ -151,6 +155,7 @@ func (c *WSSClient) PingSignal(ctx context.Context, uid int64, cookie string) (* // resolveHost 返回本次建连使用的 host。 // // 优先级:显式配置的 Host > queryHttpDns 结果(TTL 内缓存、多 host 轮询)> DefaultWSSHost。 +// 并发建连时只会发起一次 queryHttpDns,失败后短时间冷却,避免多账号同时打爆解析接口。 func (c *WSSClient) resolveHost(ctx context.Context, uid int64, cookie string) string { if host := strings.TrimSpace(c.Host); host != "" { return host @@ -158,10 +163,43 @@ func (c *WSSClient) resolveHost(ctx context.Context, uid int64, cookie string) s if !c.AutoResolveHost { return DefaultWSSHost } - if host := c.nextCachedDNSHost(); host != "" { - return host + for { + if host := c.nextCachedDNSHost(); host != "" { + return host + } + c.mu.Lock() + if time.Since(c.dnsFailedAt) < dnsFailureCooldown { + c.mu.Unlock() + c.logf("[WSS] queryHttpDns 冷却中,暂时回退 %s", DefaultWSSHost) + return DefaultWSSHost + } + if c.dnsInflight == nil { + c.dnsInflight = make(chan struct{}) + c.mu.Unlock() + break + } + wait := c.dnsInflight + c.mu.Unlock() + select { + case <-wait: + case <-ctx.Done(): + return DefaultWSSHost + } } + resp, err := c.queryHttpDns(ctx, uid, cookie) + + c.mu.Lock() + wait := c.dnsInflight + c.dnsInflight = nil + if err != nil { + c.dnsFailedAt = time.Now() + } + c.mu.Unlock() + if wait != nil { + close(wait) + } + if err != nil { c.logf("[WSS] queryHttpDns 失败,回退 %s: %v", DefaultWSSHost, err) return DefaultWSSHost diff --git a/internal/sniper/sniper.go b/internal/sniper/sniper.go index 8dbef36..2ae8c28 100644 --- a/internal/sniper/sniper.go +++ b/internal/sniper/sniper.go @@ -91,6 +91,10 @@ type runState struct { mu sync.Mutex accounts map[int64][]targetState targetDone []bool + // exchanged 记录每个账号已成功兑换的奖品,避免多个 target 命中同一奖品时重复兑换。 + exchanged map[int64]map[int64]bool + // refresh 用于等待成功后的异步刷新,确保 RunTargets 返回前不会提前释放连接。 + refresh sync.WaitGroup } type targetState struct { @@ -128,6 +132,8 @@ func (r *Runner) RunTargets(ctx context.Context, accounts []Account, targets []T } state := newRunState(accounts, len(targets)) + // 成功后的刷新是异步的,等它们结束再返回,避免调用方提前关闭连接。 + defer state.refresh.Wait() for loop := 1; loop <= cfg.Loops; loop++ { if ctx.Err() != nil { return ctx.Err() @@ -223,12 +229,20 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, for _, index := range active { target := targets[index] if shouldDirectExchange(target, cfg) { + // 直兑路径没有奖品列表,用 target.PID 判断是否已经兑换过同一奖品。 + if state.prizeExchanged(account.UID, int64(target.PID)) { + emitSkippedDuplicate(emit, loop, account.UID, index, target) + continue + } result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, nil) result = r.applyExchangeDecision(state, account, index, cfg, result, cancel) + if result.Category == CategorySuccess { + state.markPrizeExchanged(account.UID, int64(target.PID)) + } if emit != nil { emit(result) } - r.refreshAfterSuccess(client, account, target, index, loop, cfg, result, emit) + r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit) if ctx.Err() != nil { return } @@ -289,18 +303,42 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, } continue } + // 多个 target 可能命中同一个奖品(例如按不同名称匹配),已成功兑换过就跳过。 + if state.prizeExchanged(account.UID, prize.ID) { + emitSkippedDuplicate(emit, loop, account.UID, index, target) + continue + } result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, prize) result = r.applyExchangeDecision(state, account, index, cfg, result, cancel) + if result.Category == CategorySuccess { + state.markPrizeExchanged(account.UID, prize.ID) + } if emit != nil { emit(result) } - r.refreshAfterSuccess(client, account, target, index, loop, cfg, result, emit) + r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit) if ctx.Err() != nil { return } } } +// emitSkippedDuplicate 在跳过同一奖品的重复兑换时给出明确结果,便于界面观察。 +func emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, target Target) { + if emit == nil { + return + } + emit(Result{ + Loop: loop, + UID: uid, + TargetIndex: index, + Target: target, + Action: "skip_duplicate", + Category: CategoryAlreadyDone, + Message: "该奖品本账号已兑换成功,跳过重复兑换", + }) +} + func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, account Account, target Target, index int, loop int, sid int32, prize *huya.PrizeInfo) Result { pid := target.PID if prize != nil { @@ -365,11 +403,13 @@ func (r *Runner) applyExchangeDecision(state *runState, account Account, index i return result } -func (r *Runner) refreshAfterSuccess(client huya.ActivityClient, account Account, target Target, index int, loop int, cfg Config, result Result, emit func(Result)) { +func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient, account Account, target Target, index int, loop int, cfg Config, result Result, emit func(Result)) { if !cfg.RefreshAfterSuccess || emit == nil || result.Category != CategorySuccess { return } + state.refresh.Add(1) go func() { + defer state.refresh.Done() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -447,6 +487,7 @@ func newRunState(accounts []Account, targetCount int) *runState { state := &runState{ accounts: make(map[int64][]targetState, len(accounts)), targetDone: make([]bool, targetCount), + exchanged: make(map[int64]map[int64]bool, len(accounts)), } for _, account := range accounts { state.accounts[account.UID] = make([]targetState, targetCount) @@ -454,6 +495,28 @@ func newRunState(accounts []Account, targetCount int) *runState { return state } +// prizeExchanged 判断该账号是否已经成功兑换过这个奖品。 +func (s *runState) prizeExchanged(uid, prizeID int64) bool { + if prizeID <= 0 { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.exchanged[uid][prizeID] +} + +func (s *runState) markPrizeExchanged(uid, prizeID int64) { + if prizeID <= 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.exchanged[uid] == nil { + s.exchanged[uid] = map[int64]bool{} + } + s.exchanged[uid][prizeID] = true +} + func (s *runState) activeTargets(uid int64, targetCount int, now time.Time) []int { s.mu.Lock() defer s.mu.Unlock() @@ -545,19 +608,37 @@ func normalizeConfig(cfg Config) Config { return cfg } +// normalizeTargets 去掉空目标与重复目标。 +// +// 除了完全相同的 target,还按 pid / 名称去重:界面选品会同时提交 pid、名称, +// 以及 targetText 里的同一个 pid,不去重就会对同一奖品重复兑换(实测踩过)。 func normalizeTargets(targets []Target) []Target { out := make([]Target, 0, len(targets)) - seen := map[string]bool{} + seenKey := map[string]bool{} + seenPID := map[int32]bool{} + seenName := map[string]bool{} for _, target := range targets { target.Name = strings.TrimSpace(target.Name) if target.PID <= 0 && target.Name == "" && !target.Auto { continue } - key := targetKey(target) - if seen[key] { + if target.PID > 0 && seenPID[target.PID] { continue } - seen[key] = true + if target.Name != "" && seenName[target.Name] { + continue + } + key := targetKey(target) + if seenKey[key] { + continue + } + seenKey[key] = true + if target.PID > 0 { + seenPID[target.PID] = true + } + if target.Name != "" { + seenName[target.Name] = true + } out = append(out, target) } return out diff --git a/internal/sniper/sniper_test.go b/internal/sniper/sniper_test.go index 79436bc..a34a827 100644 --- a/internal/sniper/sniper_test.go +++ b/internal/sniper/sniper_test.go @@ -317,3 +317,96 @@ func TestRunTargetsConditionGateKeepsRetrying(t *testing.T) { t.Fatalf("exchangeCount=%d, want 3", exchangeCount) } } + +func TestNormalizeTargetsDropsDuplicatePrizeTargets(t *testing.T) { + targets := normalizeTargets([]Target{ + {PID: 13409, Name: "幸运币礼包(小)"}, + {PID: 13409}, + {Name: "幸运币礼包(小)"}, + {PID: 12889, Name: "套装-潮萌小黄鸭"}, + }) + if len(targets) != 2 { + t.Fatalf("targets=%d, want 2: %+v", len(targets), targets) + } + if targets[0].PID != 13409 || targets[1].PID != 12889 { + t.Fatalf("unexpected targets: %+v", targets) + } +} + +func TestRunTargetsExchangesPrizeOnceWhenTargetsDuplicated(t *testing.T) { + client := &fakeActivityClient{} + runner := Runner{Client: client} + + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{ + {PID: 13409, Name: "幸运币礼包(小)"}, + {PID: 13409}, + }, Config{ + SID: 2277, + Loops: 3, + Delay: time.Millisecond, + StopAccountAfterSuccess: true, + }, nil) + if err != nil { + t.Fatal(err) + } + _, exchangeCount, _ := client.counts() + if exchangeCount != 1 { + t.Fatalf("exchangeCount=%d, want 1(同一奖品不应重复兑换)", exchangeCount) + } +} + +func TestRunTargetsWaitsForRefreshBeforeReturn(t *testing.T) { + client := &fakeActivityClient{} + runner := Runner{Client: client} + + var mu sync.Mutex + seen := map[string]bool{} + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 13409}}, Config{ + SID: 2277, + Loops: 1, + StopAccountAfterSuccess: true, + RefreshAfterSuccess: true, + }, func(result Result) { + mu.Lock() + seen[result.Action] = true + mu.Unlock() + }) + if err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + if !seen["refresh_score"] || !seen["refresh_records"] { + t.Fatalf("RunTargets 返回时异步刷新还没结束: %v", seen) + } +} + +func TestRunTargetsDoesNotExchangeSamePrizeTwiceViaNameMatch(t *testing.T) { + client := &fakeActivityClient{ + listResp: &huya.GetActPrizeListResp{ + Status: 200, + Prizes: []huya.PrizeInfo{{ID: 13409, Name: "幸运币礼包(小)", NewScore: 20}}, + }, + } + runner := Runner{Client: client} + + // 两个不同名称的 target 都会匹配到同一个奖品。 + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{ + {Name: "幸运币"}, + {Name: "礼包"}, + }, Config{ + SID: 2277, + Loops: 2, + Delay: time.Millisecond, + DirectExchange: false, + StopAccountAfterSuccess: true, + }, nil) + if err != nil { + t.Fatal(err) + } + _, exchangeCount, _ := client.counts() + if exchangeCount != 1 { + t.Fatalf("exchangeCount=%d, want 1(同一奖品不应重复兑换)", exchangeCount) + } +}