feat: 新增启动参数设置页面,区分 desktop 可编辑与 server 只读
This commit is contained in:
@@ -45,7 +45,7 @@ var (
|
||||
func main() {
|
||||
minimalLogger := pkgLogger.NewMinimal()
|
||||
|
||||
cfg, err := config.LoadDesktopConfig()
|
||||
cfg, cfgMeta, err := config.LoadDesktopConfigWithMetadata()
|
||||
if err != nil {
|
||||
minimalLogger.Error("加载配置失败", zap.Error(err))
|
||||
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
|
||||
@@ -133,6 +133,7 @@ func main() {
|
||||
modelHandler := handler.NewModelHandler(modelService)
|
||||
statsHandler := handler.NewStatsHandler(statsService)
|
||||
versionHandler := handler.NewVersionHandler()
|
||||
settingsHandler := handler.NewSettingsHandler(cfg, "desktop", true, cfgMeta.ConfigPath)
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
@@ -142,7 +143,7 @@ func main() {
|
||||
r.Use(middleware.Logging(zapLogger))
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
|
||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
|
||||
setupStaticFiles(r)
|
||||
|
||||
server = &http.Server{
|
||||
@@ -175,7 +176,7 @@ func main() {
|
||||
setupSystray(port)
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler) {
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
|
||||
r.Any("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
|
||||
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
|
||||
r.GET("/api/version", versionHandler.GetVersion)
|
||||
@@ -204,6 +205,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
|
||||
stats.GET("/aggregate", statsHandler.AggregateStats)
|
||||
}
|
||||
|
||||
settings := r.Group("/api/settings")
|
||||
{
|
||||
settings.GET("/startup", settingsHandler.GetStartupSettings)
|
||||
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
|
||||
}
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestSetupRoutes_VersionDoesNotFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler())
|
||||
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "desktop", true, ""))
|
||||
setupStaticFilesWithFS(r, fstest.MapFS{
|
||||
"index.html": {Data: []byte("<html>fallback</html>")},
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
func main() {
|
||||
minimalLogger := pkgLogger.NewMinimal()
|
||||
|
||||
cfg, err := config.LoadServerConfig()
|
||||
cfg, cfgMeta, err := config.LoadServerConfigWithMetadata()
|
||||
if err != nil {
|
||||
minimalLogger.Fatal("加载配置失败", zap.Error(err))
|
||||
}
|
||||
@@ -94,6 +94,7 @@ func main() {
|
||||
modelHandler := handler.NewModelHandler(modelService)
|
||||
statsHandler := handler.NewStatsHandler(statsService)
|
||||
versionHandler := handler.NewVersionHandler()
|
||||
settingsHandler := handler.NewSettingsHandler(cfg, "server", false, cfgMeta.ConfigPath)
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
@@ -103,7 +104,7 @@ func main() {
|
||||
r.Use(middleware.Logging(zapLogger))
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
|
||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
@@ -141,7 +142,7 @@ func main() {
|
||||
zapLogger.Info("服务器已关闭")
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler) {
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
|
||||
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
|
||||
r.GET("/api/version", versionHandler.GetVersion)
|
||||
|
||||
@@ -169,6 +170,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
|
||||
stats.GET("/aggregate", statsHandler.AggregateStats)
|
||||
}
|
||||
|
||||
settings := r.Group("/api/settings")
|
||||
{
|
||||
settings.GET("/startup", settingsHandler.GetStartupSettings)
|
||||
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
|
||||
}
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestSetupRoutes_Version(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler())
|
||||
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "server", false, ""))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -36,7 +36,7 @@ type DatabaseConfig struct {
|
||||
Driver string `yaml:"driver" mapstructure:"driver" validate:"required,oneof=sqlite mysql"`
|
||||
Path string `yaml:"path" mapstructure:"path" validate:"required_if=Driver sqlite"`
|
||||
Host string `yaml:"host" mapstructure:"host" validate:"required_if=Driver mysql"`
|
||||
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,min=1,max=65535"`
|
||||
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,omitempty,min=1,max=65535"`
|
||||
User string `yaml:"user" mapstructure:"user" validate:"required_if=Driver mysql"`
|
||||
Password string `yaml:"password" mapstructure:"password"`
|
||||
DBName string `yaml:"dbname" mapstructure:"dbname" validate:"required_if=Driver mysql"`
|
||||
@@ -233,7 +233,10 @@ func setupConfigFile(v *viper.Viper, configPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadOptions 控制配置加载器行为
|
||||
type ConfigMetadata struct {
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
type loadOptions struct {
|
||||
configPathOverride string
|
||||
useCLI bool
|
||||
@@ -270,15 +273,19 @@ func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
// loadConfig 共享配置加载逻辑,通过 loadOptions 控制是否启用 CLI、环境变量和 --config 覆盖
|
||||
func loadConfig(opts loadOptions) (*Config, error) {
|
||||
cfg, _, err := loadConfigWithMetadata(opts)
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func loadConfigWithMetadata(opts loadOptions) (*Config, ConfigMetadata, error) {
|
||||
v := viper.New()
|
||||
|
||||
setupDefaults(v)
|
||||
|
||||
configPath, err := resolveConfigPath(v, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, ConfigMetadata{}, err
|
||||
}
|
||||
|
||||
if opts.useEnv {
|
||||
@@ -286,7 +293,7 @@ func loadConfig(opts loadOptions) (*Config, error) {
|
||||
}
|
||||
|
||||
if err := setupConfigFile(v, configPath); err != nil {
|
||||
return nil, err
|
||||
return nil, ConfigMetadata{}, err
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
@@ -294,23 +301,28 @@ func loadConfig(opts loadOptions) (*Config, error) {
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
))); err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
return nil, ConfigMetadata{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
return cfg, ConfigMetadata{ConfigPath: configPath}, nil
|
||||
}
|
||||
|
||||
// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config
|
||||
func LoadServerConfig() (*Config, error) {
|
||||
cfg, _, err := LoadServerConfigWithMetadata()
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func LoadServerConfigWithMetadata() (*Config, ConfigMetadata, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return loadConfig(loadOptions{
|
||||
return loadConfigWithMetadata(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: true,
|
||||
useEnv: true,
|
||||
@@ -320,11 +332,16 @@ func LoadServerConfig() (*Config, error) {
|
||||
|
||||
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
|
||||
func LoadDesktopConfig() (*Config, error) {
|
||||
cfg, _, err := LoadDesktopConfigWithMetadata()
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func LoadDesktopConfigWithMetadata() (*Config, ConfigMetadata, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return loadConfig(loadOptions{
|
||||
return loadConfigWithMetadata(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: false,
|
||||
useEnv: false,
|
||||
@@ -365,13 +382,15 @@ func SaveConfig(cfg *Config) error {
|
||||
if err != nil {
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return SaveConfigToPath(cfg, configPath)
|
||||
}
|
||||
|
||||
func SaveConfigToPath(cfg *Config, configPath string) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
|
||||
94
backend/internal/config/config_metadata_test.go
Normal file
94
backend/internal/config/config_metadata_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestLoadDesktopConfigAtPath_WithMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yaml")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server.Port = 8888
|
||||
data, err := yaml.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(configPath, data, 0o600))
|
||||
|
||||
loaded, meta, err := loadConfigWithMetadata(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: false,
|
||||
useEnv: false,
|
||||
useConfigFlag: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 8888, loaded.Server.Port)
|
||||
assert.Equal(t, configPath, meta.ConfigPath)
|
||||
}
|
||||
|
||||
func TestSaveConfigToPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "sub", "config.yaml")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server.Port = 7777
|
||||
|
||||
err := SaveConfigToPath(cfg, configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), "7777")
|
||||
}
|
||||
|
||||
func TestSaveConfigToPath_InvalidDir(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
err := SaveConfigToPath(cfg, "/dev/null/impossible/config.yaml")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDurationConversion(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
dto := configToDTO(cfg)
|
||||
|
||||
parsed, err := time.ParseDuration(dto.Server.ReadTimeout)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cfg.Server.ReadTimeout, parsed)
|
||||
|
||||
parsed, err = time.ParseDuration(dto.Database.ConnMaxLifetime)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cfg.Database.ConnMaxLifetime, parsed)
|
||||
}
|
||||
|
||||
func configToDTO(c *Config) struct {
|
||||
Server struct {
|
||||
Port int `json:"port"`
|
||||
ReadTimeout string `json:"read_timeout"`
|
||||
WriteTimeout string `json:"write_timeout"`
|
||||
}
|
||||
Database struct {
|
||||
ConnMaxLifetime string `json:"conn_max_lifetime"`
|
||||
}
|
||||
} {
|
||||
var result struct {
|
||||
Server struct {
|
||||
Port int `json:"port"`
|
||||
ReadTimeout string `json:"read_timeout"`
|
||||
WriteTimeout string `json:"write_timeout"`
|
||||
}
|
||||
Database struct {
|
||||
ConnMaxLifetime string `json:"conn_max_lifetime"`
|
||||
}
|
||||
}
|
||||
result.Server.Port = c.Server.Port
|
||||
result.Server.ReadTimeout = c.Server.ReadTimeout.String()
|
||||
result.Server.WriteTimeout = c.Server.WriteTimeout.String()
|
||||
result.Database.ConnMaxLifetime = c.Database.ConnMaxLifetime.String()
|
||||
return result
|
||||
}
|
||||
223
backend/internal/handler/settings_handler.go
Normal file
223
backend/internal/handler/settings_handler.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
appErrors "nex/backend/pkg/errors"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
runtimeCfg *config.Config
|
||||
mode string
|
||||
editable bool
|
||||
configPath string
|
||||
}
|
||||
|
||||
func NewSettingsHandler(runtimeCfg *config.Config, mode string, editable bool, configPath string) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
runtimeCfg: runtimeCfg,
|
||||
mode: mode,
|
||||
editable: editable,
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
type serverConfigDTO struct {
|
||||
Port int `json:"port"`
|
||||
ReadTimeout string `json:"read_timeout"`
|
||||
WriteTimeout string `json:"write_timeout"`
|
||||
}
|
||||
|
||||
type databaseConfigDTO struct {
|
||||
Driver string `json:"driver"`
|
||||
Path string `json:"path"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
DBName string `json:"dbname"`
|
||||
MaxIdleConns int `json:"max_idle_conns"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
ConnMaxLifetime string `json:"conn_max_lifetime"`
|
||||
}
|
||||
|
||||
type logConfigDTO struct {
|
||||
Level string `json:"level"`
|
||||
Path string `json:"path"`
|
||||
MaxSize int `json:"max_size"`
|
||||
MaxBackups int `json:"max_backups"`
|
||||
MaxAge int `json:"max_age"`
|
||||
Compress bool `json:"compress"`
|
||||
}
|
||||
|
||||
type startupSettingsDTO struct {
|
||||
Server serverConfigDTO `json:"server"`
|
||||
Database databaseConfigDTO `json:"database"`
|
||||
Log logConfigDTO `json:"log"`
|
||||
}
|
||||
|
||||
type startupSettingsResponse struct {
|
||||
Mode string `json:"mode"`
|
||||
Editable bool `json:"editable"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
Config startupSettingsDTO `json:"config"`
|
||||
}
|
||||
|
||||
func configToDTO(cfg *config.Config) startupSettingsDTO {
|
||||
return startupSettingsDTO{
|
||||
Server: serverConfigDTO{
|
||||
Port: cfg.Server.Port,
|
||||
ReadTimeout: cfg.Server.ReadTimeout.String(),
|
||||
WriteTimeout: cfg.Server.WriteTimeout.String(),
|
||||
},
|
||||
Database: databaseConfigDTO{
|
||||
Driver: cfg.Database.Driver,
|
||||
Path: cfg.Database.Path,
|
||||
Host: cfg.Database.Host,
|
||||
Port: cfg.Database.Port,
|
||||
User: cfg.Database.User,
|
||||
Password: cfg.Database.Password,
|
||||
DBName: cfg.Database.DBName,
|
||||
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||
ConnMaxLifetime: cfg.Database.ConnMaxLifetime.String(),
|
||||
},
|
||||
Log: logConfigDTO{
|
||||
Level: cfg.Log.Level,
|
||||
Path: cfg.Log.Path,
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
MaxBackups: cfg.Log.MaxBackups,
|
||||
MaxAge: cfg.Log.MaxAge,
|
||||
Compress: cfg.Log.Compress,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dtoToConfig(dto startupSettingsDTO) (*config.Config, error) {
|
||||
readTimeout, err := time.ParseDuration(dto.Server.ReadTimeout)
|
||||
if err != nil {
|
||||
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "read_timeout 格式错误,例如 30s")
|
||||
}
|
||||
writeTimeout, err := time.ParseDuration(dto.Server.WriteTimeout)
|
||||
if err != nil {
|
||||
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "write_timeout 格式错误,例如 30s")
|
||||
}
|
||||
connMaxLifetime, err := time.ParseDuration(dto.Database.ConnMaxLifetime)
|
||||
if err != nil {
|
||||
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "conn_max_lifetime 格式错误,例如 1h")
|
||||
}
|
||||
|
||||
return &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
Port: dto.Server.Port,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
},
|
||||
Database: config.DatabaseConfig{
|
||||
Driver: dto.Database.Driver,
|
||||
Path: dto.Database.Path,
|
||||
Host: dto.Database.Host,
|
||||
Port: dto.Database.Port,
|
||||
User: dto.Database.User,
|
||||
Password: dto.Database.Password,
|
||||
DBName: dto.Database.DBName,
|
||||
MaxIdleConns: dto.Database.MaxIdleConns,
|
||||
MaxOpenConns: dto.Database.MaxOpenConns,
|
||||
ConnMaxLifetime: connMaxLifetime,
|
||||
},
|
||||
Log: config.LogConfig{
|
||||
Level: dto.Log.Level,
|
||||
Path: dto.Log.Path,
|
||||
MaxSize: dto.Log.MaxSize,
|
||||
MaxBackups: dto.Log.MaxBackups,
|
||||
MaxAge: dto.Log.MaxAge,
|
||||
Compress: dto.Log.Compress,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) GetStartupSettings(c *gin.Context) {
|
||||
var cfg *config.Config
|
||||
var configPath string
|
||||
|
||||
if h.mode == "desktop" {
|
||||
desktopCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
cfg = desktopCfg
|
||||
configPath = h.configPath
|
||||
} else {
|
||||
cfg = h.runtimeCfg
|
||||
configPath = h.configPath
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, startupSettingsResponse{
|
||||
Mode: h.mode,
|
||||
Editable: h.editable,
|
||||
ConfigPath: configPath,
|
||||
RestartRequired: h.editable,
|
||||
Config: configToDTO(cfg),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) SaveStartupSettings(c *gin.Context) {
|
||||
if !h.editable {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "server 模式下不允许保存启动参数",
|
||||
"code": "forbidden",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Config startupSettingsDTO `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "无效的请求格式",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := dtoToConfig(req.Config)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfigToPath(cfg, h.configPath); err != nil {
|
||||
if errors.Is(err, appErrors.ErrInvalidRequest) {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
|
||||
return
|
||||
}
|
||||
|
||||
savedCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
|
||||
if err != nil {
|
||||
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, startupSettingsResponse{
|
||||
Mode: h.mode,
|
||||
Editable: h.editable,
|
||||
ConfigPath: h.configPath,
|
||||
RestartRequired: true,
|
||||
Config: configToDTO(savedCfg),
|
||||
})
|
||||
}
|
||||
418
backend/internal/handler/settings_handler_test.go
Normal file
418
backend/internal/handler/settings_handler_test.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func createTestConfig(t *testing.T) (*config.Config, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yaml")
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Database.Path = filepath.Join(dir, "test.db")
|
||||
cfg.Log.Path = filepath.Join(dir, "log")
|
||||
data, err := yaml.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(configPath, data, 0o600))
|
||||
|
||||
return cfg, configPath
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetStartupSettings_Desktop(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
|
||||
|
||||
h.GetStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "desktop", resp.Mode)
|
||||
assert.True(t, resp.Editable)
|
||||
assert.True(t, resp.RestartRequired)
|
||||
assert.Equal(t, configPath, resp.ConfigPath)
|
||||
assert.Equal(t, cfg.Server.Port, resp.Config.Server.Port)
|
||||
assert.Equal(t, "30s", resp.Config.Server.ReadTimeout)
|
||||
assert.Equal(t, cfg.Database.Driver, resp.Config.Database.Driver)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetStartupSettings_Server(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "server", false, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
|
||||
|
||||
h.GetStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "server", resp.Mode)
|
||||
assert.False(t, resp.Editable)
|
||||
assert.False(t, resp.RestartRequired)
|
||||
assert.Equal(t, configPath, resp.ConfigPath)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
newPort := 9999
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": newPort,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": filepath.Join(t.TempDir(), "new.db"),
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": filepath.Join(t.TempDir(), "log"),
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, newPort, resp.Config.Server.Port)
|
||||
assert.True(t, resp.Editable)
|
||||
assert.True(t, resp.RestartRequired)
|
||||
|
||||
savedCfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newPort, savedCfg.Server.Port)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Server_Forbidden(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "server", false, configPath)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 9999,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": "/tmp/test.db",
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": "/tmp/log",
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 403, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "不允许保存")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidConfig(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
originalData, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 0,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": "/tmp/test.db",
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": "/tmp/log",
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
|
||||
currentData, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, originalData, currentData)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidDuration(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 9826,
|
||||
"read_timeout": "not-a-duration",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": "/tmp/test.db",
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": "/tmp/log",
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "read_timeout")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop_CreatesConfigFile(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "nex", "config.yaml")
|
||||
|
||||
_, err := os.Stat(configPath)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 9826,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": filepath.Join(dir, "test.db"),
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": filepath.Join(dir, "log"),
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
_, err = os.Stat(configPath)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop_PasswordIncluded(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 9826,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "mysql",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"user": "root",
|
||||
"password": "secret123",
|
||||
"dbname": "nex",
|
||||
"path": "",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": filepath.Join(t.TempDir(), "log"),
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "secret123", resp.Config.Database.Password)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetStartupSettings_DesktopReadsConfigFile(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
|
||||
savedCfg := config.DefaultConfig()
|
||||
savedCfg.Server.Port = 5555
|
||||
data, err := yaml.Marshal(savedCfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(configPath, data, 0o600))
|
||||
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
|
||||
|
||||
h.GetStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, 5555, resp.Config.Server.Port)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetStartupSettings_ServerReturnsRuntime(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
cfg.Server.Port = 7777
|
||||
|
||||
h := NewSettingsHandler(cfg, "server", false, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
|
||||
|
||||
h.GetStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, 7777, resp.Config.Server.Port)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_InvalidJSON(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader([]byte("{invalid")))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
}
|
||||
Reference in New Issue
Block a user