From 5513f0c13d5e1499e61669cb6815748272444ff2 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 6 May 2026 11:59:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8C=BA=E5=88=86=20server=20=E4=B8=8E?= =?UTF-8?q?=20desktop=20=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E5=8F=96=E6=B6=88=E8=87=AA=E5=8A=A8=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.go 重构:抽取 loadConfig 共享逻辑,新增 LoadServerConfig/LoadDesktopConfig/LoadDesktopConfigAtPath,LoadConfig 保持向后兼容 - setupConfigFile 移除 SafeWriteConfigAs 自动创建逻辑,文件不存在时仅使用默认值 - cmd/desktop 切换为 LoadDesktopConfig,端口/HTTP/浏览器/托盘统一使用 cfg.Server.Port - cmd/server 显式使用 LoadServerConfig 明确入口语义 - 提取 desktop 可测 helper:desktopListenAddr/desktopURL/desktopPortMenuTitle/desktopConfigErrorMessage - 新增测试:desktop 忽略 CLI/env/未知参数、配置快照不变、无效配置文件不静默回退、端口 helper 一致性 - README 区分 server/desktop 配置源,移除首次启动自动创建配置文件描述 - 同步 delta specs 到 openspec/specs/ 主规范 --- README.md | 22 +++- backend/README.md | 17 ++- backend/cmd/desktop/main.go | 60 ++++++--- backend/cmd/desktop/port_test.go | 60 +++++++++ backend/cmd/server/main.go | 2 +- backend/internal/config/config.go | 148 ++++++++++++++------- backend/tests/config/config_test.go | 126 +++++++++++++++++- openspec/specs/config-management/spec.md | 157 +++++++++++++++-------- openspec/specs/desktop-app/spec.md | 92 +++++++++++-- openspec/specs/test-coverage/spec.md | 52 +++++++- 10 files changed, 589 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index fd3a80f..dec88c8 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ nex/ - **ORM**: GORM - **数据库**: SQLite / MySQL - **日志**: zap + lumberjack(结构化日志 + 日志轮转 + 模块标识) -- **配置**: Viper + pflag(多层配置:CLI > 环境变量 > 配置文件 > 默认值) +- **配置**: Viper + pflag(Server 多层配置,Desktop 配置文件快照) - **验证**: go-playground/validator/v10 - **迁移**: goose @@ -147,7 +147,6 @@ make server-run - 前端开发服务器:`http://localhost:5173` 前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动: -- 创建配置文件 `~/.nex/config.yaml` - 初始化数据库 `~/.nex/config.db` - 运行数据库迁移 - 创建日志目录 `~/.nex/log/` @@ -245,11 +244,14 @@ server 和 desktop 发布产物自包含运行时数据库迁移资源(通过 ## 配置 -配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值** +配置方式取决于启动模式: + +- **Server 模式**(`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值 +- **Desktop 模式**(`cmd/desktop`):仅支持配置文件 `~/.nex/config.yaml` > 默认值,修改配置文件后需重启 desktop 生效 ### 配置文件 -配置文件位于 `~/.nex/config.yaml`,首次启动自动生成: +配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件: ```yaml server: @@ -279,9 +281,9 @@ log: compress: true ``` -### 环境变量 +### 环境变量(仅 Server 模式) -所有配置项支持环境变量,使用 `NEX_` 前缀: +Server 模式下,所有配置项支持环境变量,使用 `NEX_` 前缀: ```bash export NEX_SERVER_PORT=9000 @@ -299,7 +301,11 @@ export NEX_DATABASE_DBNAME=nex 命名规则:配置路径转大写 + 下划线(如 `server.port` → `NEX_SERVER_PORT`)。 -### CLI 参数 +**Desktop 模式不支持环境变量覆盖。**Desktop 仅从 `~/.nex/config.yaml` 和默认值读取配置。 + +### CLI 参数(仅 Server 模式) + +Server 模式下,支持命令行参数: ```bash ./server --server-port 9000 --log-level debug --database-path /tmp/test.db @@ -307,6 +313,8 @@ export NEX_DATABASE_DBNAME=nex 命名规则:配置路径转 kebab-case(如 `server.port` → `--server-port`)。 +**Desktop 不支持命令行参数覆盖配置。**Desktop 忽略所有 CLI 参数,仅从 `~/.nex/config.yaml` 读取。 + ### 数据文件 - `~/.nex/config.yaml` - 配置文件 diff --git a/backend/README.md b/backend/README.md index a54667f..bfd41cd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -72,7 +72,7 @@ GORM 日志自动桥接到 zap,SQL 查询映射到 Debug 级别。 - **ORM**: GORM - **数据库**: SQLite / MySQL - **日志**: zap + lumberjack -- **配置**: Viper + pflag(多层配置:CLI > 环境变量 > 配置文件 > 默认值) +- **配置**: Viper + pflag(Server 多层配置,Desktop 配置文件快照) - **验证**: go-playground/validator/v10 - **迁移**: goose @@ -334,15 +334,18 @@ go mod download go run cmd/server/main.go ``` -服务将在端口 9826 启动。首次启动会自动创建配置文件和运行数据库迁移。 +服务将在端口 9826 启动。首次启动会自动运行数据库迁移。 ## 配置 -配置支持多种方式:配置文件、环境变量、命令行参数,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值** +配置方式取决于启动入口: + +- **Server 入口**(`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值 +- **Desktop 入口**(`cmd/desktop`):仅支持 `~/.nex/config.yaml` > 默认值,不支持 CLI 参数和 `NEX_*` 环境变量覆盖,修改配置文件后需重启生效 ### 配置文件 -配置文件位于 `~/.nex/config.yaml`,首次启动自动生成。 +配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件: ```yaml server: @@ -372,9 +375,9 @@ log: compress: true ``` -### 环境变量 +### 环境变量(仅 Server 入口) -所有配置项都支持环境变量,使用 `NEX_` 前缀: +Server 入口下,所有配置项都支持环境变量,使用 `NEX_` 前缀: ```bash export NEX_SERVER_PORT=9000 @@ -392,7 +395,7 @@ export NEX_DATABASE_DBNAME=nex 命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port` → `NEX_SERVER_PORT`)。 -### 命令行参数 +### 命令行参数(仅 Server 入口) ```bash ./server --server-port 9000 --log-level debug --database-path /tmp/test.db diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go index 20a3e7f..30ca48b 100644 --- a/backend/cmd/desktop/main.go +++ b/backend/cmd/desktop/main.go @@ -43,10 +43,23 @@ var ( ) func main() { - port := 9826 - minimalLogger := pkgLogger.NewMinimal() + cfg, err := config.LoadDesktopConfig() + if err != nil { + minimalLogger.Error("加载配置失败", zap.Error(err)) + showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err)) + os.Exit(1) + } + + 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")) if err := singleLock.Lock(); err != nil { minimalLogger.Error("已有 Nex 实例运行") @@ -59,17 +72,6 @@ func main() { } }() - if err := checkPortAvailable(port); err != nil { - minimalLogger.Error("端口不可用", zap.Error(err)) - showError(appName, err.Error()) - return - } - - cfg, err := config.LoadConfig() - if err != nil { - minimalLogger.Fatal("加载配置失败", zap.Error(err)) - } - zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{ Level: cfg.Log.Level, Path: cfg.Log.Path, @@ -144,7 +146,7 @@ func main() { setupStaticFiles(r) server = &http.Server{ - Addr: fmt.Sprintf(":%d", port), + Addr: desktopListenAddr(port), Handler: r, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, @@ -165,7 +167,7 @@ func main() { go func() { time.Sleep(500 * time.Millisecond) - if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil { + if err := openBrowser(desktopURL(port)); err != nil { zapLogger.Warn("无法打开浏览器", zap.Error(err)) } }() @@ -309,7 +311,7 @@ func setupSystray(port int) { systray.AddSeparator() mStatus := systray.AddMenuItem("状态: 运行中", "") mStatus.Disable() - mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "") + mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "") mPort.Disable() systray.AddSeparator() mQuit := systray.AddMenuItem("退出", "停止服务并退出") @@ -318,7 +320,7 @@ func setupSystray(port int) { for { select { case <-mOpen.ClickedCh: - if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil { + if err := openBrowser(desktopURL(port)); err != nil { zapLogger.Warn("打开浏览器失败", zap.Error(err)) } case <-mQuit.ClickedCh: @@ -349,6 +351,30 @@ func doShutdown() { } } +func getDesktopConfigPath() string { + configDir, err := config.GetConfigDir() + if err != nil { + return "~/.nex/config.yaml" + } + return filepath.Join(configDir, "config.yaml") +} + +func desktopConfigErrorMessage(configPath string, err error) string { + return fmt.Sprintf("加载配置失败\n\n配置文件: %s\n\n%v", configPath, err) +} + +func desktopListenAddr(port int) string { + return fmt.Sprintf(":%d", port) +} + +func desktopURL(port int) string { + return fmt.Sprintf("http://localhost:%d", port) +} + +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 { diff --git a/backend/cmd/desktop/port_test.go b/backend/cmd/desktop/port_test.go index 1e222af..9c20edd 100644 --- a/backend/cmd/desktop/port_test.go +++ b/backend/cmd/desktop/port_test.go @@ -4,6 +4,7 @@ import ( "errors" "net" "net/http" + "strings" "testing" "time" ) @@ -67,3 +68,62 @@ func TestCheckPortAvailableAfterClose(t *testing.T) { t.Log("端口关闭后可用测试通过") } + +func TestCheckPortAvailableErrorContainsPort(t *testing.T) { + port := 19829 + + listener, err := net.Listen("tcp", ":19829") //nolint:gosec + if err != nil { + t.Fatalf("无法启动测试服务器: %v", err) + } + defer listener.Close() + + time.Sleep(100 * time.Millisecond) + + err = checkPortAvailable(port) + if err == nil { + t.Fatal("端口被占用时应该返回错误") + } + + if !strings.Contains(err.Error(), "19829") { + t.Fatalf("错误信息应包含端口号 19829,实际: %v", err) + } + + t.Log("端口错误信息包含端口号测试通过") +} + +func TestGetDesktopConfigPath(t *testing.T) { + path := getDesktopConfigPath() + if path == "" { + t.Fatal("getDesktopConfigPath 应返回非空路径") + } + if !strings.Contains(path, "config.yaml") { + t.Fatalf("路径应包含 config.yaml,实际: %s", path) + } + t.Log("getDesktopConfigPath 测试通过") +} + +func TestDesktopConfiguredPortHelpers(t *testing.T) { + port := 19830 + + if got := desktopListenAddr(port); got != ":19830" { + t.Fatalf("HTTP 监听地址应使用配置端口,实际: %s", got) + } + if got := desktopURL(port); got != "http://localhost:19830" { + t.Fatalf("浏览器 URL 应使用配置端口,实际: %s", got) + } + if got := desktopPortMenuTitle(port); got != "端口: 19830" { + t.Fatalf("托盘端口显示应使用配置端口,实际: %s", got) + } +} + +func TestDesktopConfigErrorMessageContainsPathAndReason(t *testing.T) { + msg := desktopConfigErrorMessage("/tmp/nex/config.yaml", errors.New("yaml parse failed")) + + if !strings.Contains(msg, "/tmp/nex/config.yaml") { + t.Fatalf("配置错误提示应包含配置路径,实际: %s", msg) + } + if !strings.Contains(msg, "yaml parse failed") { + t.Fatalf("配置错误提示应包含失败原因,实际: %s", msg) + } +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 15ff664..ffce8bc 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -29,7 +29,7 @@ import ( func main() { minimalLogger := pkgLogger.NewMinimal() - cfg, err := config.LoadConfig() + cfg, err := config.LoadServerConfig() if err != nil { minimalLogger.Fatal("加载配置失败", zap.Error(err)) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 587f12a..ef2a01a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,7 +1,6 @@ package config import ( - "errors" "fmt" "os" "path/filepath" @@ -225,68 +224,71 @@ func setupConfigFile(v *viper.Viper, configPath string) error { v.SetConfigFile(configPath) v.SetConfigType("yaml") - // 尝试读取配置文件,如果不存在则忽略 if err := v.ReadInConfig(); err != nil { - if !os.IsNotExist(err) { - return appErrors.Wrap(appErrors.ErrInternal, err) - } - // 配置文件不存在,创建默认配置文件 - writeErr := v.SafeWriteConfigAs(configPath) - if writeErr == nil { + if os.IsNotExist(err) { return nil } - - var alreadyExistsErr viper.ConfigFileAlreadyExistsError - if errors.As(writeErr, &alreadyExistsErr) { - return nil - } - - return appErrors.Wrap(appErrors.ErrInternal, writeErr) + return appErrors.Wrap(appErrors.ErrInternal, err) } return nil } -// LoadConfig loads config from YAML file, creates default if not exists -func LoadConfig() (*Config, error) { - configPath, err := GetConfigPath() - if err != nil { - return nil, appErrors.Wrap(appErrors.ErrInternal, err) - } - return LoadConfigFromPath(configPath) +// loadOptions 控制配置加载器行为 +type loadOptions struct { + configPathOverride string + useCLI bool + useEnv bool + useConfigFlag bool } -// LoadConfigFromPath 从指定路径加载配置 -func LoadConfigFromPath(configPath string) (*Config, error) { - // 1. 创建 Viper 实例 +// resolveConfigPath 根据 loadOptions 解析 CLI 参数并返回最终配置文件路径 +func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) { + configPath := opts.configPathOverride + + if !opts.useCLI && !opts.useConfigFlag { + return configPath, nil + } + + flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError) + if opts.useConfigFlag { + flagSet.String("config", opts.configPathOverride, "配置文件路径") + } + if opts.useCLI { + setupFlags(v, flagSet) + } + + if err := flagSet.Parse(os.Args[1:]); err != nil { + return "", appErrors.Wrap(appErrors.ErrInvalidRequest, err) + } + + if opts.useConfigFlag { + if f, err := flagSet.GetString("config"); err == nil && f != "" { + configPath = f + } + } + + return configPath, nil +} + +// loadConfig 共享配置加载逻辑,通过 loadOptions 控制是否启用 CLI、环境变量和 --config 覆盖 +func loadConfig(opts loadOptions) (*Config, error) { v := viper.New() - // 2. 定义 CLI 参数 - flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError) - flagSet.String("config", configPath, "配置文件路径") - setupFlags(v, flagSet) - - // 3. 解析 CLI 参数(忽略错误,因为可能没有参数) - if err := flagSet.Parse(os.Args[1:]); err != nil { - return nil, appErrors.Wrap(appErrors.ErrInvalidRequest, err) - } - - // 4. 获取配置文件路径(可能被 --config 参数覆盖) - if configPathFlag, err := flagSet.GetString("config"); err == nil && configPathFlag != "" { - configPath = configPathFlag - } - - // 5. 设置默认值 setupDefaults(v) - // 6. 绑定环境变量 - setupEnv(v) + configPath, err := resolveConfigPath(v, opts) + if err != nil { + return nil, err + } + + if opts.useEnv { + setupEnv(v) + } - // 7. 读取配置文件 if err := setupConfigFile(v, configPath); err != nil { return nil, err } - // 8. 反序列化到结构体 cfg := &Config{} if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), @@ -295,7 +297,6 @@ func LoadConfigFromPath(configPath string) (*Config, error) { return nil, appErrors.Wrap(appErrors.ErrInternal, err) } - // 9. 验证配置 if err := cfg.Validate(); err != nil { return nil, err } @@ -303,6 +304,61 @@ func LoadConfigFromPath(configPath string) (*Config, error) { return cfg, nil } +// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config +func LoadServerConfig() (*Config, error) { + configPath, err := GetConfigPath() + if err != nil { + return nil, appErrors.Wrap(appErrors.ErrInternal, err) + } + return loadConfig(loadOptions{ + configPathOverride: configPath, + useCLI: true, + useEnv: true, + useConfigFlag: true, + }) +} + +// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config +func LoadDesktopConfig() (*Config, error) { + configPath, err := GetConfigPath() + if err != nil { + return nil, appErrors.Wrap(appErrors.ErrInternal, err) + } + return loadConfig(loadOptions{ + configPathOverride: configPath, + useCLI: false, + useEnv: false, + useConfigFlag: false, + }) +} + +// LoadConfig loads config from YAML file. +// 向后兼容,等同于 LoadServerConfig。 +func LoadConfig() (*Config, error) { + return LoadServerConfig() +} + +// LoadConfigFromPath 从指定路径加载配置。 +// 保留向后兼容,沿用 server 语义(支持 CLI、env 和 --config 覆盖)。 +func LoadConfigFromPath(configPath string) (*Config, error) { + return loadConfig(loadOptions{ + configPathOverride: configPath, + useCLI: true, + useEnv: true, + useConfigFlag: true, + }) +} + +// LoadDesktopConfigAtPath 从指定路径以 desktop 语义加载配置(仅配置文件和默认值),用于测试场景。 +func LoadDesktopConfigAtPath(configPath string) (*Config, error) { + return loadConfig(loadOptions{ + configPathOverride: configPath, + useCLI: false, + useEnv: false, + useConfigFlag: false, + }) +} + // SaveConfig saves config to YAML file func SaveConfig(cfg *Config) error { configPath, err := GetConfigPath() diff --git a/backend/tests/config/config_test.go b/backend/tests/config/config_test.go index 50dce41..b06eec8 100644 --- a/backend/tests/config/config_test.go +++ b/backend/tests/config/config_test.go @@ -120,7 +120,7 @@ log: assert.Equal(t, "warn", cfg.Log.Level, "YAML value should be used when no CLI/ENV override") } -func TestLoadConfig_AutoCreate(t *testing.T) { +func TestLoadConfig_NoAutoCreate(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.yaml") @@ -132,6 +132,9 @@ func TestLoadConfig_AutoCreate(t *testing.T) { require.NotNil(t, cfg) assert.Equal(t, 9826, cfg.Server.Port, "should load with default values") + + _, err = os.Stat(configPath) + assert.True(t, os.IsNotExist(err), "config file should not be auto-created") } func TestSaveAndLoadConfig(t *testing.T) { @@ -184,3 +187,124 @@ func TestSaveAndLoadConfig(t *testing.T) { assert.Equal(t, cfg.Log.MaxAge, loaded.Log.MaxAge) assert.Equal(t, cfg.Log.Compress, loaded.Log.Compress) } + +func TestLoadDesktopConfig_FileOnly(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + yamlContent := ` +server: + port: 8080 +log: + level: debug +` + err := os.WriteFile(configPath, []byte(yamlContent), 0o600) + require.NoError(t, err) + + cfg, err := config.LoadDesktopConfigAtPath(configPath) + require.NoError(t, err) + + assert.Equal(t, 8080, cfg.Server.Port) + assert.Equal(t, "debug", cfg.Log.Level) +} + +func TestLoadDesktopConfig_IgnoresCLI(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600) + require.NoError(t, err) + + originalArgs := os.Args + defer func() { os.Args = originalArgs }() + os.Args = []string{"nex", "--server-port", "9999"} + + cfg, err := config.LoadDesktopConfigAtPath(configPath) + require.NoError(t, err) + + assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore CLI args and use config file") +} + +func TestLoadDesktopConfig_IgnoresEnv(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600) + require.NoError(t, err) + + t.Setenv("NEX_SERVER_PORT", "9000") + t.Setenv("NEX_LOG_LEVEL", "debug") + + cfg, err := config.LoadDesktopConfigAtPath(configPath) + require.NoError(t, err) + + assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore env vars and use config file") + assert.Equal(t, "info", cfg.Log.Level, "desktop should ignore env vars and use default") +} + +func TestLoadDesktopConfig_IgnoresUnknownArgs(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600) + require.NoError(t, err) + + originalArgs := os.Args + defer func() { os.Args = originalArgs }() + os.Args = []string{"nex", "--unknown-flag", "value", "--another-unknown"} + + cfg, err := config.LoadDesktopConfigAtPath(configPath) + require.NoError(t, err, "desktop should not fail on unknown CLI args") + + assert.Equal(t, 8080, cfg.Server.Port) +} + +func TestLoadDesktopConfig_Snapshot(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600) + require.NoError(t, err) + + cfg, err := config.LoadDesktopConfigAtPath(configPath) + require.NoError(t, err) + assert.Equal(t, 8080, cfg.Server.Port) + + err = os.WriteFile(configPath, []byte("server:\n port: 9999\n"), 0o600) + require.NoError(t, err) + + assert.Equal(t, 8080, cfg.Server.Port, "loaded config snapshot should not change when file changes") + + cfg2, err := config.LoadDesktopConfigAtPath(configPath) + require.NoError(t, err) + assert.Equal(t, 9999, cfg2.Server.Port, "reload should pick up new config values") +} + +func TestLoadDesktopConfig_InvalidFileFails(t *testing.T) { + tests := []struct { + name string + content string + }{ + { + name: "invalid yaml", + content: "server:\n port: [\n", + }, + { + name: "validation failure", + content: "server:\n port: 70000\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + err := os.WriteFile(configPath, []byte(tt.content), 0o600) + require.NoError(t, err) + + _, err = config.LoadDesktopConfigAtPath(configPath) + require.Error(t, err, "desktop should not silently fall back to defaults for invalid config files") + }) + } +} diff --git a/openspec/specs/config-management/spec.md b/openspec/specs/config-management/spec.md index d51e848..5f5912b 100644 --- a/openspec/specs/config-management/spec.md +++ b/openspec/specs/config-management/spec.md @@ -8,20 +8,27 @@ ### Requirement: 使用 YAML 配置文件 -系统 SHALL 使用 YAML 格式的配置文件。 +系统 SHALL 使用 YAML 格式的配置文件,并按入口区分配置文件路径选择能力。 -#### Scenario: 配置文件路径 +#### Scenario: Server 默认配置文件路径 -- **WHEN** 应用启动且未指定 `--config` 参数 +- **WHEN** server 应用启动且未指定 `--config` 参数 - **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置 - **THEN** SHALL 解析 YAML 格式 -#### Scenario: 自定义配置文件路径 +#### Scenario: Server 自定义配置文件路径 -- **WHEN** 应用启动且指定 `--config /path/to/custom.yaml` +- **WHEN** server 应用启动且指定 `--config /path/to/custom.yaml` - **THEN** SHALL 从指定路径加载配置文件 - **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml` +#### Scenario: Desktop 固定配置文件路径 + +- **WHEN** desktop 应用启动 +- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置 +- **THEN** SHALL 解析 YAML 格式 +- **THEN** SHALL NOT 支持通过 `--config` 指定其他配置文件路径 + #### Scenario: 配置文件结构 - **WHEN** 加载配置文件 @@ -30,14 +37,14 @@ ### Requirement: 自动生成默认配置 -系统 SHALL 在首次使用时自动生成默认配置。 +系统 SHALL 在配置文件不存在时使用默认配置值,不自动创建配置文件。 #### Scenario: 配置文件不存在 - **WHEN** 应用启动且配置文件不存在 -- **THEN** SHALL 自动创建配置文件 -- **THEN** SHALL 写入默认配置值 -- **THEN** SHALL 记录日志提示已创建 +- **THEN** SHALL 使用默认配置值 +- **THEN** SHALL NOT 自动创建配置文件 +- **THEN** SHALL NOT 写入默认配置值到磁盘 #### Scenario: 配置文件已存在 @@ -163,22 +170,36 @@ ### Requirement: 配置加载流程 -系统 SHALL 实现标准化的配置加载流程。 +系统 SHALL 为 server 和 desktop 实现标准化且入口隔离的配置加载流程。 -#### Scenario: 加载步骤 +#### Scenario: Server 加载步骤 -- **WHEN** 应用启动 +- **WHEN** server 应用启动 - **THEN** SHALL 按以下顺序加载配置: 1. 解析 CLI 参数(获取 --config 路径) 2. 初始化配置管理器 3. 设置默认值 4. 绑定 CLI 参数 5. 绑定环境变量 - 6. 读取配置文件(不存在时自动创建) + 6. 读取配置文件(不存在时使用默认值) 7. 反序列化到结构体 8. 验证配置 9. 打印配置摘要 +#### Scenario: Desktop 加载步骤 + +- **WHEN** desktop 应用启动 +- **THEN** SHALL 按以下顺序加载配置: + 1. 初始化配置管理器 + 2. 设置默认值 + 3. 读取默认配置文件 `~/.nex/config.yaml`(不存在时使用默认值) + 4. 反序列化到结构体 + 5. 验证配置 + 6. 打印配置摘要 +- **THEN** SHALL NOT 解析 CLI 参数 +- **THEN** SHALL NOT 绑定环境变量 +- **THEN** SHALL NOT 允许 CLI 参数覆盖配置文件路径 + #### Scenario: 加载失败处理 - **WHEN** 配置加载过程中发生错误 @@ -188,25 +209,25 @@ ### Requirement: 配置优先级管理 -系统 SHALL 实现明确的配置优先级机制。 +系统 SHALL 为不同入口实现明确的配置优先级机制。 -#### Scenario: 优先级顺序 +#### Scenario: Server 优先级顺序 -- **WHEN** 同一配置项在多个配置源中设置 +- **WHEN** 同一配置项在多个 server 配置源中设置 - **THEN** SHALL 按以下优先级顺序(从高到低): 1. CLI 参数 2. 环境变量 3. 配置文件 4. 默认值 -#### Scenario: CLI 参数最高优先级 +#### Scenario: Server CLI 参数最高优先级 - **WHEN** 配置文件设置 `server.port: 9826` - **AND** 环境变量设置 `NEX_SERVER_PORT=9000` -- **AND** CLI 参数设置 `--server-port 8080` +- **AND** server CLI 参数设置 `--server-port 8080` - **THEN** SHALL 使用 CLI 参数值 8080 -#### Scenario: 环境变量次高优先级 +#### Scenario: Server 环境变量次高优先级 - **WHEN** 配置文件设置 `server.port: 9826` - **AND** 环境变量设置 `NEX_SERVER_PORT=9000` @@ -227,21 +248,35 @@ - **AND** 未设置 CLI 参数 - **THEN** SHALL 使用默认值 -#### Scenario: 部分配置覆盖 +#### Scenario: Server 部分配置覆盖 - **WHEN** 配置文件设置完整配置 -- **AND** CLI 参数仅覆盖部分配置项 +- **AND** server CLI 参数仅覆盖部分配置项 - **THEN** SHALL 合并所有配置源 - **THEN** SHALL 使用高优先级源覆盖指定项 - **THEN** SHALL 保留其他配置源中的未覆盖项 -#### Scenario: 配置项独立覆盖 +#### Scenario: Server 配置项独立覆盖 -- **WHEN** 仅通过 CLI 参数设置 `--server-port 9000` +- **WHEN** 仅通过 server CLI 参数设置 `--server-port 9000` - **THEN** SHALL 仅覆盖 server.port 配置项 - **THEN** SHALL NOT 影响其他配置项 - **THEN** SHALL 其他配置项使用配置文件或默认值 +#### Scenario: Desktop 优先级顺序 + +- **WHEN** 同一配置项存在于 desktop 默认配置文件和默认值中 +- **THEN** SHALL 使用 `~/.nex/config.yaml` 中的配置文件值 +- **THEN** SHALL 仅在配置文件未设置该配置项时使用默认值 + +#### Scenario: Desktop 忽略外部覆盖源 + +- **WHEN** desktop 启动时存在 `--server-port 9000` 参数 +- **AND** 存在 `NEX_SERVER_PORT=9001` 环境变量 +- **AND** `~/.nex/config.yaml` 设置 `server.port: 9826` +- **THEN** SHALL 使用配置文件值 9826 +- **THEN** SHALL NOT 使用 CLI 参数或环境变量覆盖配置 + #### Scenario: 启动后配置锁定 - **WHEN** 应用启动完成 @@ -314,67 +349,79 @@ ### Requirement: CLI 参数配置支持 -系统 SHALL 支持通过命令行参数设置所有配置项。 +server 入口 SHALL 支持通过命令行参数设置所有配置项;desktop 入口 SHALL NOT 将命令行参数作为配置源。 -#### Scenario: 基本参数解析 +#### Scenario: Server 基本参数解析 -- **WHEN** 应用启动时传入命令行参数 +- **WHEN** server 应用启动时传入命令行参数 - **THEN** SHALL 解析所有 CLI 参数 - **THEN** SHALL 将参数值应用到对应配置项 #### Scenario: 参数命名规范 -- **WHEN** 使用命令行参数 +- **WHEN** server 使用命令行参数 - **THEN** SHALL 使用 kebab-case 命名(如 `--server-port`) - **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns`) - **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns` → `--database-max-idle-conns`) #### Scenario: 参数类型支持 -- **WHEN** 解析不同类型的参数 +- **WHEN** server 解析不同类型的参数 - **THEN** SHALL 支持 int 类型(如 `--server-port 9000`) - **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db`) - **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s`) - **THEN** SHALL 支持 bool 类型(如 `--log-compress`) -#### Scenario: 完整配置覆盖 +#### Scenario: Server 完整配置覆盖 -- **WHEN** 使用服务器相关参数 +- **WHEN** server 使用服务器相关参数 - **THEN** SHALL 支持 `--server-port`、`--server-read-timeout`、`--server-write-timeout` -- **WHEN** 使用数据库相关参数 +- **WHEN** server 使用数据库相关参数 - **THEN** SHALL 支持 `--database-driver`、`--database-path`、`--database-host`、`--database-port`、`--database-user`、`--database-password`、`--database-dbname`、`--database-max-idle-conns`、`--database-max-open-conns`、`--database-conn-max-lifetime` -- **WHEN** 使用日志相关参数 +- **WHEN** server 使用日志相关参数 - **THEN** SHALL 支持 `--log-level`、`--log-path`、`--log-max-size`、`--log-max-backups`、`--log-max-age`、`--log-compress` -#### Scenario: 参数帮助信息 +#### Scenario: Server 参数帮助信息 -- **WHEN** 使用 `--help` 参数 +- **WHEN** server 使用 `--help` 参数 - **THEN** SHALL 显示所有支持的参数 - **THEN** SHALL 按功能分组展示参数(服务器、数据库、日志) - **THEN** SHALL 显示每个参数的默认值和说明 -#### Scenario: 参数错误处理 +#### Scenario: Server 参数错误处理 -- **WHEN** 传入无效的参数值(如 `--server-port abc`) +- **WHEN** server 传入无效的参数值(如 `--server-port abc`) - **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型 - **THEN** SHALL NOT 启动应用 -- **WHEN** 传入未定义的参数(如 `--unknown-param value`) +- **WHEN** server 传入未定义的参数(如 `--unknown-param value`) - **THEN** SHALL 返回错误信息,指示未知参数名称 - **THEN** SHALL NOT 启动应用 +#### Scenario: Desktop 忽略配置参数 + +- **WHEN** desktop 启动时传入 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml` +- **THEN** SHALL 忽略这些参数 +- **THEN** SHALL 从 `~/.nex/config.yaml` 和默认值加载配置 + +#### Scenario: Desktop 忽略未知参数 + +- **WHEN** desktop 启动时传入未知命令行参数 +- **THEN** SHALL NOT 因未知参数导致配置加载失败 +- **THEN** SHALL NOT 将未知参数应用为配置 + ### Requirement: 环境变量配置支持 -系统 SHALL 支持通过环境变量设置所有配置项,符合 12-Factor App 原则。 +server 入口 SHALL 支持通过环境变量设置所有配置项,符合 server 部署场景的 12-Factor App 原则;desktop 入口 SHALL NOT 将 `NEX_*` 环境变量作为配置源。 -#### Scenario: 环境变量读取 +#### Scenario: Server 环境变量读取 -- **WHEN** 应用启动时存在环境变量 +- **WHEN** server 应用启动时存在环境变量 - **THEN** SHALL 自动读取所有 `NEX_` 前缀的环境变量 - **THEN** SHALL 将环境变量值应用到对应配置项 #### Scenario: 环境变量命名规范 -- **WHEN** 使用环境变量配置 +- **WHEN** server 使用环境变量配置 - **THEN** SHALL 使用 `NEX_` 前缀 - **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT`) - **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS`) @@ -382,35 +429,41 @@ #### Scenario: 环境变量类型转换 -- **WHEN** 解析不同类型的环境变量 +- **WHEN** server 解析不同类型的环境变量 - **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000`) - **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db`) - **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s`) - **THEN** SHALL 支持 bool 类型(如 `NEX_LOG_COMPRESS=true`) -#### Scenario: 完整环境变量覆盖 +#### Scenario: Server 完整环境变量覆盖 -- **WHEN** 设置服务器相关环境变量 +- **WHEN** server 设置服务器相关环境变量 - **THEN** SHALL 支持 `NEX_SERVER_PORT`、`NEX_SERVER_READ_TIMEOUT`、`NEX_SERVER_WRITE_TIMEOUT` -- **WHEN** 设置数据库相关环境变量 +- **WHEN** server 设置数据库相关环境变量 - **THEN** SHALL 支持 `NEX_DATABASE_DRIVER`、`NEX_DATABASE_PATH`、`NEX_DATABASE_HOST`、`NEX_DATABASE_PORT`、`NEX_DATABASE_USER`、`NEX_DATABASE_PASSWORD`、`NEX_DATABASE_DBNAME`、`NEX_DATABASE_MAX_IDLE_CONNS`、`NEX_DATABASE_MAX_OPEN_CONNS`、`NEX_DATABASE_CONN_MAX_LIFETIME` -- **WHEN** 设置日志相关环境变量 +- **WHEN** server 设置日志相关环境变量 - **THEN** SHALL 支持 `NEX_LOG_LEVEL`、`NEX_LOG_PATH`、`NEX_LOG_MAX_SIZE`、`NEX_LOG_MAX_BACKUPS`、`NEX_LOG_MAX_AGE`、`NEX_LOG_COMPRESS` -#### Scenario: 12-Factor App 合规 +#### Scenario: Server 12-Factor App 合规 -- **WHEN** 应用部署到不同环境 +- **WHEN** server 部署到不同环境 - **THEN** SHALL 通过环境变量区分环境配置 - **THEN** SHALL NOT 修改代码或配置文件 -- **WHEN** 配置包含敏感信息(如密钥、密码) +- **WHEN** server 配置包含敏感信息(如密钥、密码) - **THEN** SHALL 通过环境变量传递 - **THEN** SHALL NOT 存储在配置文件中 -#### Scenario: 环境变量错误处理 +#### Scenario: Server 环境变量错误处理 -- **WHEN** 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`) +- **WHEN** server 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`) - **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型 - **THEN** SHALL NOT 启动应用 -- **WHEN** 必需配置项既无配置文件也无环境变量 +- **WHEN** server 必需配置项既无配置文件也无环境变量 - **THEN** SHALL 使用默认值 - **THEN** SHALL 正常启动应用 + +#### Scenario: Desktop 忽略环境变量 + +- **WHEN** desktop 启动时存在 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量 +- **THEN** SHALL NOT 读取这些环境变量作为配置源 +- **THEN** SHALL 使用 `~/.nex/config.yaml` 和默认值加载配置 diff --git a/openspec/specs/desktop-app/spec.md b/openspec/specs/desktop-app/spec.md index f09b1b1..330daaf 100644 --- a/openspec/specs/desktop-app/spec.md +++ b/openspec/specs/desktop-app/spec.md @@ -8,16 +8,18 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 ### Requirement: 桌面应用启动 -系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。 +系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。 #### Scenario: 双击启动 - **WHEN** 用户双击桌面应用可执行文件 -- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁 +- **THEN** 系统从 `~/.nex/config.yaml` 和默认值加载启动配置快照 +- **AND** 系统使用 `gofrs/flock` 尝试获取排他文件锁 - **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock` -- **AND** 系统启动后端服务 +- **AND** 系统使用启动配置中的 `server.port` 启动后端服务 +- **AND** 未配置 `server.port` 时默认端口为 9826 - **AND** 系统托盘图标出现 -- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面 +- **AND** 浏览器自动打开 `http://localhost:` 显示管理界面 #### Scenario: 单实例检查 @@ -34,7 +36,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 ### Requirement: 系统托盘 -系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。 +系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`。 #### Scenario: 托盘图标显示 @@ -50,19 +52,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 - **THEN** 显示托盘菜单 - **AND** 菜单包含"打开管理界面"选项 - **AND** 菜单包含"状态: 运行中"选项(禁用状态) -- **AND** 菜单包含"端口: 9826"选项(禁用状态) +- **AND** 菜单包含"端口: "选项(禁用状态) - **AND** 菜单包含"退出"选项 #### Scenario: 打开管理界面 - **WHEN** 用户点击托盘菜单"打开管理界面" -- **THEN** 系统在浏览器中打开 `http://localhost:9826` +- **THEN** 系统在浏览器中打开 `http://localhost:` #### Scenario: 浏览器打开失败 - **WHEN** 系统无法打开浏览器(浏览器未安装等) - **THEN** 托盘菜单仍可正常使用 -- **AND** 用户可手动访问 `http://localhost:9826` +- **AND** 用户可手动访问 `http://localhost:` #### Scenario: 退出应用 @@ -124,19 +126,81 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 ### Requirement: 端口冲突检测 -系统 SHALL 在启动前检测端口是否可用。 +系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。 -#### Scenario: 端口可用 +#### Scenario: 配置端口可用 -- **WHEN** 端口 9826 未被占用 +- **WHEN** 启动配置中的 `server.port` 未被占用 - **THEN** 服务正常启动 -#### Scenario: 端口被占用 +#### Scenario: 配置端口被占用 -- **WHEN** 端口 9826 已被其他程序占用 -- **THEN** 显示错误提示"端口 9826 已被占用" +- **WHEN** 启动配置中的 `server.port` 已被其他程序占用 +- **THEN** 显示错误提示"端口 已被占用" - **AND** 应用退出 +### Requirement: 桌面配置源隔离和启动快照 + +desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。 + +#### Scenario: Desktop 仅使用默认配置文件 + +- **WHEN** desktop 启动 +- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置 +- **THEN** SHALL 在配置文件不存在时使用默认值 +- **THEN** SHALL 使用默认值补齐配置文件未设置的配置项 + +#### Scenario: Desktop 不支持 CLI 配置源 + +- **WHEN** desktop 启动时传入 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml` +- **THEN** SHALL 忽略这些参数 +- **THEN** SHALL NOT 将这些参数应用到运行时配置 +- **THEN** SHALL NOT 使用 `--config` 指定的配置文件路径 + +#### Scenario: Desktop 不支持环境变量配置源 + +- **WHEN** desktop 启动环境中存在 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量 +- **THEN** SHALL NOT 将这些环境变量应用到运行时配置 +- **THEN** SHALL 使用默认配置文件和默认值确定运行时配置 + +#### Scenario: Desktop 忽略未知启动参数 + +- **WHEN** desktop 启动时传入未知命令行参数 +- **THEN** SHALL NOT 因未知参数导致配置加载失败 +- **THEN** SHALL 继续使用默认配置文件和默认值加载配置 + +#### Scenario: 配置文件修改仅下次启动生效 + +- **WHEN** desktop 已启动并正在处理请求 +- **AND** 用户修改 `~/.nex/config.yaml` 中的 `server.port`、`database.*`、`log.*` 或 timeout 配置 +- **THEN** 当前运行中的 desktop SHALL NOT 重新加载配置文件 +- **THEN** 当前运行中的 HTTP server、数据库连接、日志器和请求处理 SHALL NOT 因配置文件修改而重建或中断 +- **THEN** 修改后的配置 SHALL 在下一次 desktop 启动时生效 + +#### Scenario: 配置文件无效 + +- **WHEN** desktop 启动时 `~/.nex/config.yaml` 存在但内容无法解析或验证失败 +- **THEN** SHALL 显示包含配置文件路径和失败原因的错误提示 +- **THEN** SHALL 退出应用 +- **THEN** SHALL NOT 静默回退默认配置继续启动 + +### Requirement: Desktop 前端同源 API 访问 + +desktop 内嵌前端 SHALL 使用同源相对路径访问后端 API,不主动发现、缓存或覆盖 desktop 端口。 + +#### Scenario: 同源 API 请求 + +- **WHEN** desktop 浏览器页面打开在 `http://localhost:` +- **THEN** 前端 SHALL 使用 `/api/*`、`/openai/*` 和 `/anthropic/*` 等相对路径访问同一 origin +- **THEN** 前端 SHALL NOT 硬编码 desktop 端口 + +#### Scenario: 重启后新端口访问 + +- **WHEN** 用户修改配置文件中的 `server.port` 并重启 desktop +- **THEN** desktop SHALL 按新端口启动并打开 `http://localhost:` +- **THEN** 前端 SHALL 继续使用当前 origin 的相对路径访问 API +- **THEN** 前端 SHALL NOT 扫描端口、读取本地缓存端口或请求端口发现接口 + ### Requirement: 跨平台构建 系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。 diff --git a/openspec/specs/test-coverage/spec.md b/openspec/specs/test-coverage/spec.md index 67117d7..571b759 100644 --- a/openspec/specs/test-coverage/spec.md +++ b/openspec/specs/test-coverage/spec.md @@ -37,7 +37,7 @@ - **THEN** SHALL 验证 LoadConfigFromPath 正确加载默认值 - **THEN** SHALL 验证 YAML 配置文件正确读取 - **THEN** SHALL 验证优先级链:CLI 参数 > 环境变量 > YAML 文件 > 默认值 -- **THEN** SHALL 验证首次启动自动创建配置文件 +- **THEN** SHALL 验证配置文件缺失时使用默认值,不自动创建配置文件 - **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致 #### Scenario: 环境变量覆盖验证 @@ -46,11 +46,12 @@ - **THEN** SHALL 成功加载 - **THEN** 配置值 SHALL 反映环境变量覆盖 -#### Scenario: 自动创建配置文件验证 +#### Scenario: 配置文件缺失时使用默认值 - **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径 - **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误) - **THEN** SHALL 返回默认配置对象 +- **THEN** SHALL NOT 自动创建配置文件 #### Scenario: handler 错误分支测试 @@ -303,3 +304,50 @@ - **WHEN** 运行 desktop 专属测试 - **THEN** SHALL 验证 desktop 模式启动数据库迁移时使用打包迁移资源 - **THEN** SHALL 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录 + +### Requirement: Desktop 配置源隔离测试覆盖 + +系统 SHALL 为 desktop 配置加载行为建立测试覆盖,验证 desktop 只使用默认配置文件和默认值,不受 CLI 参数或 `NEX_*` 环境变量影响。 + +#### Scenario: Desktop 配置文件端口生效 + +- **WHEN** 运行 desktop 配置加载相关测试 +- **THEN** SHALL 验证 `~/.nex/config.yaml` 或等价测试配置文件中的 `server.port` 会进入 desktop 启动配置快照 +- **THEN** SHALL 验证 desktop 端口检测、HTTP 监听地址、浏览器打开地址和托盘端口显示使用同一个配置端口 + +#### Scenario: Desktop 忽略 CLI 参数 + +- **WHEN** 测试进程参数包含 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml` +- **THEN** desktop 配置加载 SHALL 忽略这些参数 +- **THEN** desktop 配置加载 SHALL 使用默认配置文件路径和配置文件值 + +#### Scenario: Desktop 忽略未知参数 + +- **WHEN** 测试进程参数包含未知命令行参数 +- **THEN** desktop 配置加载 SHALL 成功或仅因配置文件本身无效而失败 +- **THEN** desktop 配置加载 SHALL NOT 因未知参数返回参数解析错误 + +#### Scenario: Desktop 忽略环境变量 + +- **WHEN** 测试环境设置 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量 +- **THEN** desktop 配置加载 SHALL NOT 使用这些环境变量覆盖配置文件值 +- **THEN** server 配置加载的环境变量覆盖测试 SHALL 继续通过 + +#### Scenario: Desktop 配置快照不随文件变化自动更新 + +- **WHEN** desktop 配置已加载为内存中的启动快照 +- **AND** 测试修改配置文件中的 `server.port` 或其他配置项 +- **THEN** 已加载的配置对象 SHALL 保持原值 +- **THEN** 重新启动或重新执行 desktop 配置加载时 SHALL 读取修改后的配置值 + +#### Scenario: Desktop 无效配置错误提示 + +- **WHEN** desktop 启动时配置文件存在但 YAML 无法解析或配置验证失败 +- **THEN** 测试 SHALL 验证启动流程返回或显示包含配置路径和失败原因的错误 +- **THEN** 测试 SHALL 验证 desktop 不会静默回退默认配置继续启动 + +#### Scenario: 配置文件缺失时使用默认值 + +- **WHEN** 测试配置加载时指定不存在的配置文件路径 +- **THEN** SHALL 返回默认配置值,不自动创建配置文件 +- **THEN** 测试 SHALL 验证配置文件未被创建