feat: 增强桌面启动失败提示与测试覆盖
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
@@ -27,10 +28,10 @@ import (
|
||||
"nex/backend/internal/service"
|
||||
"nex/backend/pkg/buildinfo"
|
||||
|
||||
"github.com/getlantern/systray"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofrs/flock"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
pkgLogger "nex/backend/pkg/logger"
|
||||
)
|
||||
@@ -40,31 +41,65 @@ var (
|
||||
zapLogger *zap.Logger
|
||||
shutdownCtx context.Context
|
||||
shutdownCancel context.CancelFunc
|
||||
desktopHooks = defaultDesktopRuntimeHooks()
|
||||
)
|
||||
|
||||
type singletonLocker interface {
|
||||
Lock() error
|
||||
Unlock() error
|
||||
}
|
||||
|
||||
type desktopRuntimeHooks struct {
|
||||
loadConfig func() (*config.Config, config.ConfigMetadata, error)
|
||||
newLock func(string) singletonLocker
|
||||
listen func(int) (net.Listener, error)
|
||||
upgradeLogger func(*zap.Logger, pkgLogger.Config) (*zap.Logger, error)
|
||||
initDB func(*config.DatabaseConfig, *zap.Logger) (*gorm.DB, error)
|
||||
closeDB func(*gorm.DB)
|
||||
registerAdapters func(conversion.AdapterRegistry) error
|
||||
setupStaticFiles func(*gin.Engine) error
|
||||
startServer func(*http.Server, net.Listener, chan<- error, *zap.Logger)
|
||||
setupSystray func(int, <-chan error) error
|
||||
}
|
||||
|
||||
func defaultDesktopRuntimeHooks() desktopRuntimeHooks {
|
||||
return desktopRuntimeHooks{
|
||||
loadConfig: config.LoadDesktopConfigWithMetadata,
|
||||
newLock: func(lockPath string) singletonLocker { return NewSingletonLock(lockPath) },
|
||||
listen: listenDesktopPort,
|
||||
upgradeLogger: pkgLogger.Upgrade,
|
||||
initDB: database.Init,
|
||||
closeDB: database.Close,
|
||||
registerAdapters: registerDesktopAdapters,
|
||||
setupStaticFiles: setupStaticFiles,
|
||||
startServer: startDesktopServer,
|
||||
setupSystray: setupSystray,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
minimalLogger := pkgLogger.NewMinimal()
|
||||
|
||||
cfg, cfgMeta, err := config.LoadDesktopConfigWithMetadata()
|
||||
if err != nil {
|
||||
minimalLogger.Error("加载配置失败", zap.Error(err))
|
||||
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
|
||||
if err := runDesktop(minimalLogger); err != nil {
|
||||
reportStartupFailure(err, dialogLogger())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runDesktop(minimalLogger *zap.Logger) error {
|
||||
if minimalLogger == nil {
|
||||
minimalLogger = pkgLogger.NewMinimal()
|
||||
}
|
||||
|
||||
cfg, cfgMeta, err := desktopHooks.loadConfig()
|
||||
if err != nil {
|
||||
return newStartupError(phaseConfig, desktopConfigErrorMessage(getDesktopConfigPath(), err), err)
|
||||
}
|
||||
|
||||
port := cfg.Server.Port
|
||||
|
||||
if err := checkPortAvailable(port); err != nil {
|
||||
minimalLogger.Error("端口不可用", zap.Error(err))
|
||||
showError(appName, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
|
||||
singleLock := desktopHooks.newLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
|
||||
if err := singleLock.Lock(); err != nil {
|
||||
minimalLogger.Error("已有 Nex 实例运行")
|
||||
showError(appName, "已有 Nex 实例运行")
|
||||
os.Exit(1)
|
||||
return newStartupError(phaseSingleton, "已有 Nex 实例运行", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := singleLock.Unlock(); err != nil {
|
||||
@@ -72,7 +107,13 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
|
||||
listener, err := desktopHooks.listen(port)
|
||||
if err != nil {
|
||||
return newStartupError(phasePort, desktopPortUnavailableMessage(port), err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
zapLogger, err = desktopHooks.upgradeLogger(minimalLogger, pkgLogger.Config{
|
||||
Level: cfg.Log.Level,
|
||||
Path: cfg.Log.Path,
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
@@ -81,7 +122,7 @@ func main() {
|
||||
Compress: cfg.Log.Compress,
|
||||
})
|
||||
if err != nil {
|
||||
minimalLogger.Fatal("初始化日志失败", zap.Error(err))
|
||||
return newStartupError(phaseLogger, fmt.Sprintf("初始化日志失败\n\n日志目录: %s\n\n请检查目录权限或磁盘空间", cfg.Log.Path), err)
|
||||
}
|
||||
defer func() {
|
||||
if err := zapLogger.Sync(); err != nil {
|
||||
@@ -91,11 +132,17 @@ func main() {
|
||||
|
||||
cfg.PrintSummary(zapLogger)
|
||||
|
||||
db, err := database.Init(&cfg.Database, zapLogger)
|
||||
db, err := desktopHooks.initDB(&cfg.Database, zapLogger)
|
||||
if err != nil {
|
||||
zapLogger.Fatal("初始化数据库失败", zap.Error(err))
|
||||
phase := phaseDatabase
|
||||
message := fmt.Sprintf("数据库初始化失败\n\n请检查数据库配置、文件权限或连接状态\n\n%v", err)
|
||||
if errors.Is(err, database.ErrMigration) {
|
||||
phase = phaseMigration
|
||||
message = fmt.Sprintf("数据库迁移失败\n\n请查看日志或检查数据库迁移权限\n\n%v", err)
|
||||
}
|
||||
return newStartupError(phase, message, err)
|
||||
}
|
||||
defer database.Close(db)
|
||||
defer desktopHooks.closeDB(db)
|
||||
|
||||
providerRepo := repository.NewProviderRepository(db)
|
||||
modelRepo := repository.NewModelRepository(db)
|
||||
@@ -118,11 +165,8 @@ func main() {
|
||||
statsService := service.NewStatsService(statsRepo, statsBuffer)
|
||||
|
||||
registry := conversion.NewMemoryRegistry()
|
||||
if err := registry.Register(openai.NewAdapter()); err != nil {
|
||||
zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err))
|
||||
}
|
||||
if err := registry.Register(anthropic.NewAdapter()); err != nil {
|
||||
zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err))
|
||||
if err := desktopHooks.registerAdapters(registry); err != nil {
|
||||
return newStartupError(phaseAdapter, startupInternalErrorMessage(), err)
|
||||
}
|
||||
engine := conversion.NewConversionEngine(registry, zapLogger)
|
||||
|
||||
@@ -144,7 +188,9 @@ func main() {
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
|
||||
setupStaticFiles(r)
|
||||
if err := desktopHooks.setupStaticFiles(r); err != nil {
|
||||
return newStartupError(phaseStaticResource, startupInternalErrorMessage(), err)
|
||||
}
|
||||
|
||||
server = &http.Server{
|
||||
Addr: desktopListenAddr(port),
|
||||
@@ -154,26 +200,46 @@ func main() {
|
||||
}
|
||||
|
||||
shutdownCtx, shutdownCancel = context.WithCancel(context.Background())
|
||||
defer doShutdown()
|
||||
|
||||
serverErrCh := make(chan error, 1)
|
||||
desktopHooks.startServer(server, listener, serverErrCh, zapLogger)
|
||||
select {
|
||||
case err := <-serverErrCh:
|
||||
return newStartupError(phaseServer, startupServerErrorMessage(), err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := desktopHooks.setupSystray(port, serverErrCh); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-serverErrCh:
|
||||
return newStartupError(phaseServer, startupServerErrorMessage(), err)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func registerDesktopAdapters(registry conversion.AdapterRegistry) error {
|
||||
if err := registry.Register(openai.NewAdapter()); err != nil {
|
||||
return err
|
||||
}
|
||||
return registry.Register(anthropic.NewAdapter())
|
||||
}
|
||||
|
||||
func startDesktopServer(server *http.Server, listener net.Listener, serverErrCh chan<- error, logger *zap.Logger) {
|
||||
go func() {
|
||||
zapLogger.Info("AI Gateway 启动",
|
||||
logger.Info("AI Gateway 启动",
|
||||
zap.String("addr", server.Addr),
|
||||
zap.String("version", buildinfo.Version()),
|
||||
zap.String("commit", buildinfo.Commit()),
|
||||
zap.String("build_time", buildinfo.BuildTime()))
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
zapLogger.Fatal("服务器启动失败", zap.Error(err))
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
serverErrCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if err := openBrowser(desktopURL(port)); err != nil {
|
||||
zapLogger.Warn("无法打开浏览器", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
setupSystray(port)
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
|
||||
@@ -223,12 +289,13 @@ func withProtocol(protocol string, next gin.HandlerFunc) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func setupStaticFiles(r *gin.Engine) {
|
||||
func setupStaticFiles(r *gin.Engine) error {
|
||||
distFS, err := frontendDistFS()
|
||||
if err != nil {
|
||||
zapLogger.Fatal("无法加载前端资源", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
setupStaticFilesWithFS(r, distFS)
|
||||
return nil
|
||||
}
|
||||
|
||||
func frontendDistFS() (fs.FS, error) {
|
||||
@@ -299,47 +366,6 @@ func setupStaticFilesWithFS(r *gin.Engine, distFS fs.FS) {
|
||||
})
|
||||
}
|
||||
|
||||
func setupSystray(port int) {
|
||||
systray.Run(func() {
|
||||
var icon []byte
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
icon, err = embedfs.Assets.ReadFile("assets/icon.ico")
|
||||
} else {
|
||||
icon, err = embedfs.Assets.ReadFile("assets/icon.png")
|
||||
}
|
||||
if err != nil {
|
||||
zapLogger.Error("无法加载托盘图标", zap.Error(err))
|
||||
}
|
||||
systray.SetIcon(icon)
|
||||
systray.SetTooltip(appTooltip)
|
||||
|
||||
mOpen := systray.AddMenuItem("打开管理界面", "在浏览器中打开")
|
||||
systray.AddSeparator()
|
||||
mStatus := systray.AddMenuItem("状态: 运行中", "")
|
||||
mStatus.Disable()
|
||||
mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "")
|
||||
mPort.Disable()
|
||||
systray.AddSeparator()
|
||||
mQuit := systray.AddMenuItem("退出", "停止服务并退出")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-mOpen.ClickedCh:
|
||||
if err := openBrowser(desktopURL(port)); err != nil {
|
||||
zapLogger.Warn("打开浏览器失败", zap.Error(err))
|
||||
}
|
||||
case <-mQuit.ClickedCh:
|
||||
doShutdown()
|
||||
systray.Quit()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func doShutdown() {
|
||||
if zapLogger != nil {
|
||||
zapLogger.Info("正在关闭服务器...")
|
||||
@@ -382,13 +408,12 @@ func desktopPortMenuTitle(port int) string {
|
||||
return fmt.Sprintf("端口: %d", port)
|
||||
}
|
||||
|
||||
func checkPortAvailable(port int) error {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
|
||||
}
|
||||
ln.Close()
|
||||
return nil
|
||||
func listenDesktopPort(port int) (net.Listener, error) {
|
||||
return net.Listen("tcp", desktopListenAddr(port))
|
||||
}
|
||||
|
||||
func desktopPortUnavailableMessage(port int) string {
|
||||
return fmt.Sprintf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
|
||||
}
|
||||
|
||||
type SingletonLock struct {
|
||||
|
||||
Reference in New Issue
Block a user