新增实例槽位隔离与结构化日志,多开不再共用日志与账号
This commit is contained in:
+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:"-"`
|
||||
|
||||
Reference in New Issue
Block a user