diff --git a/README.md b/README.md index 82fe79f..6aab537 100644 --- a/README.md +++ b/README.md @@ -72,13 +72,27 @@ CLI 示例: ./huya-go -cmd monitor -sid 2203 -pid 12889 -cookie-file cookie.txt -transport http -at "2026-07-06 20:00:00" ``` +## 多开(多实例) + +同一台机器可以同时启动多个实例,互不干扰: + +- 启动时按顺序占用「实例槽位」并加独占锁:第 1 个进程是**默认实例**(沿用历史数据目录),第 2 个及以后分别是 `实例 2`、`实例 3`…,数据目录为 `<数据目录>/instances/instance-N`。 +- 每个实例独占自己的日志目录与 WebView2 用户数据目录,所以多开时日志不会交错,Windows 上也不会出现「第二个窗口开不出来」。 +- 界面本地存储(账号、设置、商品缓存)按实例分片,每个实例各存各的账号;标题栏会显示当前实例名。 +- 槽位锁在进程退出时自动释放;若锁目录不可写,会退回到「进程唯一」的临时实例目录(日志降级为仅内存),**绝不退回默认目录**,以免两个进程互相覆盖。 +- 环境变量:`HUYA_DATA_DIR` 覆盖数据根目录,`HUYA_LOG_DIR` 覆盖日志目录,`HUYA_LOG_LEVEL` 覆盖默认日志等级。 + ## 日志 -GUI 日志同时输出到界面和磁盘: +GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签),同时输出到界面与磁盘: -- 路径:`<系统用户配置目录>/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`,最旧的被丢弃。 -- 界面「日志」页签顶部显示当前路径,可一键打开日志目录或复制路径。 +- 路径:`<数据目录>/logs/huya-go_<实例标识>_<时间戳>.log`。macOS 数据目录为 `~/Library/Application Support/huya-go`,Windows 为 `%AppData%\huya-go`。 +- 每次启动创建独立文件(`O_EXCL` 抢占,重名自动加 `-2` 后缀),所以多开或同秒重启都不会写进同一个文件;文件开头写明实例名、数据目录与格式说明。 +- 单文件上限 5MB,超出后滚动为 `.1` ~ `.5`,最旧的被丢弃。 +- 每行格式:`时间 [分类][等级][账号] 正文`。逐条网络请求是 `debug` 级,默认不记录,可在界面「日志」页签打开「调试」开关,或 CLI 使用 `-log-level debug`。 +- 写盘是异步且队列有界:队列满时丢弃日志并在文件尾部与界面提示丢弃条数,避免磁盘 I/O 拖慢抢购链路。 +- 界面「日志」页签支持按等级筛选、按 ID 增量拉取、一键打开日志目录或复制路径。 +- GUI 启动失败(例如 WebView2 建不出环境)时,`<用户缓存目录>/huya-go/startup.log` 会记录原因。 ## 参数说明 @@ -94,7 +108,9 @@ GUI 日志同时输出到界面和磁盘: - `-timeout`:单次调用超时秒数。 - `-loops`、`-delay`、`-concurrency`:轮询次数、轮询间隔、并发账号数。 - `-stop-after-success`:`monitor` 中任一账号成功后是否停止。 -- `-v`:输出调试日志。 +- `-log-dir`:日志目录,默认使用应用数据目录下的 `logs`。 +- `-log-level`:日志最低等级,`debug`/`info`/`warn`/`error`,默认 `info`。 +- `-v`:输出调试日志(等价于 `-log-level debug`,并把网络日志同时打到 stderr)。 ## 已确认的小黄鸭参数 diff --git a/cmd/huya-go/main.go b/cmd/huya-go/main.go index 135ae60..6d8d376 100644 --- a/cmd/huya-go/main.go +++ b/cmd/huya-go/main.go @@ -14,9 +14,20 @@ import ( "time" "huya-go/internal/huya" + "huya-go/internal/instance" + "huya-go/internal/logger" "huya-go/internal/sniper" ) +// closeLogger 由 logger.Init 返回;os.Exit 不会执行 defer,退出前必须显式刷盘。 +var closeLogger func() + +const ( + categoryNet = "网络" + categoryMonitor = "监控" + categoryCLI = "CLI" +) + func main() { var ( cmd = flag.String("cmd", "list", "命令:list/score/records/exchange/monitor") @@ -47,10 +58,15 @@ func main() { refreshSuccess = flag.Bool("refresh-after-success", true, "monitor 成功后异步刷新积分和记录") freqBackoffMS = flag.Int("freq-backoff", 300, "操作频繁后的账号退避毫秒") errorBackoffMS = flag.Int("error-backoff", 300, "请求异常后的账号退避毫秒") - verbose = flag.Bool("v", false, "输出调试日志") + verbose = flag.Bool("v", false, "输出调试日志(同时打印到 stderr)") + logDir = flag.String("log-dir", "", "日志目录,默认使用应用数据目录下的 logs") + logLevel = flag.String("log-level", "info", "日志最低等级:debug/info/warn/error") ) flag.Parse() + initLogging(*logDir, *logLevel, *verbose) + defer flushLogs() + if *sid <= 0 { exitErr(errors.New("必须提供有效 -sid")) } @@ -68,11 +84,13 @@ func main() { accounts = append(accounts, sniper.Account{UID: uid, Cookie: ck}) } - logger := func(string) {} - if *verbose { - logger = func(msg string) { fmt.Fprintln(os.Stderr, msg) } + clientLogger := func(msg string) { + logger.LogEvent(categoryNet, logger.LevelDebug, "%s", msg) + if *verbose { + fmt.Fprintln(os.Stderr, msg) + } } - client, closeClient, err := huya.NewActivityClient(*transport, *wssHost, time.Duration(*timeoutSec)*time.Second, logger) + client, closeClient, err := huya.NewActivityClient(*transport, *wssHost, time.Duration(*timeoutSec)*time.Second, clientLogger) if err != nil { exitErr(err) } @@ -138,6 +156,8 @@ func main() { outMu.Lock() defer outMu.Unlock() _ = json.NewEncoder(os.Stdout).Encode(result) + logger.LogAccountEvent(categoryMonitor, sniper.LogLevelForCategory(result.Category), strconv.FormatInt(result.UID, 10), + "loop=%d action=%s category=%s msg=%s error=%s", result.Loop, result.Action, result.Category, result.Message, result.Error) } cfg := sniper.Config{ SID: int32(*sid), @@ -336,8 +356,40 @@ func availableScore(resp *huya.GetUserScoreResp) int64 { return resp.AvailableScore() } +// initLogging 初始化 CLI 日志:默认写入应用数据目录下的 logs,和 GUI 一样是 +// 结构化日志(带分类与账号标签),便于事后复盘。 +func initLogging(dir, level string, verbose bool) { + if strings.TrimSpace(dir) == "" { + dir = instance.DefaultLogDir() + } + logger.SetMinLevel(logger.LevelFromString(chooseLevel(level, verbose))) + store := logger.NewStore(1000) + closeFn, err := logger.Init(store, dir, "CLI", "cli", instance.DefaultDataDir()) + closeLogger = closeFn + if err != nil { + fmt.Fprintf(os.Stderr, "日志文件不可用,仅保留在内存: %v\n", err) + } +} + +func chooseLevel(level string, verbose bool) string { + if verbose { + return logger.LevelDebug + } + return level +} + +// flushLogs 把异步日志队列刷盘;必须在 os.Exit 之前调用。 +func flushLogs() { + if closeLogger != nil { + closeLogger() + closeLogger = nil + } +} + func exitErr(err error) { + logger.LogEvent(categoryCLI, logger.LevelError, "%v", err) fmt.Fprintln(os.Stderr, "错误:", err) + flushLogs() os.Exit(1) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b14272..4847854 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,22 +38,50 @@ import { import { DefaultConfig, Exchange, + GetInstanceInfo, GetLogFilePath, GetLogs, + GetLogStats, GetRecords, IsRunning, ListPrizes, OpenLogDir, PreviewAccounts, QueryScores, + SetLogLevel, StartMonitor, StopMonitor, } from '../wailsjs/go/gui/App' import { ClipboardGetText, ClipboardSetText, EventsOn } from '../wailsjs/runtime/runtime' -const storageKey = 'huya-go.gui.config.v2' -const cookieStorageKey = 'huya-go.gui.cookies.v1' -const prizeCacheStorageKey = 'huya-go.gui.prizes.v1' +const configStorageBase = 'huya-go.gui.config.v2' +const cookieStorageBase = 'huya-go.gui.cookies.v1' +const prizeCacheStorageBase = 'huya-go.gui.prizes.v1' + +// 界面本地存储保留的日志上限,与后端的增量拉取配合使用。 +const maxClientLogs = 1000 + +// 多开时按实例给本地存储分片:两个实例各存各的账号与配置,互不覆盖。 +let storageSuffix = '' + +function scopedKey(base: string): string { + return storageSuffix ? `${base}@${storageSuffix}` : base +} + +function applyStorageScope(instanceID: string) { + const id = (instanceID || '').trim() + storageSuffix = !id || id === 'default' ? '' : id +} + +// 日志等级筛选项(仅作用于界面显示,不影响后端记录)。 +const logFilterOptions = [ + { value: 'all', label: '全部等级' }, + { value: 'debug', label: 'debug' }, + { value: 'info', label: 'info' }, + { value: 'success', label: 'success' }, + { value: 'warn', label: 'warn' }, + { value: 'error', label: 'error' }, +] type UiConfig = { transport: string @@ -100,11 +128,30 @@ type CookieEntry = { } type LogEntry = { + id: number time: string level: string + category: string + account: string message: string } +type LogStats = { + level: string + filePath: string + dir: string + dropped: number + count: number +} + +type InstanceInfo = { + name: string + id: string + dataDir: string + logDir: string + logFile: string +} + type PrizeInfo = { id: number sid?: number @@ -356,19 +403,19 @@ function categoryTag(category?: string): ReactNode { function readSavedConfig(): Partial { try { - return JSON.parse(localStorage.getItem(storageKey) || '{}') as Partial + return JSON.parse(localStorage.getItem(scopedKey(configStorageBase)) || '{}') as Partial } catch { return {} } } function writeSavedConfigPatch(patch: Partial) { - localStorage.setItem(storageKey, JSON.stringify({ ...readSavedConfig(), ...patch })) + localStorage.setItem(scopedKey(configStorageBase), JSON.stringify({ ...readSavedConfig(), ...patch })) } function readSavedCookies(): CookieEntry[] { try { - const items = JSON.parse(localStorage.getItem(cookieStorageKey) || '[]') as Partial[] + const items = JSON.parse(localStorage.getItem(scopedKey(cookieStorageBase)) || '[]') as Partial[] return items .map((item, index) => ({ id: item.id || `ck-${Date.now()}-${index}`, @@ -385,12 +432,12 @@ function readSavedCookies(): CookieEntry[] { } function writeSavedCookies(items: CookieEntry[]) { - localStorage.setItem(cookieStorageKey, JSON.stringify(items)) + localStorage.setItem(scopedKey(cookieStorageBase), JSON.stringify(items)) } function readSavedPrizeCache(): PrizeCache { try { - const cache = JSON.parse(localStorage.getItem(prizeCacheStorageKey) || '{}') as Partial + const cache = JSON.parse(localStorage.getItem(scopedKey(prizeCacheStorageBase)) || '{}') as Partial const prizes = Array.isArray(cache.prizes) ? cache.prizes.filter((item) => Number(item?.id || 0) > 0 && String(item?.name || '').trim()) : [] @@ -406,7 +453,7 @@ function readSavedPrizeCache(): PrizeCache { function writeSavedPrizeCache(prizes: PrizeInfo[], sid: number) { localStorage.setItem( - prizeCacheStorageKey, + scopedKey(prizeCacheStorageBase), JSON.stringify({ prizes, sid, @@ -480,6 +527,16 @@ function normalizeMonitorResult(row: MonitorResult): MonitorResult { } } +// mergeLogs 按自增 ID 合并日志:实时事件与初始拉取可能交错,按 ID 去重并保持顺序。 +function mergeLogs(prev: LogEntry[], incoming: LogEntry[]): LogEntry[] { + if (incoming.length === 0) return prev + const lastID = prev.length > 0 ? prev[prev.length - 1].id : 0 + const fresh = incoming.filter((item) => Number(item.id || 0) > lastID) + if (fresh.length === 0) return prev + fresh.sort((a, b) => a.id - b.id) + return [...prev, ...fresh].slice(-maxClientLogs) +} + export default function Dashboard() { const { message, modal } = AntApp.useApp() const [form] = Form.useForm() @@ -511,6 +568,10 @@ export default function Dashboard() { const [prizeCacheUpdatedAt, setPrizeCacheUpdatedAt] = useState(0) const [configLoaded, setConfigLoaded] = useState(false) const [logPath, setLogPath] = useState('') + const [logStats, setLogStats] = useState(null) + const [logFilter, setLogFilter] = useState('all') + const [debugLog, setDebugLog] = useState(false) + const [instanceInfo, setInstanceInfo] = useState(null) const applyCookieEntries = async (entries: CookieEntry[], persist = true, refreshLatest = false) => { const normalizedEntries = entries @@ -555,9 +616,16 @@ export default function Dashboard() { useEffect(() => { let mounted = true - Promise.all([DefaultConfig(), GetLogs(), IsRunning(), GetLogFilePath()]) - .then(([defaults, logItems, isRunning, savedLogPath]) => { + Promise.all([DefaultConfig(), GetLogs(0), IsRunning(), GetLogFilePath(), GetInstanceInfo(), GetLogStats()]) + .then(([defaults, logItems, isRunning, savedLogPath, info, stats]) => { if (!mounted) return + const instance = info as InstanceInfo | null + // 本地存储必须按实例分片后再读取,否则多开时两个实例会共用同一份账号与配置。 + applyStorageScope(instance?.id || '') + setInstanceInfo(instance) + setLogStats((stats || null) as LogStats | null) + setDebugLog(String((stats as LogStats | null)?.level || '') === 'debug') + const savedCookies = readSavedCookies() const savedPrizeCache = readSavedPrizeCache() form.setFieldsValue({ @@ -568,8 +636,8 @@ export default function Dashboard() { setCookieEntries(savedCookies) setPrizes(savedPrizeCache.prizes) setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt) - setLogs((logItems || []) as LogEntry[]) - setLogPath(String(savedLogPath || '')) + setLogs((prev) => mergeLogs(prev, (logItems || []) as LogEntry[])) + setLogPath(String(savedLogPath || instance?.logFile || '')) setRunning(Boolean(isRunning)) setConfigLoaded(true) if (savedCookies.length > 0) { @@ -579,7 +647,7 @@ export default function Dashboard() { .catch((err: unknown) => message.error(String(err))) const offLog = EventsOn('gui:log', (entry: LogEntry) => { - setLogs((prev) => [...prev.slice(-499), entry]) + setLogs((prev) => mergeLogs(prev, [entry])) }) const offResult = EventsOn('gui:monitor-result', (result: MonitorResult) => { setResults((prev) => [normalizeMonitorResult(result), ...prev].slice(0, 300)) @@ -716,6 +784,12 @@ export default function Dashboard() { }, [prizes, productSearch]) const inventorySummary = useMemo(() => summarizeInventory(filteredPrizes), [filteredPrizes]) + // 日志筛选只在界面上生效;后端的 debug 开关由「调试日志」按钮控制。 + const visibleLogs = useMemo( + () => (logFilter === 'all' ? logs : logs.filter((item) => item.level === logFilter)), + [logs, logFilter], + ) + const config = () => ({ ...pickConfig(form.getFieldsValue(true)), selectedAccountLines: selectedAccountLines.map(Number).filter((line) => line > 0), @@ -904,7 +978,7 @@ export default function Dashboard() { cancelText: '取消', okButtonProps: { danger: true }, onOk: () => { - localStorage.removeItem(prizeCacheStorageKey) + localStorage.removeItem(scopedKey(prizeCacheStorageBase)) setPrizes([]) setSelectedPrizeKeys([]) setPrizeCacheUpdatedAt(0) @@ -940,7 +1014,7 @@ export default function Dashboard() { timeoutSec: values.timeoutSec, startAt: values.startAt, } - localStorage.setItem(storageKey, JSON.stringify(saved)) + localStorage.setItem(scopedKey(configStorageBase), JSON.stringify(saved)) message.success('已保存') } @@ -1014,6 +1088,14 @@ export default function Dashboard() { message.success('已复制日志路径') } + // 调试日志开关:打开后后端会把逐条网络请求也写进日志(debug 级)。 + const toggleDebugLog = async (checked: boolean) => { + const level = await SetLogLevel(checked ? 'debug' : 'info') + setDebugLog(level === 'debug') + setLogStats((prev) => (prev ? { ...prev, level } : prev)) + message.success(level === 'debug' ? '已开启 debug 日志(含逐条网络请求)' : '已恢复 info 日志') + } + const selectAllAccounts = () => { setSelectedAccountLines(validAccounts.map((item) => item.line)) } @@ -1258,6 +1340,7 @@ export default function Dashboard() {
+ {instanceInfo ? `${instanceInfo.name} · ` : ''} {logPath || '日志文件不可用'} @@ -1274,7 +1357,31 @@ export default function Dashboard() {
- +
+