新增实例槽位隔离与结构化日志,多开不再共用日志与账号

This commit is contained in:
yml2213
2026-10-14 21:32:49 +08:00
parent f96fa7d858
commit 8c4b15066f
27 changed files with 2011 additions and 468 deletions
+21 -5
View File
@@ -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)。
## 已确认的小黄鸭参数
+57 -5
View File
@@ -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)
}
+136 -20
View File
@@ -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<SavedConfig> {
try {
return JSON.parse(localStorage.getItem(storageKey) || '{}') as Partial<SavedConfig>
return JSON.parse(localStorage.getItem(scopedKey(configStorageBase)) || '{}') as Partial<SavedConfig>
} catch {
return {}
}
}
function writeSavedConfigPatch(patch: Partial<SavedConfig>) {
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<CookieEntry>[]
const items = JSON.parse(localStorage.getItem(scopedKey(cookieStorageBase)) || '[]') as Partial<CookieEntry>[]
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<PrizeCache>
const cache = JSON.parse(localStorage.getItem(scopedKey(prizeCacheStorageBase)) || '{}') as Partial<PrizeCache>
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<UiConfig>()
@@ -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<LogStats | null>(null)
const [logFilter, setLogFilter] = useState('all')
const [debugLog, setDebugLog] = useState(false)
const [instanceInfo, setInstanceInfo] = useState<InstanceInfo | null>(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() {
<div className="log-pane">
<div className="log-tools">
<span className="log-path" title={logPath || '日志文件不可用'}>
{instanceInfo ? `${instanceInfo.name} · ` : ''}
{logPath || '日志文件不可用'}
</span>
<Space size={6}>
@@ -1274,7 +1357,31 @@ export default function Dashboard() {
</Tooltip>
</Space>
</div>
<LogView logs={logs} />
<div className="log-tools">
<Select
size="small"
value={logFilter}
style={{ width: 116 }}
onChange={setLogFilter}
options={logFilterOptions}
/>
<Space size={8}>
<Tooltip title="开启后记录逐条网络请求(debug 级)">
<Switch
size="small"
checked={debugLog}
checkedChildren="调试"
unCheckedChildren="常规"
onChange={toggleDebugLog}
/>
</Tooltip>
<span className="log-path">共 {logs.length} 条</span>
</Space>
</div>
{logStats && logStats.dropped > 0 ? (
<div className="log-path">日志队列已丢弃 {logStats.dropped} 条(磁盘写入压力过大)</div>
) : null}
<LogView logs={visibleLogs} />
</div>
),
},
@@ -1301,6 +1408,7 @@ export default function Dashboard() {
<Statistic title="异常事件" value={errorCount} />
<Statistic title="待处理账号" value={pendingCount} />
<Statistic title="日志条数" value={logs.length} />
<Statistic title="丢弃日志" value={logStats?.dropped || 0} />
</div>
),
},
@@ -1401,6 +1509,11 @@ export default function Dashboard() {
</div>
<CountdownBadge countdown={scheduleCountdown} now={now} running={running} />
<Space size={12}>
<Tooltip
title={instanceInfo?.dataDir ? `实例数据目录: ${instanceInfo.dataDir}` : '实例信息未就绪'}
>
<Tag color="blue">{instanceInfo?.name || '默认实例'}</Tag>
</Tooltip>
<Tag>{String(watchedTransport || 'http').toUpperCase()}</Tag>
<Button icon={<Settings size={16} />} onClick={() => setSettingsOpen(true)}>
设置
@@ -1923,11 +2036,14 @@ function padNumber(value: number, size = 2): string {
function LogView({ logs }: { logs: LogEntry[] }) {
return (
<div className="log-view">
{logs.map((item, index) => (
<div className={`log-line level-${item.level}`} key={`${item.time}-${index}`}>
{logs.map((item) => (
<div className={`log-line level-${item.level}`} key={item.id}>
<span>{item.time}</span>
<Tag>{item.level}</Tag>
<code>{item.message}</code>
<code>
{item.category ? `[${item.category}]` : ''}
{item.account ? `[账号:${item.account}]` : ''} {item.message}
</code>
</div>
))}
</div>
+8 -1
View File
@@ -1,14 +1,19 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {gui} from '../models';
import {logger} from '../models';
export function DefaultConfig():Promise<gui.Config>;
export function Exchange(arg1:gui.Config):Promise<Array<gui.ExchangeResult>>;
export function GetInstanceInfo():Promise<gui.InstanceInfo>;
export function GetLogFilePath():Promise<string>;
export function GetLogs():Promise<Array<gui.LogEntry>>;
export function GetLogStats():Promise<gui.LogStats>;
export function GetLogs(arg1:number):Promise<Array<logger.Entry>>;
export function GetRecords(arg1:gui.Config):Promise<Array<gui.RecordResult>>;
@@ -22,6 +27,8 @@ export function PreviewAccounts(arg1:string):Promise<Array<gui.AccountPreview>>;
export function QueryScores(arg1:gui.Config):Promise<Array<gui.ScoreResult>>;
export function SetLogLevel(arg1:string):Promise<string>;
export function StartMonitor(arg1:gui.Config):Promise<void>;
export function StopMonitor():Promise<boolean>;
+14 -2
View File
@@ -10,12 +10,20 @@ export function Exchange(arg1) {
return window['go']['gui']['App']['Exchange'](arg1);
}
export function GetInstanceInfo() {
return window['go']['gui']['App']['GetInstanceInfo']();
}
export function GetLogFilePath() {
return window['go']['gui']['App']['GetLogFilePath']();
}
export function GetLogs() {
return window['go']['gui']['App']['GetLogs']();
export function GetLogStats() {
return window['go']['gui']['App']['GetLogStats']();
}
export function GetLogs(arg1) {
return window['go']['gui']['App']['GetLogs'](arg1);
}
export function GetRecords(arg1) {
@@ -42,6 +50,10 @@ export function QueryScores(arg1) {
return window['go']['gui']['App']['QueryScores'](arg1);
}
export function SetLogLevel(arg1) {
return window['go']['gui']['App']['SetLogLevel'](arg1);
}
export function StartMonitor(arg1) {
return window['go']['gui']['App']['StartMonitor'](arg1);
}
+58 -7
View File
@@ -116,20 +116,44 @@ export namespace gui {
return a;
}
}
export class LogEntry {
time: string;
level: string;
message: string;
export class InstanceInfo {
name: string;
id: string;
dataDir: string;
logDir: string;
logFile: string;
static createFrom(source: any = {}) {
return new LogEntry(source);
return new InstanceInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.id = source["id"];
this.dataDir = source["dataDir"];
this.logDir = source["logDir"];
this.logFile = source["logFile"];
}
}
export class LogStats {
level: string;
filePath: string;
dir: string;
dropped: number;
count: number;
static createFrom(source: any = {}) {
return new LogStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.time = source["time"];
this.level = source["level"];
this.message = source["message"];
this.filePath = source["filePath"];
this.dir = source["dir"];
this.dropped = source["dropped"];
this.count = source["count"];
}
}
export class PrizeListResult {
@@ -530,3 +554,30 @@ export namespace huya {
}
export namespace logger {
export class Entry {
id: number;
time: string;
level: string;
category: string;
account: string;
message: string;
static createFrom(source: any = {}) {
return new Entry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.time = source["time"];
this.level = source["level"];
this.category = source["category"];
this.account = source["account"];
this.message = source["message"];
}
}
}
View File
Vendored Regular → Executable
View File
View File
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.26
require (
github.com/gorilla/websocket v1.5.3
github.com/wailsapp/wails/v2 v2.15.0
golang.org/x/sys v0.46.0
)
require (
@@ -33,6 +34,5 @@ require (
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
)
+158 -173
View File
@@ -7,12 +7,13 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"huya-go/internal/huya"
"huya-go/internal/instance"
"huya-go/internal/logger"
"huya-go/internal/sniper"
)
@@ -20,25 +21,31 @@ const (
eventLog = "gui:log"
eventMonitorResult = "gui:monitor-result"
eventMonitorState = "gui:monitor-state"
maxLogEntries = 500
logQueueSize = 2048
// maxLogEntries 内存里保留的日志条数,界面按自增 ID 增量拉取。
maxLogEntries = 2000
// 日志分类,便于在界面上区分应用、网络、监控与账号维度的日志。
categoryApp = "应用"
categoryNet = "网络"
categoryMonitor = "监控"
)
type App struct {
ctx context.Context
logMu sync.Mutex
logs []LogEntry
// logStore 是内存日志缓冲;界面通过 GetLogs(sinceID) 增量拉取。
logStore *logger.Store
// closeLog 由 logger.Init 返回,用于在退出时把异步队列刷盘。
closeLog func()
logInitErr error
logCh chan LogEntry
logStop chan struct{}
logWorkerOnce sync.Once
logStopOnce sync.Once
droppedLogs atomic.Int64
// logSink 为 nil 表示当前环境无法写日志文件,日志只留在内存与界面上。
logSink *logSink
logSinkErr error
logWriteFailures atomic.Int64
// instanceLease 在 wails.Run 之前由 main 占用并注入(WebView2 目录需要提前确定)。
instanceLease *instance.Lease
instanceErr error
instanceName string
instanceID string
dataDir string
runMu sync.Mutex
monitorCancel context.CancelFunc
@@ -80,13 +87,22 @@ type AccountPreview struct {
Error string `json:"error,omitempty"`
}
type LogEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
// LogStats 描述当前日志运行时状态,供界面展示与排查。
type LogStats struct {
Level string `json:"level"`
FilePath string `json:"filePath"`
Dir string `json:"dir"`
Dropped uint64 `json:"dropped"`
Count int `json:"count"`
}
// at 只用于落盘,保证文件里带完整日期(不参与 JSON 序列化)。
at time.Time
// InstanceInfo 描述当前进程占用的实例,供界面展示与本地存储分片。
type InstanceInfo struct {
Name string `json:"name"`
ID string `json:"id"`
DataDir string `json:"dataDir"`
LogDir string `json:"logDir"`
LogFile string `json:"logFile"`
}
type PrizeListResult struct {
@@ -129,58 +145,140 @@ type cookieLine struct {
text string
}
func NewApp() *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
// NewApp 创建 App 实例。lease 由 main 在 wails.Run 之前占用并注入。
//
// 为什么要在 wails.Run 之前占位:WebView2 的用户数据目录必须在创建窗口之前确定,
// 而它必须按实例区分(见 instance.Lease.WebviewUserDataDir)。
// 用构造器参数而不是导出的 setter,是为了避免它被 Wails 当成前端可调用方法生成绑定。
func NewApp(lease *instance.Lease, instanceErr error) *App {
return &App{instanceLease: lease, instanceErr: instanceErr}
}
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)
lease, leaseErr := a.instanceLease, a.instanceErr
if lease == nil {
// 其它入口(测试、非 Wails 启动)没有提前占位时在这里补一次。
lease, leaseErr = instance.Acquire()
}
logDir := ""
if lease != nil {
a.instanceLease = lease
a.instanceName = lease.Name()
a.instanceID = lease.ID()
a.dataDir = lease.Paths().DataDir
logDir = lease.Paths().LogDir
}
if a.instanceName == "" {
a.instanceName = "默认实例"
}
if a.instanceID == "" {
a.instanceID = "default"
}
a.logStore = logger.NewStore(maxLogEntries)
// 日志先注册界面推送,再初始化文件输出,这样启动阶段的日志也能实时到达界面。
logger.SetEmitter(func(entry logger.Entry) {
a.emit(eventLog, entry)
})
closeLog, logErr := logger.Init(a.logStore, logDir, a.instanceName, a.instanceID, a.dataDir)
a.closeLog = closeLog
a.logInitErr = logErr
if leaseErr != nil {
// 数据目录仍然是本实例独享的(临时实例),但隔离能力已降级,必须让人看见。
logger.LogEvent("系统", logger.LevelWarn, "实例隔离已降级(%s,数据目录仍独立): %v", a.instanceName, leaseErr)
} else {
logger.LogEvent("系统", logger.LevelSuccess, "已启动%s,数据目录: %s", a.instanceName, a.dataDir)
}
if logErr != nil {
logger.LogEvent(categoryApp, logger.LevelWarn, "日志文件不可用,日志仅保留在界面: %v", logErr)
}
}
// GetLogFilePath 返回当前日志文件路径(供界面展示/复制)。
func (a *App) GetLogFilePath() string {
if a.logSink == nil {
return ""
return logger.FilePath()
}
// GetLogs 返回自增 ID 大于 sinceID 的日志,界面据此增量拉取。
func (a *App) GetLogs(sinceID int64) []logger.Entry {
if a.logStore == nil {
return []logger.Entry{}
}
return a.logSink.Path()
return a.logStore.Since(sinceID)
}
// GetLogStats 返回日志运行时状态(等级、文件、丢弃条数)。
func (a *App) GetLogStats() LogStats {
return LogStats{
Level: logger.MinLevel(),
FilePath: logger.FilePath(),
Dir: logger.Dir(),
Dropped: logger.Dropped(),
Count: a.logStoreCount(),
}
}
// SetLogLevel 调整日志最低输出等级,返回归一化后的等级。
func (a *App) SetLogLevel(level string) string {
normalized := logger.LevelFromString(level)
logger.SetMinLevel(normalized)
logger.LogEvent(categoryApp, logger.LevelSuccess, "日志等级已切换为 %s", normalized)
return normalized
}
// GetInstanceInfo 返回当前实例信息(名称、标识、数据与日志路径)。
func (a *App) GetInstanceInfo() InstanceInfo {
name := a.instanceName
if name == "" {
name = "默认实例"
}
id := a.instanceID
if id == "" {
id = "default"
}
info := InstanceInfo{Name: name, ID: id, DataDir: a.dataDir, LogFile: logger.FilePath()}
if a.instanceLease != nil {
info.LogDir = a.instanceLease.Paths().LogDir
}
return info
}
// OpenLogDir 用系统文件管理器打开日志目录。
func (a *App) OpenLogDir() error {
if a.logSink == nil {
if a.logSinkErr != nil {
return fmt.Errorf("日志目录不可用: %w", a.logSinkErr)
dir := logger.Dir()
if dir == "" {
if a.logInitErr != nil {
return fmt.Errorf("日志目录不可用: %w", a.logInitErr)
}
return errors.New("日志目录不可用")
}
if err := openDirectory(a.logSink.Dir()); err != nil {
if err := openDirectory(dir); err != nil {
return fmt.Errorf("打开日志目录失败: %w", err)
}
return nil
}
func (a *App) logStoreCount() int {
if a.logStore == nil {
return 0
}
return a.logStore.Count()
}
func (a *App) Shutdown(ctx context.Context) {
a.StopMonitor()
a.stopLogWorker()
a.closeLogSink()
logger.SetEmitter(nil)
if a.closeLog != nil {
a.closeLog()
a.closeLog = nil
}
if a.instanceLease != nil {
a.instanceLease.Release()
a.instanceLease = nil
}
}
func (a *App) DefaultConfig() Config {
@@ -218,12 +316,6 @@ func (a *App) PreviewAccounts(cookieText string) []AccountPreview {
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()
@@ -400,7 +492,8 @@ func (a *App) StartMonitor(cfg Config) error {
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)
a.logAccount(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)
}
runnerCfg := sniper.Config{
SID: cfg.SID,
@@ -451,9 +544,10 @@ func (a *App) StopMonitor() bool {
return true
}
// newClient 创建活动客户端,并把逐条网络交互接到 debug 级日志。
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)
a.logEvent(categoryNet, logger.LevelDebug, "%s", msg)
})
}
@@ -484,128 +578,19 @@ func (a *App) finishMonitor(message string) {
a.emitState(false, message)
}
// addLog 记录一条应用级日志。
func (a *App) addLog(level, format string, args ...any) {
now := time.Now()
entry := LogEntry{
Time: now.Format("15:04:05.000"),
at: now,
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)
}
logger.LogEvent(categoryApp, level, format, args...)
}
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()
})
// logEvent 记录一条带分类的日志。
func (a *App) logEvent(category, level, format string, args ...any) {
logger.LogEvent(category, level, format, args...)
}
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
}
now := time.Now()
a.writeLogEntry(LogEntry{
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 {
a.logs = append([]LogEntry(nil), a.logs[len(a.logs)-maxLogEntries:]...)
}
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()
// logAccount 记录一条带账号标签的日志,便于界面按账号观察抢购链路。
func (a *App) logAccount(category, level, account string, format string, args ...any) {
logger.LogAccountEvent(category, level, account, format, args...)
}
func (a *App) emitState(running bool, message string) {
+24 -15
View File
@@ -3,6 +3,8 @@ package gui
import (
"testing"
"time"
"huya-go/internal/logger"
)
func TestSplitCookieLinesNormalizesLineBreaks(t *testing.T) {
@@ -78,24 +80,31 @@ func TestParseStartTimeAcceptsClockOnly(t *testing.T) {
})
}
func TestAddLogDoesNotBlockWhenQueueIsFull(t *testing.T) {
app := &App{
logCh: make(chan LogEntry),
logStop: make(chan struct{}),
func TestSetLogLevelNormalizesAndFilters(t *testing.T) {
dir := t.TempDir()
store := logger.NewStore(20)
closeLog, err := logger.Init(store, dir, "测试实例", "test", dir)
if err != nil {
t.Fatalf("logger.Init() error = %v", err)
}
done := make(chan struct{})
go func() {
app.addLog("debug", "test")
close(done)
}()
defer closeLog()
defer logger.SetMinLevel(logger.LevelInfo)
select {
case <-done:
case <-time.After(100 * time.Millisecond):
t.Fatal("addLog blocked when log queue was full")
app := &App{logStore: store}
if got := app.SetLogLevel("这不是合法等级"); got != logger.LevelInfo {
t.Fatalf("非法等级应回退为 info, got %q", got)
}
if dropped := app.droppedLogs.Load(); dropped != 1 {
t.Fatalf("droppedLogs = %d, want 1", dropped)
if got := app.SetLogLevel("debug"); got != logger.LevelDebug {
t.Fatalf("SetLogLevel(debug) = %q", got)
}
logger.LogEvent("网络", logger.LevelDebug, "debug 应被保留")
app.SetLogLevel("error")
logger.LogEvent("网络", logger.LevelDebug, "debug 应被过滤")
for _, entry := range store.Since(0) {
if entry.Message == "debug 应被过滤" {
t.Fatal("低于最低等级的日志不应进入内存缓冲")
}
}
}
+1 -126
View File
@@ -1,136 +1,11 @@
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 调用系统文件管理器打开目录。
// openDirectory 调用系统文件管理器打开目录,供日志面板「打开日志目录」使用。
func openDirectory(dir string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
+57 -108
View File
@@ -1,138 +1,87 @@
package gui
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"huya-go/internal/logger"
)
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) {
// TestAppLogsReachMemoryAndFile 验证 GUI 的日志入口同时进入内存缓冲与实例日志文件,
// 且账号维度日志带上账号标签。
func TestAppLogsReachMemoryAndFile(t *testing.T) {
dir := t.TempDir()
sink, err := newLogSinkAt(dir)
store := logger.NewStore(50)
closeLog, err := logger.Init(store, dir, "测试实例", "test", dir)
if err != nil {
t.Fatal(err)
t.Fatalf("logger.Init() error = %v", 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)
}
path := logger.FilePath()
if path == "" {
t.Fatal("日志文件路径为空")
}
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())
}
}
}
app := &App{logStore: store}
app.addLog("info", "日志文件已就绪")
app.logAccount("监控", "success", "12345", "兑换成功 pid=%d", 9)
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)
}
// 关闭日志(同时把异步队列刷盘)后再读文件。
closeLog()
data, err := os.ReadFile(reopened.Path())
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
t.Fatalf("读取日志文件失败: %v", err)
}
text := string(data)
if !strings.Contains(text, "第一条") || !strings.Contains(text, "第二条") {
t.Fatalf("追加写入失败: %q", text)
for _, want := range []string{"实例: 测试实例", "日志文件已就绪", "[账号:12345]", "兑换成功 pid=9"} {
if !strings.Contains(text, want) {
t.Fatalf("日志文件缺少 %q:\n%s", want, text)
}
}
if strings.Index(text, "第一条") > strings.Index(text, "第二条") {
t.Fatalf("日志顺序异常: %q", text)
if !strings.Contains(text, "[info]") || !strings.Contains(text, "[success]") {
t.Fatalf("日志级别未写入: %s", text)
}
entries := store.Since(0)
if len(entries) < 3 {
t.Fatalf("内存日志条数 = %d, want >= 3", len(entries))
}
if entries[len(entries)-1].Account != "12345" {
t.Fatalf("最后一条日志账号标签 = %q, want 12345", entries[len(entries)-1].Account)
}
}
func TestAppWritesLogsToFile(t *testing.T) {
sink, err := newLogSinkAt(t.TempDir())
// TestGetLogsReturnsIncrementally 验证界面按自增 ID 增量拉取日志。
func TestGetLogsReturnsIncrementally(t *testing.T) {
dir := t.TempDir()
store := logger.NewStore(50)
closeLog, err := logger.Init(store, dir, "测试实例", "test", dir)
if err != nil {
t.Fatal(err)
t.Fatalf("logger.Init() error = %v", err)
}
defer sink.Close()
defer closeLog()
app := &App{logSink: sink}
app.addLog("info", "日志文件: %s", sink.Path())
app.addLog("debug", "[WSS] pingSignal 往返=28ms 服务端=17ms")
app := &App{logStore: store}
app.addLog("info", "第一条")
first := app.GetLogs(0)
if len(first) == 0 {
t.Fatal("首次拉取不应为空")
}
lastID := first[len(first)-1].ID
data, err := os.ReadFile(sink.Path())
if err != nil {
t.Fatal(err)
app.addLog("warn", "第二条")
// 只有写入后新增的日志应当返回,避免界面重复渲染历史日志。
incremental := app.GetLogs(lastID)
if len(incremental) != 1 {
t.Fatalf("增量拉取条数 = %d, want 1", len(incremental))
}
text := string(data)
if !strings.Contains(text, "日志文件: ") || !strings.Contains(text, "pingSignal") {
t.Fatalf("日志未落盘: %q", text)
if incremental[0].Message != "第二条" || incremental[0].Level != "warn" {
t.Fatalf("增量日志 = %+v", incremental[0])
}
if !strings.Contains(text, "[info]") || !strings.Contains(text, "[debug]") {
t.Fatalf("日志级别未写入: %q", text)
stats := app.GetLogStats()
if stats.Level == "" || stats.FilePath == "" || stats.Count < 2 {
t.Fatalf("日志统计异常: %+v", stats)
}
}
+236
View File
@@ -0,0 +1,236 @@
// Package instance 负责多开场景下的实例槽位分配与数据目录隔离。
//
// 每个进程启动时占用一个「实例槽位」:1 号槽位是默认实例(沿用历史数据目录),
// 2 号及以后各自拿到 instances/instance-N 目录,互不共享配置、账号与日志文件。
// 槽位通过独立的锁文件互斥,进程退出(或崩溃后文件句柄被系统回收)即可被下一个
// 进程复用。
package instance
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
const (
// appDirName 用户配置目录下的应用目录名。
appDirName = "huya-go"
// logSubDir 默认的日志子目录名。
logSubDir = "logs"
// maxSlots 单机允许同时运行的实例数上限。
maxSlots = 64
)
// 单次实例分配里允许的「意外错误」重试预算与间隔。
// 杀毒软件/索引器可能短暂独占刚创建的锁文件,稍后重试同一个槽位即可,
// 不该直接跳到别的槽位,也不该让整个分配失败。
const (
slotLockRetryBudget = 12
slotLockRetryWait = 80 * time.Millisecond
)
// Paths 是某个实例独占的运行路径。
type Paths struct {
// DataDir 实例数据目录,同时用作 WebView2 用户数据目录的父目录。
DataDir string
// LogDir 实例日志目录。
LogDir string
}
// Lease 代表当前进程持有的实例槽位;必须在程序退出时释放。
type Lease struct {
paths Paths
name string
id string
lock instanceLock
releaseOnce sync.Once
}
// Acquire 按顺序占用空闲实例。第一个进程使用默认实例,后续进程分别使用实例 2、
// 实例 3,以隔离数据目录与日志文件。
//
// 返回的 lease 一定可用:即使所有槽位都拿不到(锁目录不可写、槽位全部异常等),
// 也会退回到一个「进程唯一」的临时实例目录,并把原因作为 error 返回供上层记录。
// 绝不退回默认目录——那样两个进程会共享 localStorage 数据目录与日志文件,
// 导致账号互相覆盖与日志交错。
func Acquire() (*Lease, error) {
baseDir := baseDataDir()
instanceRoot := filepath.Join(baseDir, "instances")
lockDir := filepath.Join(instanceRoot, ".locks")
if err := osMkdirAll(lockDir); err != nil {
return fallbackLease(baseDir, fmt.Errorf("创建实例锁目录失败: %w", err))
}
var scanErr error
scanRetries := slotLockRetryBudget
for slot := 1; slot <= maxSlots; slot++ {
lockPath := filepath.Join(lockDir, instanceLockFileName(slot))
lock, acquired, err := tryAcquireInstanceLock(lockPath)
for err != nil && scanRetries > 0 {
scanRetries--
time.Sleep(slotLockRetryWait)
lock, acquired, err = tryAcquireInstanceLock(lockPath)
}
if err != nil {
// 单个槽位异常不该让整个分配失败:记录原因后继续试下一个槽位。
if scanErr == nil {
scanErr = fmt.Errorf("锁定%s失败: %w", instanceName(slot), err)
}
continue
}
if !acquired {
continue
}
dataDir := baseDir
if slot > 1 {
dataDir = filepath.Join(instanceRoot, instanceID(slot))
}
if err := osMkdirAll(dataDir); err != nil {
_ = lock.Close()
if scanErr == nil {
scanErr = fmt.Errorf("创建%s数据目录失败: %w", instanceName(slot), err)
}
continue
}
return &Lease{
paths: pathsForDataDir(dataDir),
name: instanceName(slot),
id: instanceID(slot),
lock: lock,
}, nil
}
return fallbackLease(baseDir, fmt.Errorf("最多支持同时运行 %d 个实例(%v)", maxSlots, scanErr))
}
// fallbackLease 构造兜底实例:使用进程唯一的临时目录,保证与其它实例互不干扰。
// 即使该目录也创建不出来,也只返回这个「本进程独占」的路径(日志会降级为仅内存),
// 绝不退回默认目录——退回默认目录会让两个进程共用同一个日志文件与前端数据目录。
func fallbackLease(baseDir string, cause error) (*Lease, error) {
id := fmt.Sprintf("instance-tmp-%d", os.Getpid())
dataDir := filepath.Join(baseDir, "instances", id)
_ = osMkdirAll(dataDir)
return &Lease{
paths: pathsForDataDir(dataDir),
name: "临时实例",
id: id,
}, cause
}
// Paths 返回当前实例独立的数据路径。
func (l *Lease) Paths() Paths {
if l == nil {
return Paths{}
}
return l.paths
}
// Name 返回面向用户的实例名称。
func (l *Lease) Name() string {
if l == nil {
return ""
}
return l.name
}
// ID 返回实例的稳定标识,用于给前端本地存储分片(不含空格与中文)。
func (l *Lease) ID() string {
if l == nil {
return ""
}
return l.id
}
// WebviewUserDataDir 返回当前实例独占的 WebView2 用户数据目录。
//
// Wails 在 WebviewUserDataPath 为空时会把用户数据目录放在 %APPDATA%\<可执行文件名>,
// 所有实例共用同一个目录;而 WebView2 的一个用户数据目录只能有一个浏览器进程,
// 第二个实例创建环境时会偶发失败 —— 表现就是「有时候开不出新实例,必须全部关掉重开」。
// 这里给每个实例一个独立目录,从根上消除这个竞争。
func (l *Lease) WebviewUserDataDir() string {
if l == nil || l.paths.DataDir == "" {
return ""
}
return filepath.Join(l.paths.DataDir, "webview2")
}
// Release 释放实例锁,允许新启动的程序复用该实例。
func (l *Lease) Release() {
if l == nil || l.lock == nil {
return
}
l.releaseOnce.Do(func() {
_ = l.lock.Close()
})
}
func pathsForDataDir(dataDir string) Paths {
logDir := filepath.Join(dataDir, logSubDir)
if value := os.Getenv("HUYA_LOG_DIR"); value != "" {
logDir = resolvePath(value)
}
return Paths{DataDir: dataDir, LogDir: logDir}
}
// DefaultLogDir 返回不绑定实例槽位的默认日志目录,供 CLI 等单次运行入口使用。
func DefaultLogDir() string {
return pathsForDataDir(baseDataDir()).LogDir
}
// DefaultDataDir 返回不绑定实例槽位的默认数据目录,供 CLI 等单次运行入口使用。
func DefaultDataDir() string {
return baseDataDir()
}
// baseDataDir 返回实例目录的根。
// 默认放在当前用户的配置目录,而不是工作目录,避免应用包目录无写权限。
func baseDataDir() string {
if value := os.Getenv("HUYA_DATA_DIR"); value != "" {
return resolvePath(value)
}
return resolvePath(filepath.Join(defaultAppDataDir(), appDirName))
}
func defaultAppDataDir() string {
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
return dir
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".config")
}
return "."
}
func resolvePath(path string) string {
if filepath.IsAbs(path) {
return path
}
if abs, err := filepath.Abs(path); err == nil {
return abs
}
return path
}
func instanceName(slot int) string {
if slot <= 1 {
return "默认实例"
}
return fmt.Sprintf("实例 %d", slot)
}
func instanceID(slot int) string {
if slot <= 1 {
return "default"
}
return fmt.Sprintf("instance-%d", slot)
}
func instanceLockFileName(slot int) string {
if slot <= 1 {
return "default.lock"
}
return fmt.Sprintf("instance-%d.lock", slot)
}
+221
View File
@@ -0,0 +1,221 @@
package instance
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// TestAcquireSeparatesConcurrentInstances 验证槽位分配:第一个进程用默认数据目录,
// 第二个进程拿到 instance-2,锁释放后默认实例可被复用。
func TestAcquireSeparatesConcurrentInstances(t *testing.T) {
baseDir := t.TempDir()
t.Setenv("HUYA_DATA_DIR", baseDir)
t.Setenv("HUYA_LOG_DIR", "")
first, err := Acquire()
if err != nil {
t.Fatal(err)
}
defer first.Release()
if first.Name() != "默认实例" || first.ID() != "default" {
t.Fatalf("first instance = %q/%q", first.Name(), first.ID())
}
if first.Paths().DataDir != baseDir {
t.Fatalf("first dataDir = %q, want %q", first.Paths().DataDir, baseDir)
}
if first.Paths().LogDir != filepath.Join(baseDir, "logs") {
t.Fatalf("first logDir = %q", first.Paths().LogDir)
}
second, err := Acquire()
if err != nil {
t.Fatal(err)
}
defer second.Release()
wantSecondDir := filepath.Join(baseDir, "instances", "instance-2")
if second.Name() != "实例 2" || second.ID() != "instance-2" {
t.Fatalf("second instance = %q/%q", second.Name(), second.ID())
}
if second.Paths().DataDir != wantSecondDir {
t.Fatalf("second dataDir = %q, want %q", second.Paths().DataDir, wantSecondDir)
}
// 日志目录必须落在实例自己的目录里,否则多开时日志会交错。
if second.Paths().LogDir != filepath.Join(wantSecondDir, "logs") {
t.Fatalf("second logDir = %q", second.Paths().LogDir)
}
first.Release()
reused, err := Acquire()
if err != nil {
t.Fatal(err)
}
defer reused.Release()
if reused.Name() != "默认实例" || reused.Paths().DataDir != baseDir {
t.Fatalf("reused instance = %q/%q", reused.Name(), reused.Paths().DataDir)
}
}
// TestWebviewDirsAreIsolated WebView2 一个用户数据目录只能有一个浏览器进程,
// 每个实例必须是不同目录,否则第二个实例会偶发开不出窗口。
func TestWebviewDirsAreIsolated(t *testing.T) {
baseDir := t.TempDir()
t.Setenv("HUYA_DATA_DIR", baseDir)
t.Setenv("HUYA_LOG_DIR", "")
first, err := Acquire()
if err != nil {
t.Fatal(err)
}
defer first.Release()
second, err := Acquire()
if err != nil {
t.Fatal(err)
}
defer second.Release()
firstDir := first.WebviewUserDataDir()
secondDir := second.WebviewUserDataDir()
if firstDir == "" || secondDir == "" || firstDir == secondDir {
t.Fatalf("webview dirs = %q / %q", firstDir, secondDir)
}
if want := filepath.Join(baseDir, "webview2"); firstDir != want {
t.Fatalf("first webview dir = %q, want %q", firstDir, want)
}
if want := filepath.Join(baseDir, "instances", "instance-2", "webview2"); secondDir != want {
t.Fatalf("second webview dir = %q, want %q", secondDir, want)
}
}
// TestAcquireNeverSharesDefaultDir 保证实例占位失败时也不会退回默认目录。
// 退回默认目录会让两个进程共享日志文件与前端数据目录。
func TestAcquireNeverSharesDefaultDir(t *testing.T) {
baseDir := t.TempDir()
// 用一个普通文件占住 <dataDir>,让 <dataDir>/instances/.locks 无法创建。
blocked := filepath.Join(baseDir, "blocked")
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("HUYA_DATA_DIR", blocked)
t.Setenv("HUYA_LOG_DIR", "")
lease, err := Acquire()
if lease == nil {
t.Fatal("实例占位必须始终返回可用目录")
}
defer lease.Release()
if err == nil {
t.Fatal("降级路径应当把原因返回给上层记录")
}
if lease.Name() != "临时实例" {
t.Fatalf("降级实例名 = %q, want 临时实例", lease.Name())
}
if lease.Paths().DataDir == blocked || !strings.Contains(lease.ID(), "instance-tmp-") {
t.Fatalf("降级目录必须与本进程绑定且独立: %q", lease.Paths().DataDir)
}
}
// TestReleaseIsIdempotent 验证重复释放不会出错(Shutdown 与 defer 可能都调用一次)。
func TestReleaseIsIdempotent(t *testing.T) {
t.Setenv("HUYA_DATA_DIR", t.TempDir())
t.Setenv("HUYA_LOG_DIR", "")
lease, err := Acquire()
if err != nil {
t.Fatal(err)
}
lease.Release()
lease.Release()
}
// TestAcquireIsExclusiveAcrossProcesses 用两个真实进程验证「多开」:第二个进程不会拿到
// 与第一个进程相同的数据目录(否则会互相覆盖日志与前端数据)。
func TestAcquireIsExclusiveAcrossProcesses(t *testing.T) {
if os.Getenv("HUYA_INSTANCE_HELPER") == "1" {
runInstanceHelper()
return
}
baseDir := t.TempDir()
first := startInstanceHelper(t, baseDir)
defer first.stop()
firstName, firstID, firstDir := first.read(t)
// 第一个进程已经拿到锁之后再启动第二个,避免出现「同时抢同一槽位」的随机性。
second := startInstanceHelper(t, baseDir)
defer second.stop()
_, secondID, secondDir := second.read(t)
if firstID != "default" || firstName != "默认实例" {
t.Fatalf("第一个进程实例 = %q/%q, want 默认实例/default", firstName, firstID)
}
if secondID != "instance-2" {
t.Fatalf("第二个进程实例 = %q, want instance-2", secondID)
}
if firstDir == secondDir {
t.Fatalf("两个进程的数据目录相同: %s", firstDir)
}
if !strings.Contains(secondDir, filepath.Join("instances", "instance-2")) {
t.Fatalf("第二个进程数据目录 = %q", secondDir)
}
}
// runInstanceHelper 是子进程分支:占位后打印实例信息,短暂保持占用再释放。
func runInstanceHelper() {
lease, err := Acquire()
if err != nil {
fmt.Println("ERR:" + err.Error())
return
}
fmt.Printf("%s|%s|%s\n", lease.Name(), lease.ID(), lease.Paths().DataDir)
time.Sleep(2 * time.Second)
lease.Release()
}
type instanceHelper struct {
cmd *exec.Cmd
lines *bufio.Scanner
}
func startInstanceHelper(t *testing.T, baseDir string) *instanceHelper {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=TestAcquireIsExclusiveAcrossProcesses")
cmd.Env = append(os.Environ(),
"HUYA_INSTANCE_HELPER=1",
"HUYA_DATA_DIR="+baseDir,
"HUYA_LOG_DIR=",
)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
return &instanceHelper{cmd: cmd, lines: bufio.NewScanner(stdout)}
}
// read 读取子进程输出的 "名称|标识|数据目录"。
func (h *instanceHelper) read(t *testing.T) (name, id, dir string) {
t.Helper()
if !h.lines.Scan() {
t.Fatalf("子进程未输出实例信息: %v", h.lines.Err())
}
parts := strings.SplitN(strings.TrimSpace(h.lines.Text()), "|", 3)
if len(parts) != 3 {
t.Fatalf("子进程输出异常: %q", h.lines.Text())
}
return parts[0], parts[1], parts[2]
}
func (h *instanceHelper) stop() {
if h.cmd.Process != nil {
_ = h.cmd.Wait()
}
}
+12
View File
@@ -0,0 +1,12 @@
package instance
import "os"
// instanceLock 抽象「进程独占某个槽位」的能力,由各平台实现。
type instanceLock interface {
Close() error
}
func osMkdirAll(path string) error {
return os.MkdirAll(path, 0o700)
}
+38
View File
@@ -0,0 +1,38 @@
//go:build !windows
package instance
import (
"errors"
"os"
"syscall"
)
type unixInstanceLock struct {
file *os.File
}
// tryAcquireInstanceLock 用 flock 非阻塞独占锁文件。
// 第二个参数表示「锁被别的进程持有」,此时调用方应继续扫描下一个槽位。
func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, false, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
return nil, false, nil
}
return nil, false, err
}
return &unixInstanceLock{file: file}, true, nil
}
func (l *unixInstanceLock) Close() error {
if l == nil || l.file == nil {
return nil
}
_ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
return l.file.Close()
}
+43
View File
@@ -0,0 +1,43 @@
//go:build windows
package instance
import (
"errors"
"golang.org/x/sys/windows"
)
type windowsInstanceLock struct {
handle windows.Handle
}
// tryAcquireInstanceLock 用共享模式为 0 的 CreateFile 独占锁文件:
// 已被占用的锁文件会返回 ERROR_SHARING_VIOLATION,此时调用方继续扫描下一个槽位。
func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
handle, err := windows.CreateFile(
windows.StringToUTF16Ptr(path),
windows.GENERIC_READ|windows.GENERIC_WRITE,
0,
nil,
windows.OPEN_ALWAYS,
windows.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
if errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return nil, false, nil
}
return nil, false, err
}
return &windowsInstanceLock{handle: handle}, true, nil
}
func (l *windowsInstanceLock) Close() error {
if l == nil || l.handle == windows.InvalidHandle {
return nil
}
err := windows.CloseHandle(l.handle)
l.handle = windows.InvalidHandle
return err
}
+94
View File
@@ -0,0 +1,94 @@
package logger
import (
"fmt"
"io"
"sync"
"sync/atomic"
"time"
)
// asyncBufferSize 异步写盘队列长度。队列满时丢弃日志,优先保护抢购链路。
const asyncBufferSize = 8192
// AsyncWriter 异步日志写入器,队列满时丢弃日志以保护主链路。
type AsyncWriter struct {
mu sync.RWMutex
dst io.Writer
ch chan []byte
wg sync.WaitGroup
closed uint32
dropped uint64
}
// NewAsyncWriter 创建异步日志写入器。
func NewAsyncWriter(dst io.Writer, buffer int) *AsyncWriter {
if buffer <= 0 {
buffer = 1024
}
w := &AsyncWriter{
dst: dst,
ch: make(chan []byte, buffer),
}
w.wg.Add(1)
go w.run()
return w
}
// Write 异步写日志;队列满时丢弃,避免抢购请求被磁盘 I/O 阻塞。
func (w *AsyncWriter) Write(p []byte) (int, error) {
if w == nil {
return len(p), nil
}
w.mu.RLock()
defer w.mu.RUnlock()
if atomic.LoadUint32(&w.closed) == 1 {
return len(p), nil
}
buf := append([]byte(nil), p...)
select {
case w.ch <- buf:
default:
atomic.AddUint64(&w.dropped, 1)
}
return len(p), nil
}
// Dropped 返回因队列满被丢弃的日志条数。
func (w *AsyncWriter) Dropped() uint64 {
if w == nil {
return 0
}
return atomic.LoadUint64(&w.dropped)
}
func (w *AsyncWriter) run() {
defer w.wg.Done()
for p := range w.ch {
_, _ = w.dst.Write(p)
}
if dropped := atomic.LoadUint64(&w.dropped); dropped > 0 {
notice := formatLogLine(Entry{
Time: time.Now().Format(entryTimeLayout),
Level: LevelWarn,
Category: "日志",
Message: fmt.Sprintf("日志队列已丢弃 %d 条,已优先保护抢购链路", dropped),
at: time.Now(),
})
_, _ = w.dst.Write([]byte(notice))
}
}
// Close 关闭写入器,等待队列清空。
func (w *AsyncWriter) Close() error {
if w == nil {
return nil
}
w.mu.Lock()
defer w.mu.Unlock()
if atomic.CompareAndSwapUint32(&w.closed, 0, 1) {
close(w.ch)
w.wg.Wait()
}
return nil
}
+179
View File
@@ -0,0 +1,179 @@
// Package logger 提供全应用共用的结构化日志。
//
// 设计要点(多开场景下尤其重要):
// - 每个进程启动时创建一个独占的日志文件(O_EXCL + 自增后缀),
// 多个实例同时运行也不会写进同一个文件;
// - 文件异步写入且队列有界,队列满时丢弃日志,避免磁盘 I/O 阻塞抢购主链路;
// - 内存环形缓冲带自增 ID,界面可以按 ID 增量拉取;
// - 条目带分类与账号标签,便于按账号观察抢购链路。
package logger
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
)
// 日志级别。
const (
LevelDebug = "debug"
LevelInfo = "info"
LevelSuccess = "success"
LevelWarn = "warn"
LevelError = "error"
)
const (
entryTimeLayout = "15:04:05.000"
lineTimeLayout = "2006-01-02 15:04:05.000"
)
// Entry 是一条结构化日志。
type Entry struct {
ID int64 `json:"id"`
Time string `json:"time"`
Level string `json:"level"`
Category string `json:"category"`
Account string `json:"account"`
Message string `json:"message"`
// at 只用于落盘,保证文件里带完整日期(不参与 JSON 序列化)。
at time.Time
}
var minLevel atomic.Int32
var (
emitterMu sync.RWMutex
emitter func(Entry)
)
// SetMinLevel 设置日志最低输出等级,低于该等级的日志(例如逐条网络请求的 debug)
// 不会进入内存缓冲,也不会落盘。
func SetMinLevel(level string) {
minLevel.Store(int32(levelPriority(level)))
}
// MinLevel 返回当前最低输出等级名。
func MinLevel() string {
switch minLevel.Load() {
case -1:
return LevelDebug
case 1:
return LevelWarn
case 2:
return LevelError
default:
return LevelInfo
}
}
// SetEmitter 注册日志推送回调(界面用来实时显示日志)。传 nil 表示取消。
func SetEmitter(fn func(Entry)) {
emitterMu.Lock()
emitter = fn
emitterMu.Unlock()
}
func currentEmitter() func(Entry) {
emitterMu.RLock()
defer emitterMu.RUnlock()
return emitter
}
func levelPriority(level string) int {
switch strings.ToLower(strings.TrimSpace(level)) {
case LevelDebug:
return -1
case LevelWarn:
return 1
case LevelError:
return 2
case "", LevelInfo, LevelSuccess:
return 0
default:
return 0
}
}
func normalizeLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case LevelDebug:
return LevelDebug
case LevelWarn:
return LevelWarn
case LevelError:
return LevelError
case LevelSuccess:
return LevelSuccess
default:
return LevelInfo
}
}
// LogEvent 输出带分类和级别的结构化日志。
func LogEvent(category, level, format string, args ...any) {
logEntry(category, level, "", format, args...)
}
// LogAccountEvent 输出带账号标签的结构化日志,便于界面按账号观察抢购链路。
func LogAccountEvent(category, level, account string, format string, args ...any) {
logEntry(category, level, account, format, args...)
}
func logEntry(category, level, account, format string, args ...any) {
level = normalizeLevel(level)
if levelPriority(level) < int(minLevel.Load()) {
return
}
if category = strings.TrimSpace(category); category == "" {
category = "系统"
}
now := time.Now()
entry := Entry{
Time: now.Format(entryTimeLayout),
Level: level,
Category: category,
Account: account,
Message: fmt.Sprintf(format, args...),
at: now,
}
if current := currentSink(); current != nil {
current.write(entry)
}
}
// formatLogLine 把条目渲染成落盘的一行文本。
func formatLogLine(entry Entry) string {
at := entry.at
if at.IsZero() {
at = time.Now()
}
prefix := at.Format(lineTimeLayout)
if entry.Account != "" {
return fmt.Sprintf("%s [%s][%s][账号:%s] %s\n", prefix, entry.Category, entry.Level, entry.Account, entry.Message)
}
return fmt.Sprintf("%s [%s][%s] %s\n", prefix, entry.Category, entry.Level, entry.Message)
}
// logFileHeader 说明日志格式与运行上下文,多实例日志汇总后仍能分清来源。
func logFileHeader(instanceName, instanceID, dataDir string) string {
if instanceName == "" {
instanceName = "未知实例"
}
if instanceID == "" {
instanceID = "unknown"
}
var b strings.Builder
b.WriteString("==================================================================\n")
fmt.Fprintf(&b, "实例: %s (%s)\n", instanceName, instanceID)
if dataDir != "" {
fmt.Fprintf(&b, "数据目录: %s\n", dataDir)
}
b.WriteString("时间格式: YYYY-MM-DD HH:MM:SS.mmm [分类][级别][账号] 正文\n")
b.WriteString("逐条网络请求为 debug 级,默认不显示;排查时把日志等级改成 debug\n")
b.WriteString("==================================================================\n")
return b.String()
}
+159
View File
@@ -0,0 +1,159 @@
package logger
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
const (
defaultMaxFileBytes = 5 << 20 // 单个日志文件上限 5MB
defaultBackups = 5 // 最多保留 5 个滚动文件
)
// fileWriter 负责把日志追加写入实例独占的文件,超过大小上限时按 .1/.2/... 滚动。
//
// 文件名在创建时就用 O_EXCL 抢占,因此同一实例目录下不会出现两个进程写同一个
// 文件的情况(多开时每个实例有各自的目录,重启则拿到新的时间戳文件)。
type fileWriter struct {
mu sync.Mutex
dir string
path string
file *os.File
size int64
maxBytes int64
backups int
}
// newFileWriter 在 dir 下创建一个独占的日志文件。
func newFileWriter(dir, instanceID string) (*fileWriter, error) {
if dir == "" {
return nil, errors.New("日志目录为空")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
if instanceID == "" {
instanceID = "default"
}
base := filepath.Join(dir, fmt.Sprintf("huya-go_%s_%s", instanceID, time.Now().Format("20060102_150405")))
file, path, err := createUniqueLogFile(base)
if err != nil {
return nil, err
}
w := &fileWriter{
dir: dir,
path: path,
file: file,
maxBytes: defaultMaxFileBytes,
backups: defaultBackups,
}
if info, err := file.Stat(); err == nil {
w.size = info.Size()
}
return w, nil
}
// createUniqueLogFile 以 O_EXCL 递增后缀创建日志文件。
// 秒级时间戳相同(例如重启)时自动追加 -2 / -3 后缀,避免两个进程写进同一个文件。
func createUniqueLogFile(base string) (*os.File, string, error) {
for i := 1; i <= 50; i++ {
path := base + ".log"
if i > 1 {
path = fmt.Sprintf("%s-%d.log", base, i)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil {
return file, path, nil
}
if !errors.Is(err, os.ErrExist) {
return nil, "", err
}
}
return nil, "", fmt.Errorf("日志文件名连续冲突: %s", base)
}
// Dir 返回日志目录。
func (w *fileWriter) Dir() string { return w.dir }
// Path 返回当前日志文件路径。
func (w *fileWriter) Path() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.path
}
// Write 追加一段日志,必要时先滚动。
func (w *fileWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return 0, errors.New("日志文件已关闭")
}
if w.maxBytes > 0 && w.size+int64(len(p)) > w.maxBytes {
if err := w.rotateLocked(); err != nil {
return 0, err
}
}
n, err := w.file.Write(p)
w.size += int64(n)
return n, err
}
// rotateLocked 关闭当前文件并重命名成 .1/.2/...,然后重新打开一个同名文件。
// 调用方必须已持有 w.mu。
func (w *fileWriter) rotateLocked() error {
_ = w.file.Close()
w.file = nil
for i := w.backups - 1; i >= 1; i-- {
from := fmt.Sprintf("%s.%d", w.path, i)
if _, err := os.Stat(from); err != nil {
continue
}
_ = os.Rename(from, fmt.Sprintf("%s.%d", w.path, i+1))
}
if _, err := os.Stat(w.path); err == nil {
_ = os.Rename(w.path, w.path+".1")
}
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
w.file = file
w.size = 0
return nil
}
// Close 关闭当前文件。
func (w *fileWriter) Close() error {
if w == nil {
return nil
}
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
// writeHeader 把实例信息写在文件开头,确保排在任何日志之前。
func (w *fileWriter) writeHeader(instanceName, instanceID, dataDir string) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return
}
header := logFileHeader(instanceName, instanceID, dataDir)
if _, err := w.file.WriteString(header); err != nil {
return
}
w.size += int64(len(header))
}
+156
View File
@@ -0,0 +1,156 @@
package logger
import (
"os"
"path/filepath"
"strings"
"sync"
)
// sink 是日志输出汇集点,把结构化条目分发到内存缓冲、日志文件与界面回调。
type sink struct {
store *Store
file *fileWriter
async *AsyncWriter
}
var (
sinkMu sync.RWMutex
globalSink *sink
globalClose func()
)
// Init 初始化日志输出:内存缓冲、日志文件与界面回调同时生效。
//
// instanceName / instanceID / dataDir 只用于写文件表头,便于把多实例日志
// 复制或汇总后仍能看出是哪台机器的哪个实例。
// 返回的 close 函数必须先调用(保证队列落盘),再由调用方关闭其它资源。
func Init(store *Store, logDir, instanceName, instanceID, dataDir string) (func(), error) {
if store == nil {
store = NewStore(500)
}
if level := os.Getenv("HUYA_LOG_LEVEL"); level != "" {
SetMinLevel(level)
}
// 解析为绝对路径,避免打包后工作目录不同导致日志写到意外位置。
logDir = resolveAbsolutePath(logDir)
store.SetFilePath("")
file, err := newFileWriter(logDir, instanceID)
if err != nil {
// 日志文件不可用时降级为「仅内存 + 界面」,不影响主流程。
replaceSink(&sink{store: store}, nil)
LogEvent("日志", LevelWarn, "日志文件不可用,日志仅保留在内存: %v", err)
return func() { closeSink() }, err
}
file.writeHeader(instanceName, instanceID, dataDir)
store.SetFilePath(file.Path())
replaceSink(&sink{
store: store,
file: file,
async: NewAsyncWriter(file, asyncBufferSize),
}, func() { closeSink() })
LogEvent("日志", LevelSuccess, "日志文件已启用: %s", file.Path())
return func() { closeSink() }, nil
}
func replaceSink(next *sink, closeFn func()) {
sinkMu.Lock()
previous := globalClose
globalSink = next
globalClose = closeFn
sinkMu.Unlock()
if previous != nil {
previous()
}
}
func currentSink() *sink {
sinkMu.RLock()
defer sinkMu.RUnlock()
return globalSink
}
func closeSink() {
sinkMu.Lock()
current := globalSink
globalSink = nil
globalClose = nil
sinkMu.Unlock()
if current == nil {
return
}
if current.async != nil {
_ = current.async.Close()
}
if current.file != nil {
_ = current.file.Close()
}
}
func (s *sink) write(entry Entry) {
if s == nil {
return
}
if s.store != nil {
entry = s.store.Append(entry)
}
if s.async != nil {
_, _ = s.async.Write([]byte(formatLogLine(entry)))
} else if s.file != nil {
_, _ = s.file.Write([]byte(formatLogLine(entry)))
}
if emit := currentEmitter(); emit != nil {
emit(entry)
}
}
// Dir 返回当前日志目录(无文件日志时返回空串)。
func Dir() string {
current := currentSink()
if current == nil || current.file == nil {
return ""
}
return current.file.Dir()
}
// FilePath 返回当前日志文件路径(无文件日志时返回空串)。
func FilePath() string {
current := currentSink()
if current == nil || current.file == nil {
return ""
}
return current.file.Path()
}
// Dropped 返回因队列满被丢弃的日志条数。
func Dropped() uint64 {
current := currentSink()
if current == nil || current.async == nil {
return 0
}
return current.async.Dropped()
}
// LevelFromString 把用户输入的等级名归一化,非法值回退为 info。
func LevelFromString(level string) string {
return normalizeLevel(strings.TrimSpace(level))
}
// resolveAbsolutePath 把相对路径解析为绝对路径,便于日志里直接复制路径排查。
func resolveAbsolutePath(path string) string {
if path == "" || filepath.IsAbs(path) {
return path
}
if abs, err := filepath.Abs(path); err == nil {
return abs
}
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), path)
}
return path
}
+200
View File
@@ -0,0 +1,200 @@
package logger
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func initForTest(t *testing.T) (*Store, string, func()) {
t.Helper()
dir := t.TempDir()
store := NewStore(200)
closeLog, err := Init(store, dir, "测试实例", "test", dir)
if err != nil {
t.Fatalf("Init() error = %v", err)
}
t.Cleanup(func() {
SetMinLevel(LevelInfo)
SetEmitter(nil)
})
return store, FilePath(), closeLog
}
// TestInitWritesHeaderAndStructuredLines 验证日志文件带实例表头,
// 且每一行包含分类、级别与账号标签。
func TestInitWritesHeaderAndStructuredLines(t *testing.T) {
store, path, closeLog := initForTest(t)
if path == "" {
t.Fatal("日志文件路径为空")
}
LogEvent("配置", LevelInfo, "活动 SID=%d", 2203)
LogAccountEvent("监控", LevelSuccess, "88231", "兑换成功 pid=%d", 12889)
closeLog()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取日志文件失败: %v", err)
}
text := string(data)
for _, want := range []string{
"实例: 测试实例 (test)",
"数据目录:",
"[配置][info] 活动 SID=2203",
"[监控][success][账号:88231] 兑换成功 pid=12889",
} {
if !strings.Contains(text, want) {
t.Fatalf("日志文件缺少 %q:\n%s", want, text)
}
}
if store.Count() < 3 {
t.Fatalf("内存日志条数 = %d, want >= 3", store.Count())
}
}
// TestUniqueLogFileAvoidsSameSecondCollision 同一秒内两次创建也不能拿到同一个文件:
// 多开或快速重启时,两个进程写同一个文件会让日志互相污染。
func TestUniqueLogFileAvoidsSameSecondCollision(t *testing.T) {
dir := t.TempDir()
first, err := newFileWriter(dir, "test")
if err != nil {
t.Fatal(err)
}
defer first.Close()
second, err := newFileWriter(dir, "test")
if err != nil {
t.Fatal(err)
}
defer second.Close()
if first.Path() == second.Path() {
t.Fatalf("两个进程拿到了同一个日志文件: %s", first.Path())
}
if filepath.Dir(first.Path()) != dir {
t.Fatalf("日志目录 = %q, want %q", filepath.Dir(first.Path()), dir)
}
}
// TestFileWriterRotatesAndDropsOldest 验证按大小滚动并丢弃最旧的备份。
func TestFileWriterRotatesAndDropsOldest(t *testing.T) {
dir := t.TempDir()
writer, err := newFileWriter(dir, "test")
if err != nil {
t.Fatal(err)
}
defer writer.Close()
writer.maxBytes = 200
writer.backups = 2
for i := 0; i < 60; i++ {
if _, err := writer.Write([]byte(fmt.Sprintf("第 %d 条日志,用于触发滚动\n", i))); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(writer.Path() + ".1"); err != nil {
t.Fatalf("期望存在滚动文件 .1: %v", err)
}
if _, err := os.Stat(writer.Path() + ".2"); err != nil {
t.Fatalf("期望存在滚动文件 .2: %v", err)
}
if _, err := os.Stat(writer.Path() + ".3"); err == nil {
t.Fatal("backups=2 时不应出现 .3")
}
}
type blockingWriter struct {
release chan struct{}
}
func (w blockingWriter) Write(p []byte) (int, error) {
<-w.release
return len(p), nil
}
// TestAsyncWriterDropsInsteadOfBlocking 队列满时必须丢弃日志而不是阻塞调用方,
// 否则抢购主链路会被磁盘 I/O 拖住。
func TestAsyncWriterDropsInsteadOfBlocking(t *testing.T) {
release := make(chan struct{})
writer := NewAsyncWriter(blockingWriter{release: release}, 1)
start := time.Now()
for i := 0; i < 100; i++ {
if _, err := writer.Write([]byte("x\n")); err != nil {
t.Fatalf("Write() error = %v", err)
}
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("写入被阻塞了 %s", elapsed)
}
if writer.Dropped() == 0 {
t.Fatal("队列满时应记录丢弃条数")
}
close(release)
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
// TestStoreSinceIsIncremental 验证界面增量拉取语义:只返回 ID 更大的日志。
func TestStoreSinceIsIncremental(t *testing.T) {
store := NewStore(3)
first := store.Append(Entry{Message: "第一条"})
if first.ID == 0 {
t.Fatal("Append 应分配自增 ID")
}
store.Append(Entry{Message: "第二条"})
if got := store.Since(0); len(got) != 2 {
t.Fatalf("Since(0) 条数 = %d, want 2", len(got))
}
if got := store.Since(first.ID); len(got) != 1 || got[0].Message != "第二条" {
t.Fatalf("Since(first) = %+v", got)
}
// 超出容量时丢弃最旧的条目。
store.Append(Entry{Message: "第三条"})
store.Append(Entry{Message: "第四条"})
if got := store.Since(0); len(got) != 3 || got[0].Message != "第二条" {
t.Fatalf("环形缓冲 = %+v", got)
}
if store.Count() != 3 {
t.Fatalf("Count() = %d, want 3", store.Count())
}
}
// TestMinLevelFiltersDebug 低等级日志不进入内存缓冲,也不落盘。
func TestMinLevelFiltersDebug(t *testing.T) {
store, _, closeLog := initForTest(t)
defer func() {
SetMinLevel(LevelInfo)
closeLog()
}()
SetMinLevel(LevelDebug)
LogEvent("网络", LevelDebug, "debug 应保留")
if MinLevel() != LevelDebug {
t.Fatalf("MinLevel() = %q", MinLevel())
}
SetMinLevel(LevelError)
LogEvent("网络", LevelDebug, "debug 应被过滤")
if MinLevel() != LevelError {
t.Fatalf("MinLevel() = %q", MinLevel())
}
for _, entry := range store.Since(0) {
if entry.Message == "debug 应被过滤" {
t.Fatal("低于最低等级的日志不应进入内存缓冲")
}
}
if got := LevelFromString("不是等级"); got != LevelInfo {
t.Fatalf("LevelFromString(非法) = %q, want info", got)
}
}
+70
View File
@@ -0,0 +1,70 @@
package logger
import "sync"
// Store 是内存日志环形缓冲,供界面增量拉取。
type Store struct {
mu sync.RWMutex
nextID int64
max int
filePath string
entries []Entry
}
// NewStore 创建内存日志缓冲,最多保留 max 条最新日志。
func NewStore(max int) *Store {
if max <= 0 {
max = 500
}
return &Store{
max: max,
entries: make([]Entry, 0, max),
}
}
// Append 追加一条日志,由内部署分配自增 ID;超过容量时丢弃最旧的条目。
func (s *Store) Append(entry Entry) Entry {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
entry.ID = s.nextID
s.entries = append(s.entries, entry)
if len(s.entries) > s.max {
s.entries = append([]Entry(nil), s.entries[len(s.entries)-s.max:]...)
}
return entry
}
// Since 返回 ID 大于 since 的日志,用于界面增量拉取。
func (s *Store) Since(since int64) []Entry {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Entry, 0, len(s.entries))
for _, entry := range s.entries {
if entry.ID > since {
out = append(out, entry)
}
}
return out
}
// Count 返回当前缓冲内的日志条数。
func (s *Store) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.entries)
}
// SetFilePath 保存当前日志文件路径。
func (s *Store) SetFilePath(path string) {
s.mu.Lock()
defer s.mu.Unlock()
s.filePath = path
}
// FilePath 返回当前日志文件路径。
func (s *Store) FilePath() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.filePath
}
+14
View File
@@ -29,6 +29,20 @@ const (
CategoryCondition = "condition"
)
// LogLevelForCategory 把结果分类映射成日志等级,供界面按等级高亮与筛选。
func LogLevelForCategory(category string) string {
switch category {
case CategorySuccess:
return "success"
case CategoryError:
return "error"
case CategoryFailed, CategoryTooFrequent, CategoryCondition:
return "warn"
default:
return "info"
}
}
type Account struct {
UID int64 `json:"uid"`
Cookie string `json:"-"`
+54 -5
View File
@@ -2,22 +2,33 @@ package main
import (
"embed"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/windows"
"huya-go/internal/gui"
"huya-go/internal/instance"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
app := gui.NewApp()
// 实例槽位必须在 wails.Run 之前占用:WebView2 的用户数据目录要按实例区分,
// 而它只能在创建窗口之前设定。占位结果随后注入 App,避免重复占位。
lease, leaseErr := instance.Acquire()
writeStartupTrace(fmt.Sprintf("启动: 实例=%s 数据目录=%s", lease.Name(), lease.Paths().DataDir), leaseErr)
err := wails.Run(&options.App{
app := gui.NewApp(lease, leaseErr)
appOptions := &options.App{
Title: "虎牙抢购助手",
Width: 1280,
Height: 820,
@@ -31,8 +42,46 @@ func main() {
Bind: []interface{}{
app,
},
})
if err != nil {
log.Fatal(err)
}
// 每个实例一个独立的 WebView2 用户数据目录。Wails 默认用
// %APPDATA%\<可执行文件名>,所有实例共用,而 WebView2 的一个数据目录
// 只能有一个浏览器进程,第二个实例会偶发创建环境失败。
if webviewDir := lease.WebviewUserDataDir(); webviewDir != "" {
if err := os.MkdirAll(webviewDir, 0o700); err != nil {
writeStartupTrace("WebView2 用户数据目录创建失败: "+err.Error(), nil)
} else {
appOptions.Windows = &windows.Options{WebviewUserDataPath: webviewDir}
}
}
if err := wails.Run(appOptions); err != nil {
log.Printf("启动失败: %v", err)
writeStartupTrace("启动失败: "+err.Error(), nil)
os.Exit(1)
}
}
// writeStartupTrace 把每次启动/失败追加到 <用户缓存目录>/huya-go/startup.log。
//
// GUI 应用没有控制台,而 WebView2 环境创建失败时 OnStartup 根本不会执行、
// 应用自己的日志文件也不会生成——那时只有这份文件能说明「窗口为什么没起来」。
func writeStartupTrace(line string, cause error) {
dir, err := os.UserCacheDir()
if err != nil || dir == "" {
return
}
path := filepath.Join(dir, "huya-go", "startup.log")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
defer func() { _ = file.Close() }()
if cause != nil {
line = fmt.Sprintf("%s (原因: %v)", line, cause)
}
_, _ = fmt.Fprintf(file, "%s pid=%d %s\n", time.Now().Format("2006-01-02 15:04:05.000"), os.Getpid(), line)
}