diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go index 6721bb8..bfceb2b 100644 --- a/backend/cmd/desktop/main.go +++ b/backend/cmd/desktop/main.go @@ -12,11 +12,11 @@ import ( "path/filepath" "runtime" "strings" - "syscall" "time" "github.com/gin-gonic/gin" "github.com/getlantern/systray" + "github.com/gofrs/flock" "github.com/pressly/goose/v3" "go.uber.org/zap" "gorm.io/driver/sqlite" @@ -47,11 +47,12 @@ var ( func main() { port := 9826 - if err := acquireSingleInstance(); err != nil { + singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock")) + if err := singleLock.Lock(); err != nil { showError("Nex Gateway", "已有 Nex 实例运行") os.Exit(1) } - defer releaseSingleInstance() + defer singleLock.Unlock() if err := checkPortAvailable(port); err != nil { showError("Nex Gateway", err.Error()) @@ -89,10 +90,21 @@ func main() { modelRepo := repository.NewModelRepository(db) statsRepo := repository.NewStatsRepository(db) - providerService := service.NewProviderService(providerRepo, modelRepo) - modelService := service.NewModelService(modelRepo, providerRepo) - routingService := service.NewRoutingService(modelRepo, providerRepo) - statsService := service.NewStatsService(statsRepo) + routingCache := service.NewRoutingCache(modelRepo, providerRepo, zapLogger) + if err := routingCache.Preload(); err != nil { + zapLogger.Warn("缓存预热失败,将使用懒加载", zap.Error(err)) + } + + statsBuffer := service.NewStatsBuffer(statsRepo, zapLogger, + service.WithFlushInterval(5*time.Second), + service.WithFlushThreshold(100)) + statsBuffer.Start() + defer statsBuffer.Stop() + + providerService := service.NewProviderService(providerRepo, modelRepo, routingCache) + modelService := service.NewModelService(modelRepo, providerRepo, routingCache) + routingService := service.NewRoutingService(routingCache) + statsService := service.NewStatsService(statsRepo, statsBuffer) registry := conversion.NewMemoryRegistry() if err := registry.Register(openai.NewAdapter()); err != nil { @@ -378,31 +390,29 @@ func checkPortAvailable(port int) error { return nil } -var lockFile *os.File +type SingletonLock struct { + flock *flock.Flock +} -func acquireSingleInstance() error { - lockPath := filepath.Join(os.TempDir(), "nex-gateway.lock") +func NewSingletonLock(lockPath string) *SingletonLock { + return &SingletonLock{ + flock: flock.New(lockPath), + } +} - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666) +func (s *SingletonLock) Lock() error { + locked, err := s.flock.TryLock() if err != nil { return err } - - err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err != nil { - f.Close() + if !locked { return fmt.Errorf("已有实例运行") } - - lockFile = f return nil } -func releaseSingleInstance() { - if lockFile != nil { - syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) - lockFile.Close() - } +func (s *SingletonLock) Unlock() { + s.flock.Unlock() } func openBrowser(url string) error { diff --git a/backend/cmd/desktop/singleton_test.go b/backend/cmd/desktop/singleton_test.go index 03031cf..c26b13f 100644 --- a/backend/cmd/desktop/singleton_test.go +++ b/backend/cmd/desktop/singleton_test.go @@ -3,37 +3,56 @@ package main import ( "os" "path/filepath" - "syscall" "testing" ) -func TestAcquireSingleInstance(t *testing.T) { - lockPath := filepath.Join(os.TempDir(), "nex-gateway-test.lock") - - origLockFile := lockFile - lockFile = nil - defer func() { lockFile = origLockFile }() - - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666) - if err != nil { - t.Fatalf("无法创建锁文件: %v", err) - } - defer f.Close() +func TestSingletonLock_FirstLockSuccess(t *testing.T) { + lockPath := filepath.Join(os.TempDir(), "nex-gateway-test-first.lock") defer os.Remove(lockPath) - err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err != nil { - t.Fatalf("无法获取文件锁: %v", err) + lock := NewSingletonLock(lockPath) + if err := lock.Lock(); err != nil { + t.Fatalf("首次加锁应成功,但返回错误: %v", err) } - defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) - - t.Log("单实例锁测试通过") + defer lock.Unlock() } -func TestReleaseSingleInstance(t *testing.T) { - lockFile = nil - - releaseSingleInstance() - - t.Log("释放空锁测试通过") +func TestSingletonLock_DuplicateLockFails(t *testing.T) { + lockPath := filepath.Join(os.TempDir(), "nex-gateway-test-dup.lock") + defer os.Remove(lockPath) + + lock1 := NewSingletonLock(lockPath) + if err := lock1.Lock(); err != nil { + t.Fatalf("首次加锁应成功: %v", err) + } + defer lock1.Unlock() + + lock2 := NewSingletonLock(lockPath) + err := lock2.Lock() + if err == nil { + lock2.Unlock() + t.Fatal("重复加锁应失败,但返回 nil") + } +} + +func TestSingletonLock_UnlockThenRelock(t *testing.T) { + lockPath := filepath.Join(os.TempDir(), "nex-gateway-test-relock.lock") + defer os.Remove(lockPath) + + lock1 := NewSingletonLock(lockPath) + if err := lock1.Lock(); err != nil { + t.Fatalf("首次加锁应成功: %v", err) + } + lock1.Unlock() + + lock2 := NewSingletonLock(lockPath) + if err := lock2.Lock(); err != nil { + t.Fatalf("释放后重新加锁应成功: %v", err) + } + lock2.Unlock() +} + +func TestSingletonLock_UnlockWithoutLock(t *testing.T) { + lock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway-test-nil.lock")) + lock.Unlock() } diff --git a/backend/go.mod b/backend/go.mod index 99e3d99..7ce3c5f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,6 +13,7 @@ require ( github.com/getlantern/systray v1.2.2 github.com/gin-gonic/gin v1.12.0 github.com/go-playground/validator/v10 v10.30.2 + github.com/gofrs/flock v0.13.0 github.com/google/uuid v1.6.0 github.com/mitchellh/mapstructure v1.5.0 github.com/pressly/goose/v3 v3.27.0 @@ -102,7 +103,6 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/gofrs/flock v0.12.1 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/go-printf-func-name v0.1.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index d6f1911..9c89ceb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -239,8 +239,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/openspec/specs/cross-platform-singleton/spec.md b/openspec/specs/cross-platform-singleton/spec.md new file mode 100644 index 0000000..6561736 --- /dev/null +++ b/openspec/specs/cross-platform-singleton/spec.md @@ -0,0 +1,53 @@ +# 跨平台单实例锁 + +## Purpose + +TBD - 提供跨平台单实例文件锁机制,使用 `github.com/gofrs/flock` 封装,支持 Windows、macOS、Linux 三平台。 + +## Requirements + +### Requirement: 跨平台单实例文件锁 + +系统 SHALL 提供跨平台的单实例文件锁机制,使用 `github.com/gofrs/flock` 实现,在 Windows、macOS、Linux 上均可正常工作。 + +#### Scenario: 首次加锁成功 +- **WHEN** 调用 `NewSingletonLock(lockPath)` 创建锁实例并调用 `Lock()` +- **AND** 锁文件未被其他进程持有 +- **THEN** 加锁成功,返回 `nil` + +#### Scenario: 重复加锁失败 +- **WHEN** 另一个进程已持有同一锁文件的排他锁 +- **AND** 调用 `Lock()` +- **THEN** 加锁失败,返回错误 + +#### Scenario: 释放锁 +- **WHEN** 调用 `Unlock()` +- **THEN** 排他锁被释放 +- **AND** 其他进程可以成功加锁 + +#### Scenario: 进程崩溃后锁自动释放 +- **WHEN** 持有锁的进程异常退出(崩溃、kill) +- **THEN** 操作系统自动释放文件锁 +- **AND** 新进程可以成功加锁 + +> 注:此场景由操作系统和 `gofrs/flock` 库保证,无需显式单元测试。 + +### Requirement: 锁路径参数化 + +`SingletonLock` SHALL 通过构造函数参数接收锁文件路径,支持不同运行上下文使用不同锁文件。 + +#### Scenario: 正式运行锁路径 +- **WHEN** 桌面应用正常启动 +- **THEN** 使用 `%TEMP%/nex-gateway.lock` 作为锁文件路径 + +#### Scenario: 测试运行锁路径 +- **WHEN** 运行 `go test` +- **THEN** 测试代码使用独立的锁文件路径(如 `%TEMP%/nex-gateway-test.lock`) +- **AND** 与正式版锁文件互不影响 + +#### Scenario: 锁路径隔离 +- **WHEN** 正式版桌面应用正在运行 +- **AND** 同时执行 `go test` +- **THEN** 正式版持有 `nex-gateway.lock` +- **AND** 测试持有 `nex-gateway-test.lock` +- **AND** 两者互不阻塞 diff --git a/openspec/specs/desktop-app/spec.md b/openspec/specs/desktop-app/spec.md index 9758f23..f509a25 100644 --- a/openspec/specs/desktop-app/spec.md +++ b/openspec/specs/desktop-app/spec.md @@ -12,16 +12,23 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 #### Scenario: 双击启动 - **WHEN** 用户双击桌面应用可执行文件 -- **THEN** 系统启动后端服务 +- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁 +- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock` +- **AND** 系统启动后端服务 - **AND** 系统托盘图标出现 - **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面 #### Scenario: 单实例检查 - **WHEN** 用户尝试启动第二个实例 -- **THEN** 系统检测到已有实例运行 +- **THEN** 系统检测到已有实例持有文件锁 - **AND** 显示错误提示"已有 Nex 实例运行" - **AND** 新实例退出 +#### Scenario: 退出释放锁 +- **WHEN** 用户点击托盘菜单"退出" +- **THEN** 系统释放文件锁 +- **AND** 应用进程退出 + ### Requirement: 系统托盘 系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。