1
0

feat: 区分 server 与 desktop 配置加载入口,取消自动创建配置文件

- 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/ 主规范
This commit is contained in:
2026-05-06 11:59:19 +08:00
parent 598e2acb7e
commit 5513f0c13d
10 changed files with 589 additions and 147 deletions

View File

@@ -66,7 +66,7 @@ nex/
- **ORM**: GORM - **ORM**: GORM
- **数据库**: SQLite / MySQL - **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack结构化日志 + 日志轮转 + 模块标识) - **日志**: zap + lumberjack结构化日志 + 日志轮转 + 模块标识)
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值 - **配置**: Viper + pflagServer 多层配置Desktop 配置文件快照
- **验证**: go-playground/validator/v10 - **验证**: go-playground/validator/v10
- **迁移**: goose - **迁移**: goose
@@ -147,7 +147,6 @@ make server-run
- 前端开发服务器:`http://localhost:5173` - 前端开发服务器:`http://localhost:5173`
前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动: 前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动:
- 创建配置文件 `~/.nex/config.yaml`
- 初始化数据库 `~/.nex/config.db` - 初始化数据库 `~/.nex/config.db`
- 运行数据库迁移 - 运行数据库迁移
- 创建日志目录 `~/.nex/log/` - 创建日志目录 `~/.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 ```yaml
server: server:
@@ -279,9 +281,9 @@ log:
compress: true compress: true
``` ```
### 环境变量 ### 环境变量(仅 Server 模式)
所有配置项支持环境变量,使用 `NEX_` 前缀: Server 模式下,所有配置项支持环境变量,使用 `NEX_` 前缀:
```bash ```bash
export NEX_SERVER_PORT=9000 export NEX_SERVER_PORT=9000
@@ -299,7 +301,11 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转大写 + 下划线(如 `server.port``NEX_SERVER_PORT`)。 命名规则:配置路径转大写 + 下划线(如 `server.port``NEX_SERVER_PORT`)。
### CLI 参数 **Desktop 模式不支持环境变量覆盖。**Desktop 仅从 `~/.nex/config.yaml` 和默认值读取配置。
### CLI 参数(仅 Server 模式)
Server 模式下,支持命令行参数:
```bash ```bash
./server --server-port 9000 --log-level debug --database-path /tmp/test.db ./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`)。 命名规则:配置路径转 kebab-case`server.port``--server-port`)。
**Desktop 不支持命令行参数覆盖配置。**Desktop 忽略所有 CLI 参数,仅从 `~/.nex/config.yaml` 读取。
### 数据文件 ### 数据文件
- `~/.nex/config.yaml` - 配置文件 - `~/.nex/config.yaml` - 配置文件

View File

@@ -72,7 +72,7 @@ GORM 日志自动桥接到 zapSQL 查询映射到 Debug 级别。
- **ORM**: GORM - **ORM**: GORM
- **数据库**: SQLite / MySQL - **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack - **日志**: zap + lumberjack
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值 - **配置**: Viper + pflagServer 多层配置Desktop 配置文件快照
- **验证**: go-playground/validator/v10 - **验证**: go-playground/validator/v10
- **迁移**: goose - **迁移**: goose
@@ -334,15 +334,18 @@ go mod download
go run cmd/server/main.go 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 ```yaml
server: server:
@@ -372,9 +375,9 @@ log:
compress: true compress: true
``` ```
### 环境变量 ### 环境变量(仅 Server 入口)
所有配置项都支持环境变量,使用 `NEX_` 前缀: Server 入口下,所有配置项都支持环境变量,使用 `NEX_` 前缀:
```bash ```bash
export NEX_SERVER_PORT=9000 export NEX_SERVER_PORT=9000
@@ -392,7 +395,7 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port``NEX_SERVER_PORT`)。 命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port``NEX_SERVER_PORT`)。
### 命令行参数 ### 命令行参数(仅 Server 入口)
```bash ```bash
./server --server-port 9000 --log-level debug --database-path /tmp/test.db ./server --server-port 9000 --log-level debug --database-path /tmp/test.db

View File

@@ -43,10 +43,23 @@ var (
) )
func main() { func main() {
port := 9826
minimalLogger := pkgLogger.NewMinimal() 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")) singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
if err := singleLock.Lock(); err != nil { if err := singleLock.Lock(); err != nil {
minimalLogger.Error("已有 Nex 实例运行") 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{ zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
Level: cfg.Log.Level, Level: cfg.Log.Level,
Path: cfg.Log.Path, Path: cfg.Log.Path,
@@ -144,7 +146,7 @@ func main() {
setupStaticFiles(r) setupStaticFiles(r)
server = &http.Server{ server = &http.Server{
Addr: fmt.Sprintf(":%d", port), Addr: desktopListenAddr(port),
Handler: r, Handler: r,
ReadTimeout: cfg.Server.ReadTimeout, ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout, WriteTimeout: cfg.Server.WriteTimeout,
@@ -165,7 +167,7 @@ func main() {
go func() { go func() {
time.Sleep(500 * time.Millisecond) 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)) zapLogger.Warn("无法打开浏览器", zap.Error(err))
} }
}() }()
@@ -309,7 +311,7 @@ func setupSystray(port int) {
systray.AddSeparator() systray.AddSeparator()
mStatus := systray.AddMenuItem("状态: 运行中", "") mStatus := systray.AddMenuItem("状态: 运行中", "")
mStatus.Disable() mStatus.Disable()
mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "") mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "")
mPort.Disable() mPort.Disable()
systray.AddSeparator() systray.AddSeparator()
mQuit := systray.AddMenuItem("退出", "停止服务并退出") mQuit := systray.AddMenuItem("退出", "停止服务并退出")
@@ -318,7 +320,7 @@ func setupSystray(port int) {
for { for {
select { select {
case <-mOpen.ClickedCh: 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)) zapLogger.Warn("打开浏览器失败", zap.Error(err))
} }
case <-mQuit.ClickedCh: 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 { func checkPortAvailable(port int) error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil { if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"net" "net"
"net/http" "net/http"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -67,3 +68,62 @@ func TestCheckPortAvailableAfterClose(t *testing.T) {
t.Log("端口关闭后可用测试通过") 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)
}
}

View File

@@ -29,7 +29,7 @@ import (
func main() { func main() {
minimalLogger := pkgLogger.NewMinimal() minimalLogger := pkgLogger.NewMinimal()
cfg, err := config.LoadConfig() cfg, err := config.LoadServerConfig()
if err != nil { if err != nil {
minimalLogger.Fatal("加载配置失败", zap.Error(err)) minimalLogger.Fatal("加载配置失败", zap.Error(err))
} }

View File

@@ -1,7 +1,6 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -225,68 +224,71 @@ func setupConfigFile(v *viper.Viper, configPath string) error {
v.SetConfigFile(configPath) v.SetConfigFile(configPath)
v.SetConfigType("yaml") v.SetConfigType("yaml")
// 尝试读取配置文件,如果不存在则忽略
if err := v.ReadInConfig(); err != nil { if err := v.ReadInConfig(); err != nil {
if !os.IsNotExist(err) { if os.IsNotExist(err) {
return nil
}
return appErrors.Wrap(appErrors.ErrInternal, err) return appErrors.Wrap(appErrors.ErrInternal, err)
} }
// 配置文件不存在,创建默认配置文件
writeErr := v.SafeWriteConfigAs(configPath)
if writeErr == nil {
return nil return nil
} }
var alreadyExistsErr viper.ConfigFileAlreadyExistsError // loadOptions 控制配置加载器行为
if errors.As(writeErr, &alreadyExistsErr) { type loadOptions struct {
return nil configPathOverride string
useCLI bool
useEnv bool
useConfigFlag bool
} }
return appErrors.Wrap(appErrors.ErrInternal, writeErr) // resolveConfigPath 根据 loadOptions 解析 CLI 参数并返回最终配置文件路径
} func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
return nil configPath := opts.configPathOverride
if !opts.useCLI && !opts.useConfigFlag {
return configPath, nil
} }
// LoadConfig loads config from YAML file, creates default if not exists flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError)
func LoadConfig() (*Config, error) { if opts.useConfigFlag {
configPath, err := GetConfigPath() flagSet.String("config", opts.configPathOverride, "配置文件路径")
if err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
} }
return LoadConfigFromPath(configPath) if opts.useCLI {
setupFlags(v, flagSet)
} }
// LoadConfigFromPath 从指定路径加载配置 if err := flagSet.Parse(os.Args[1:]); err != nil {
func LoadConfigFromPath(configPath string) (*Config, error) { return "", appErrors.Wrap(appErrors.ErrInvalidRequest, err)
// 1. 创建 Viper 实例 }
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() 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) setupDefaults(v)
// 6. 绑定环境变量 configPath, err := resolveConfigPath(v, opts)
setupEnv(v) if err != nil {
return nil, err
}
if opts.useEnv {
setupEnv(v)
}
// 7. 读取配置文件
if err := setupConfigFile(v, configPath); err != nil { if err := setupConfigFile(v, configPath); err != nil {
return nil, err return nil, err
} }
// 8. 反序列化到结构体
cfg := &Config{} cfg := &Config{}
if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeDurationHookFunc(),
@@ -295,7 +297,6 @@ func LoadConfigFromPath(configPath string) (*Config, error) {
return nil, appErrors.Wrap(appErrors.ErrInternal, err) return nil, appErrors.Wrap(appErrors.ErrInternal, err)
} }
// 9. 验证配置
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, err return nil, err
} }
@@ -303,6 +304,61 @@ func LoadConfigFromPath(configPath string) (*Config, error) {
return cfg, nil 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 // SaveConfig saves config to YAML file
func SaveConfig(cfg *Config) error { func SaveConfig(cfg *Config) error {
configPath, err := GetConfigPath() configPath, err := GetConfigPath()

View File

@@ -120,7 +120,7 @@ log:
assert.Equal(t, "warn", cfg.Log.Level, "YAML value should be used when no CLI/ENV override") 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() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml") configPath := filepath.Join(tmpDir, "config.yaml")
@@ -132,6 +132,9 @@ func TestLoadConfig_AutoCreate(t *testing.T) {
require.NotNil(t, cfg) require.NotNil(t, cfg)
assert.Equal(t, 9826, cfg.Server.Port, "should load with default values") 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) { 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.MaxAge, loaded.Log.MaxAge)
assert.Equal(t, cfg.Log.Compress, loaded.Log.Compress) 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")
})
}
}

View File

@@ -8,20 +8,27 @@
### Requirement: 使用 YAML 配置文件 ### Requirement: 使用 YAML 配置文件
系统 SHALL 使用 YAML 格式的配置文件。 系统 SHALL 使用 YAML 格式的配置文件,并按入口区分配置文件路径选择能力
#### Scenario: 配置文件路径 #### Scenario: Server 默认配置文件路径
- **WHEN** 应用启动且未指定 `--config` 参数 - **WHEN** server 应用启动且未指定 `--config` 参数
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置 - **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
- **THEN** SHALL 解析 YAML 格式 - **THEN** SHALL 解析 YAML 格式
#### Scenario: 自定义配置文件路径 #### Scenario: Server 自定义配置文件路径
- **WHEN** 应用启动且指定 `--config /path/to/custom.yaml` - **WHEN** server 应用启动且指定 `--config /path/to/custom.yaml`
- **THEN** SHALL 从指定路径加载配置文件 - **THEN** SHALL 从指定路径加载配置文件
- **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml` - **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml`
#### Scenario: Desktop 固定配置文件路径
- **WHEN** desktop 应用启动
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
- **THEN** SHALL 解析 YAML 格式
- **THEN** SHALL NOT 支持通过 `--config` 指定其他配置文件路径
#### Scenario: 配置文件结构 #### Scenario: 配置文件结构
- **WHEN** 加载配置文件 - **WHEN** 加载配置文件
@@ -30,14 +37,14 @@
### Requirement: 自动生成默认配置 ### Requirement: 自动生成默认配置
系统 SHALL 在首次使用时自动生成默认配置 系统 SHALL 在配置文件不存在时使用默认配置值,不自动创建配置文件
#### Scenario: 配置文件不存在 #### Scenario: 配置文件不存在
- **WHEN** 应用启动且配置文件不存在 - **WHEN** 应用启动且配置文件不存在
- **THEN** SHALL 自动创建配置文件 - **THEN** SHALL 使用默认配置值
- **THEN** SHALL 写入默认配置值 - **THEN** SHALL NOT 自动创建配置文件
- **THEN** SHALL 记录日志提示已创建 - **THEN** SHALL NOT 写入默认配置值到磁盘
#### Scenario: 配置文件已存在 #### Scenario: 配置文件已存在
@@ -163,22 +170,36 @@
### Requirement: 配置加载流程 ### Requirement: 配置加载流程
系统 SHALL 实现标准化的配置加载流程。 系统 SHALL 为 server 和 desktop 实现标准化且入口隔离的配置加载流程。
#### Scenario: 加载步骤 #### Scenario: Server 加载步骤
- **WHEN** 应用启动 - **WHEN** server 应用启动
- **THEN** SHALL 按以下顺序加载配置: - **THEN** SHALL 按以下顺序加载配置:
1. 解析 CLI 参数(获取 --config 路径) 1. 解析 CLI 参数(获取 --config 路径)
2. 初始化配置管理器 2. 初始化配置管理器
3. 设置默认值 3. 设置默认值
4. 绑定 CLI 参数 4. 绑定 CLI 参数
5. 绑定环境变量 5. 绑定环境变量
6. 读取配置文件(不存在时自动创建 6. 读取配置文件(不存在时使用默认值
7. 反序列化到结构体 7. 反序列化到结构体
8. 验证配置 8. 验证配置
9. 打印配置摘要 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: 加载失败处理 #### Scenario: 加载失败处理
- **WHEN** 配置加载过程中发生错误 - **WHEN** 配置加载过程中发生错误
@@ -188,25 +209,25 @@
### Requirement: 配置优先级管理 ### Requirement: 配置优先级管理
系统 SHALL 实现明确的配置优先级机制。 系统 SHALL 为不同入口实现明确的配置优先级机制。
#### Scenario: 优先级顺序 #### Scenario: Server 优先级顺序
- **WHEN** 同一配置项在多个配置源中设置 - **WHEN** 同一配置项在多个 server 配置源中设置
- **THEN** SHALL 按以下优先级顺序(从高到低): - **THEN** SHALL 按以下优先级顺序(从高到低):
1. CLI 参数 1. CLI 参数
2. 环境变量 2. 环境变量
3. 配置文件 3. 配置文件
4. 默认值 4. 默认值
#### Scenario: CLI 参数最高优先级 #### Scenario: Server CLI 参数最高优先级
- **WHEN** 配置文件设置 `server.port: 9826` - **WHEN** 配置文件设置 `server.port: 9826`
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000` - **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
- **AND** CLI 参数设置 `--server-port 8080` - **AND** server CLI 参数设置 `--server-port 8080`
- **THEN** SHALL 使用 CLI 参数值 8080 - **THEN** SHALL 使用 CLI 参数值 8080
#### Scenario: 环境变量次高优先级 #### Scenario: Server 环境变量次高优先级
- **WHEN** 配置文件设置 `server.port: 9826` - **WHEN** 配置文件设置 `server.port: 9826`
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000` - **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
@@ -227,21 +248,35 @@
- **AND** 未设置 CLI 参数 - **AND** 未设置 CLI 参数
- **THEN** SHALL 使用默认值 - **THEN** SHALL 使用默认值
#### Scenario: 部分配置覆盖 #### Scenario: Server 部分配置覆盖
- **WHEN** 配置文件设置完整配置 - **WHEN** 配置文件设置完整配置
- **AND** CLI 参数仅覆盖部分配置项 - **AND** server CLI 参数仅覆盖部分配置项
- **THEN** SHALL 合并所有配置源 - **THEN** SHALL 合并所有配置源
- **THEN** SHALL 使用高优先级源覆盖指定项 - **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 仅覆盖 server.port 配置项
- **THEN** SHALL NOT 影响其他配置项 - **THEN** SHALL NOT 影响其他配置项
- **THEN** SHALL 其他配置项使用配置文件或默认值 - **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: 启动后配置锁定 #### Scenario: 启动后配置锁定
- **WHEN** 应用启动完成 - **WHEN** 应用启动完成
@@ -314,67 +349,79 @@
### Requirement: CLI 参数配置支持 ### Requirement: CLI 参数配置支持
系统 SHALL 支持通过命令行参数设置所有配置项。 server 入口 SHALL 支持通过命令行参数设置所有配置项desktop 入口 SHALL NOT 将命令行参数作为配置源
#### Scenario: 基本参数解析 #### Scenario: Server 基本参数解析
- **WHEN** 应用启动时传入命令行参数 - **WHEN** server 应用启动时传入命令行参数
- **THEN** SHALL 解析所有 CLI 参数 - **THEN** SHALL 解析所有 CLI 参数
- **THEN** SHALL 将参数值应用到对应配置项 - **THEN** SHALL 将参数值应用到对应配置项
#### Scenario: 参数命名规范 #### Scenario: 参数命名规范
- **WHEN** 使用命令行参数 - **WHEN** server 使用命令行参数
- **THEN** SHALL 使用 kebab-case 命名(如 `--server-port` - **THEN** SHALL 使用 kebab-case 命名(如 `--server-port`
- **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns` - **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns`
- **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns``--database-max-idle-conns` - **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns``--database-max-idle-conns`
#### Scenario: 参数类型支持 #### Scenario: 参数类型支持
- **WHEN** 解析不同类型的参数 - **WHEN** server 解析不同类型的参数
- **THEN** SHALL 支持 int 类型(如 `--server-port 9000` - **THEN** SHALL 支持 int 类型(如 `--server-port 9000`
- **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db` - **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db`
- **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s` - **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s`
- **THEN** SHALL 支持 bool 类型(如 `--log-compress` - **THEN** SHALL 支持 bool 类型(如 `--log-compress`
#### Scenario: 完整配置覆盖 #### Scenario: Server 完整配置覆盖
- **WHEN** 使用服务器相关参数 - **WHEN** server 使用服务器相关参数
- **THEN** SHALL 支持 `--server-port``--server-read-timeout``--server-write-timeout` - **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` - **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` - **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 按功能分组展示参数(服务器、数据库、日志) - **THEN** SHALL 按功能分组展示参数(服务器、数据库、日志)
- **THEN** SHALL 显示每个参数的默认值和说明 - **THEN** SHALL 显示每个参数的默认值和说明
#### Scenario: 参数错误处理 #### Scenario: Server 参数错误处理
- **WHEN** 传入无效的参数值(如 `--server-port abc` - **WHEN** server 传入无效的参数值(如 `--server-port abc`
- **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型 - **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型
- **THEN** SHALL NOT 启动应用 - **THEN** SHALL NOT 启动应用
- **WHEN** 传入未定义的参数(如 `--unknown-param value` - **WHEN** server 传入未定义的参数(如 `--unknown-param value`
- **THEN** SHALL 返回错误信息,指示未知参数名称 - **THEN** SHALL 返回错误信息,指示未知参数名称
- **THEN** SHALL NOT 启动应用 - **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: 环境变量配置支持 ### 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 自动读取所有 `NEX_` 前缀的环境变量
- **THEN** SHALL 将环境变量值应用到对应配置项 - **THEN** SHALL 将环境变量值应用到对应配置项
#### Scenario: 环境变量命名规范 #### Scenario: 环境变量命名规范
- **WHEN** 使用环境变量配置 - **WHEN** server 使用环境变量配置
- **THEN** SHALL 使用 `NEX_` 前缀 - **THEN** SHALL 使用 `NEX_` 前缀
- **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT` - **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT`
- **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS` - **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS`
@@ -382,35 +429,41 @@
#### Scenario: 环境变量类型转换 #### Scenario: 环境变量类型转换
- **WHEN** 解析不同类型的环境变量 - **WHEN** server 解析不同类型的环境变量
- **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000` - **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000`
- **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db` - **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db`
- **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s` - **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s`
- **THEN** SHALL 支持 bool 类型(如 `NEX_LOG_COMPRESS=true` - **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` - **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` - **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` - **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 通过环境变量区分环境配置
- **THEN** SHALL NOT 修改代码或配置文件 - **THEN** SHALL NOT 修改代码或配置文件
- **WHEN** 配置包含敏感信息(如密钥、密码) - **WHEN** server 配置包含敏感信息(如密钥、密码)
- **THEN** SHALL 通过环境变量传递 - **THEN** SHALL 通过环境变量传递
- **THEN** SHALL NOT 存储在配置文件中 - **THEN** SHALL NOT 存储在配置文件中
#### Scenario: 环境变量错误处理 #### Scenario: Server 环境变量错误处理
- **WHEN** 环境变量值格式无效(如 `NEX_SERVER_PORT=abc` - **WHEN** server 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`
- **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型 - **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型
- **THEN** SHALL NOT 启动应用 - **THEN** SHALL NOT 启动应用
- **WHEN** 必需配置项既无配置文件也无环境变量 - **WHEN** server 必需配置项既无配置文件也无环境变量
- **THEN** SHALL 使用默认值 - **THEN** SHALL 使用默认值
- **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` 和默认值加载配置

View File

@@ -8,16 +8,18 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 桌面应用启动 ### Requirement: 桌面应用启动
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。 系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口
#### Scenario: 双击启动 #### Scenario: 双击启动
- **WHEN** 用户双击桌面应用可执行文件 - **WHEN** 用户双击桌面应用可执行文件
- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁 - **THEN** 系统 `~/.nex/config.yaml` 和默认值加载启动配置快照
- **AND** 系统使用 `gofrs/flock` 尝试获取排他文件锁
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock` - **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
- **AND** 系统启动后端服务 - **AND** 系统使用启动配置中的 `server.port` 启动后端服务
- **AND** 未配置 `server.port` 时默认端口为 9826
- **AND** 系统托盘图标出现 - **AND** 系统托盘图标出现
- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面 - **AND** 浏览器自动打开 `http://localhost:<server.port>` 显示管理界面
#### Scenario: 单实例检查 #### Scenario: 单实例检查
@@ -34,7 +36,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 系统托盘 ### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。 系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`
#### Scenario: 托盘图标显示 #### Scenario: 托盘图标显示
@@ -50,19 +52,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **THEN** 显示托盘菜单 - **THEN** 显示托盘菜单
- **AND** 菜单包含"打开管理界面"选项 - **AND** 菜单包含"打开管理界面"选项
- **AND** 菜单包含"状态: 运行中"选项(禁用状态) - **AND** 菜单包含"状态: 运行中"选项(禁用状态)
- **AND** 菜单包含"端口: 9826"选项(禁用状态) - **AND** 菜单包含"端口: <server.port>"选项(禁用状态)
- **AND** 菜单包含"退出"选项 - **AND** 菜单包含"退出"选项
#### Scenario: 打开管理界面 #### Scenario: 打开管理界面
- **WHEN** 用户点击托盘菜单"打开管理界面" - **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:9826` - **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
#### Scenario: 浏览器打开失败 #### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等) - **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用 - **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:9826` - **AND** 用户可手动访问 `http://localhost:<server.port>`
#### Scenario: 退出应用 #### Scenario: 退出应用
@@ -124,19 +126,81 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 端口冲突检测 ### Requirement: 端口冲突检测
系统 SHALL 在启动前检测端口是否可用。 系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。
#### Scenario: 端口可用 #### Scenario: 配置端口可用
- **WHEN** 端口 9826 未被占用 - **WHEN** 启动配置中的 `server.port` 未被占用
- **THEN** 服务正常启动 - **THEN** 服务正常启动
#### Scenario: 端口被占用 #### Scenario: 配置端口被占用
- **WHEN** 端口 9826 已被其他程序占用 - **WHEN** 启动配置中的 `server.port` 已被其他程序占用
- **THEN** 显示错误提示"端口 9826 已被占用" - **THEN** 显示错误提示"端口 <server.port> 已被占用"
- **AND** 应用退出 - **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:<server.port>`
- **THEN** 前端 SHALL 使用 `/api/*``/openai/*``/anthropic/*` 等相对路径访问同一 origin
- **THEN** 前端 SHALL NOT 硬编码 desktop 端口
#### Scenario: 重启后新端口访问
- **WHEN** 用户修改配置文件中的 `server.port` 并重启 desktop
- **THEN** desktop SHALL 按新端口启动并打开 `http://localhost:<new-port>`
- **THEN** 前端 SHALL 继续使用当前 origin 的相对路径访问 API
- **THEN** 前端 SHALL NOT 扫描端口、读取本地缓存端口或请求端口发现接口
### Requirement: 跨平台构建 ### Requirement: 跨平台构建
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。 系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。

View File

@@ -37,7 +37,7 @@
- **THEN** SHALL 验证 LoadConfigFromPath 正确加载默认值 - **THEN** SHALL 验证 LoadConfigFromPath 正确加载默认值
- **THEN** SHALL 验证 YAML 配置文件正确读取 - **THEN** SHALL 验证 YAML 配置文件正确读取
- **THEN** SHALL 验证优先级链CLI 参数 > 环境变量 > YAML 文件 > 默认值 - **THEN** SHALL 验证优先级链CLI 参数 > 环境变量 > YAML 文件 > 默认值
- **THEN** SHALL 验证首次启动自动创建配置文件 - **THEN** SHALL 验证配置文件缺失时使用默认值,不自动创建配置文件
- **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致 - **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致
#### Scenario: 环境变量覆盖验证 #### Scenario: 环境变量覆盖验证
@@ -46,11 +46,12 @@
- **THEN** SHALL 成功加载 - **THEN** SHALL 成功加载
- **THEN** 配置值 SHALL 反映环境变量覆盖 - **THEN** 配置值 SHALL 反映环境变量覆盖
#### Scenario: 自动创建配置文件验证 #### Scenario: 配置文件缺失时使用默认值
- **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径 - **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径
- **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误) - **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误)
- **THEN** SHALL 返回默认配置对象 - **THEN** SHALL 返回默认配置对象
- **THEN** SHALL NOT 自动创建配置文件
#### Scenario: handler 错误分支测试 #### Scenario: handler 错误分支测试
@@ -303,3 +304,50 @@
- **WHEN** 运行 desktop 专属测试 - **WHEN** 运行 desktop 专属测试
- **THEN** SHALL 验证 desktop 模式启动数据库迁移时使用打包迁移资源 - **THEN** SHALL 验证 desktop 模式启动数据库迁移时使用打包迁移资源
- **THEN** SHALL 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录 - **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 验证配置文件未被创建