新增实例槽位隔离与结构化日志,多开不再共用日志与账号
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Vendored
+8
-1
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
Regular → Executable
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package instance
|
||||
|
||||
import "os"
|
||||
|
||||
// instanceLock 抽象「进程独占某个槽位」的能力,由各平台实现。
|
||||
type instanceLock interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
func osMkdirAll(path string) error {
|
||||
return os.MkdirAll(path, 0o700)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user