GUI 日志落盘,修复同一奖品重复兑换与监控结束竞态
- 日志落盘:新增 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 补日志说明
This commit is contained in:
@@ -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`。
|
||||
|
||||
+42
-5
@@ -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<CookieEntry[]>([])
|
||||
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: <LogView logs={logs} />,
|
||||
children: (
|
||||
<div className="log-pane">
|
||||
<div className="log-tools">
|
||||
<span className="log-path" title={logPath || '日志文件不可用'}>
|
||||
{logPath || '日志文件不可用'}
|
||||
</span>
|
||||
<Space size={6}>
|
||||
<Tooltip title="打开日志目录">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FolderOpen size={14} />}
|
||||
disabled={!logPath}
|
||||
onClick={openLogDir}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="复制日志路径">
|
||||
<Button size="small" icon={<Copy size={14} />} disabled={!logPath} onClick={copyLogPath} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
<LogView logs={logs} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'results',
|
||||
|
||||
+27
-1
@@ -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;
|
||||
|
||||
Vendored
+4
@@ -6,6 +6,8 @@ export function DefaultConfig():Promise<gui.Config>;
|
||||
|
||||
export function Exchange(arg1:gui.Config):Promise<Array<gui.ExchangeResult>>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
|
||||
export function GetLogs():Promise<Array<gui.LogEntry>>;
|
||||
|
||||
export function GetRecords(arg1:gui.Config):Promise<Array<gui.RecordResult>>;
|
||||
@@ -14,6 +16,8 @@ export function IsRunning():Promise<boolean>;
|
||||
|
||||
export function ListPrizes(arg1:gui.Config):Promise<gui.PrizeListResult>;
|
||||
|
||||
export function OpenLogDir():Promise<void>;
|
||||
|
||||
export function PreviewAccounts(arg1:string):Promise<Array<gui.AccountPreview>>;
|
||||
|
||||
export function QueryScores(arg1:gui.Config):Promise<Array<gui.ScoreResult>>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+84
-3
@@ -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) {
|
||||
|
||||
@@ -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 在系统用户配置目录下创建 <config>/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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ const (
|
||||
|
||||
// DefaultDNSTTL 是响应没有 ttl 时的兜底缓存时间。
|
||||
DefaultDNSTTL = 10 * time.Minute
|
||||
// dnsFailureCooldown 是 queryHttpDns 失败后的冷却时间。
|
||||
dnsFailureCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
// QueryHttpDnsReq 对应浏览器 launch.queryHttpDns 的 tReq。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user