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

@@ -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()