From c524e8f928c76f23033e17739f76cc85ff8becc0 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 8 May 2026 14:18:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=AF=E5=8A=A8=E5=8F=82=E6=95=B0=20d?= =?UTF-8?q?uration=20=E5=80=99=E9=80=89=E5=80=BC=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=A0=87=E5=87=86=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端 Select 使用 Go time.Duration.String() 标准字符串作为 value, 与后端查询/保存响应保持一致,解决保存后反显不匹配的问题。 --- README.md | 4 +- backend/README.md | 2 +- .../internal/config/config_metadata_test.go | 57 ++++++++++++ .../internal/handler/settings_handler_test.go | 92 +++++++++++++++++++ .../src/__tests__/hooks/useSettings.test.tsx | 2 +- .../src/__tests__/pages/Settings.test.tsx | 2 +- .../pages/Settings/StartupSettingsCard.tsx | 18 ++-- openspec/specs/config-management/spec.md | 30 ++++++ openspec/specs/frontend/spec.md | 6 +- 9 files changed, 197 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 813c59e..39ae33e 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ server 和 desktop 发布产物自包含运行时数据库迁移资源(通过 - **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效 - **Server 模式**:查询返回当前运行有效配置,保存请求始终返回 403 -响应包含 `mode`、`editable`、`config_path`、`restart_required` 元数据和完整启动参数配置。Duration 字段使用字符串格式(如 `30s`、`1h`)。 +响应包含 `mode`、`editable`、`config_path`、`restart_required` 元数据和完整启动参数配置。Duration 字段使用 Go `time.Duration.String()` 标准字符串格式(如 `30s`、`1m0s`、`1h0m0s`);配置文件中用户可手写任意合法 Go duration 字符串(如 `1h`、`30m`),保存时系统会统一为标准格式。 #### 版本信息 - `GET /api/version` - 获取后端构建版本信息(`version`、`commit`、`build_time`),用于前端 About 页面诊断前后端版本一致性 @@ -281,7 +281,7 @@ database: # dbname: nex max_idle_conns: 10 max_open_conns: 100 - conn_max_lifetime: 1h + conn_max_lifetime: 1h0m0s log: level: info diff --git a/backend/README.md b/backend/README.md index bfd41cd..7dbff99 100644 --- a/backend/README.md +++ b/backend/README.md @@ -364,7 +364,7 @@ database: # dbname: nex max_idle_conns: 10 max_open_conns: 100 - conn_max_lifetime: 1h + conn_max_lifetime: 1h0m0s log: level: info diff --git a/backend/internal/config/config_metadata_test.go b/backend/internal/config/config_metadata_test.go index 39e510a..5efad1d 100644 --- a/backend/internal/config/config_metadata_test.go +++ b/backend/internal/config/config_metadata_test.go @@ -66,6 +66,63 @@ func TestDurationConversion(t *testing.T) { assert.Equal(t, cfg.Database.ConnMaxLifetime, parsed) } +func TestSaveConfigToPath_DurationFormat(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + + cfg := DefaultConfig() + cfg.Server.ReadTimeout = 30 * time.Second + cfg.Server.WriteTimeout = 1 * time.Minute + cfg.Database.ConnMaxLifetime = 1 * time.Hour + + err := SaveConfigToPath(cfg, configPath) + require.NoError(t, err) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "conn_max_lifetime: 1h0m0s") + assert.Contains(t, content, "read_timeout: 30s") + assert.Contains(t, content, "write_timeout: 1m0s") +} + +func TestSaveAndReload_DurationRoundTrip(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + + yamlContent := ` +server: + port: 9826 + read_timeout: 30s + write_timeout: 1m +database: + driver: sqlite + path: ` + filepath.Join(dir, "test.db") + ` + max_idle_conns: 10 + max_open_conns: 100 + conn_max_lifetime: 30m +log: + level: info + path: ` + filepath.Join(dir, "log") + ` + max_size: 100 + max_backups: 10 + max_age: 30 + compress: true +` + require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0o600)) + + cfg, err := LoadDesktopConfigAtPath(configPath) + require.NoError(t, err) + assert.Equal(t, 30*time.Minute, cfg.Database.ConnMaxLifetime) + + err = SaveConfigToPath(cfg, configPath) + require.NoError(t, err) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), "conn_max_lifetime: 30m0s") +} + func configToDTO(c *Config) struct { Server struct { Port int `json:"port"` diff --git a/backend/internal/handler/settings_handler_test.go b/backend/internal/handler/settings_handler_test.go index 27b9483..40a52d4 100644 --- a/backend/internal/handler/settings_handler_test.go +++ b/backend/internal/handler/settings_handler_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -416,3 +417,94 @@ func TestSettingsHandler_SaveStartupSettings_InvalidJSON(t *testing.T) { assert.Equal(t, 400, w.Code) } + +func TestSettingsHandler_GetStartupSettings_DurationNormalization(t *testing.T) { + cfg, configPath := createTestConfig(t) + + yamlContent := ` +server: + port: 9826 + read_timeout: 30s + write_timeout: 1m +database: + driver: sqlite + path: ` + cfg.Database.Path + ` + max_idle_conns: 10 + max_open_conns: 100 + conn_max_lifetime: 30m +log: + level: info + path: ` + cfg.Log.Path + ` + max_size: 100 + max_backups: 10 + max_age: 30 + compress: true +` + require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 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, "1m0s", resp.Config.Server.WriteTimeout) + assert.Equal(t, "30m0s", resp.Config.Database.ConnMaxLifetime) +} + +func TestSettingsHandler_SaveStartupSettings_StandardDurationRoundTrip(t *testing.T) { + cfg, configPath := createTestConfig(t) + h := NewSettingsHandler(cfg, "desktop", true, configPath) + + tmpDir := t.TempDir() + body, _ := json.Marshal(map[string]interface{}{ + "config": map[string]interface{}{ + "server": map[string]interface{}{ + "port": 9826, + "read_timeout": "30s", + "write_timeout": "1m0s", + }, + "database": map[string]interface{}{ + "driver": "sqlite", + "path": filepath.Join(tmpDir, "test.db"), + "port": 3306, + "dbname": "nex", + "max_idle_conns": 10, + "max_open_conns": 100, + "conn_max_lifetime": "1h0m0s", + }, + "log": map[string]interface{}{ + "level": "info", + "path": filepath.Join(tmpDir, "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, "1m0s", resp.Config.Server.WriteTimeout) + assert.Equal(t, "1h0m0s", resp.Config.Database.ConnMaxLifetime) + + savedCfg, err := config.LoadDesktopConfigAtPath(configPath) + require.NoError(t, err) + assert.Equal(t, 1*time.Hour, savedCfg.Database.ConnMaxLifetime) +} diff --git a/frontend/src/__tests__/hooks/useSettings.test.tsx b/frontend/src/__tests__/hooks/useSettings.test.tsx index a37b25b..f3227fe 100644 --- a/frontend/src/__tests__/hooks/useSettings.test.tsx +++ b/frontend/src/__tests__/hooks/useSettings.test.tsx @@ -31,7 +31,7 @@ const mockDesktopSettings: StartupSettings = { dbname: 'nex', maxIdleConns: 10, maxOpenConns: 100, - connMaxLifetime: '1h', + connMaxLifetime: '1h0m0s', }, log: { level: 'info', path: '/home/user/.nex/log', maxSize: 100, maxBackups: 10, maxAge: 30, compress: true }, }, diff --git a/frontend/src/__tests__/pages/Settings.test.tsx b/frontend/src/__tests__/pages/Settings.test.tsx index ab7d7db..76516d9 100644 --- a/frontend/src/__tests__/pages/Settings.test.tsx +++ b/frontend/src/__tests__/pages/Settings.test.tsx @@ -41,7 +41,7 @@ const mockDesktopSettings: StartupSettings = { dbname: 'nex', maxIdleConns: 10, maxOpenConns: 100, - connMaxLifetime: '1h', + connMaxLifetime: '1h0m0s', }, log: { level: 'info', diff --git a/frontend/src/pages/Settings/StartupSettingsCard.tsx b/frontend/src/pages/Settings/StartupSettingsCard.tsx index bd505a0..e475971 100644 --- a/frontend/src/pages/Settings/StartupSettingsCard.tsx +++ b/frontend/src/pages/Settings/StartupSettingsCard.tsx @@ -9,18 +9,18 @@ const TIMEOUT_OPTIONS = [ { label: '10 秒', value: '10s' }, { label: '15 秒', value: '15s' }, { label: '30 秒', value: '30s' }, - { label: '1 分钟', value: '60s' }, - { label: '2 分钟', value: '120s' }, - { label: '5 分钟', value: '300s' }, + { label: '1 分钟', value: '1m0s' }, + { label: '2 分钟', value: '2m0s' }, + { label: '5 分钟', value: '5m0s' }, ] const CONN_LIFETIME_OPTIONS = [ - { label: '5 分钟', value: '5m' }, - { label: '15 分钟', value: '15m' }, - { label: '30 分钟', value: '30m' }, - { label: '1 小时', value: '1h' }, - { label: '2 小时', value: '2h' }, - { label: '4 小时', value: '4h' }, + { label: '5 分钟', value: '5m0s' }, + { label: '15 分钟', value: '15m0s' }, + { label: '30 分钟', value: '30m0s' }, + { label: '1 小时', value: '1h0m0s' }, + { label: '2 小时', value: '2h0m0s' }, + { label: '4 小时', value: '4h0m0s' }, ] const MAX_AGE_OPTIONS = [ diff --git a/openspec/specs/config-management/spec.md b/openspec/specs/config-management/spec.md index aca4b08..22e2b1a 100644 --- a/openspec/specs/config-management/spec.md +++ b/openspec/specs/config-management/spec.md @@ -569,3 +569,33 @@ server 入口 SHALL 允许前端查看当前有效启动参数,但 SHALL NOT - **THEN** 后端 SHALL 返回禁止修改错误 - **THEN** 后端 SHALL NOT 写入配置文件 - **THEN** 后端 SHALL NOT 修改当前运行配置 + +### Requirement: 启动参数 duration 标准格式 + +系统 SHALL 在启动参数设置查询、保存响应和配置文件保存中使用 Go `time.Duration.String()` 标准字符串表示 duration 字段,同时继续接受合法 Go duration 字符串作为输入。 + +#### Scenario: 查询启动参数时返回标准 duration 字符串 + +- **WHEN** 后端查询启动参数设置 +- **THEN** 返回配置中的 `server.read_timeout`、`server.write_timeout` 和 `database.conn_max_lifetime` SHALL 使用 Go `time.Duration.String()` 标准字符串格式 +- **THEN** 若配置文件中 `database.conn_max_lifetime` 为 `30m`,返回值 SHALL 为 `30m0s` +- **THEN** 若配置文件中 `database.conn_max_lifetime` 为 `1h`,返回值 SHALL 为 `1h0m0s` + +#### Scenario: 保存启动参数时接受标准 duration 字符串 + +- **WHEN** desktop 入口收到包含 `database.conn_max_lifetime: 1h0m0s` 的有效启动参数保存请求 +- **THEN** 后端 SHALL 成功解析该 duration 字符串 +- **THEN** 后端 SHALL 将配置保存到默认配置文件 +- **THEN** 保存响应中的 `database.conn_max_lifetime` SHALL 为 `1h0m0s` + +#### Scenario: 保存启动参数时写入标准 duration 字符串 + +- **WHEN** desktop 入口成功保存启动参数配置 +- **THEN** 写入配置文件的 `server.read_timeout`、`server.write_timeout` 和 `database.conn_max_lifetime` SHALL 使用 Go `time.Duration.String()` 标准字符串格式 +- **THEN** 若保存请求中的 `database.conn_max_lifetime` 语义为 30 分钟,配置文件中的值 SHALL 为 `30m0s` + +#### Scenario: 读取用户手写的合法 duration 字符串 + +- **WHEN** 配置文件中 duration 字段使用合法 Go duration 字符串,例如 `30m` 或 `30m0s` +- **THEN** 后端 SHALL 正确解析配置文件 +- **THEN** 后端返回启动参数设置时 SHALL 将语义等价的 duration 统一为 Go `time.Duration.String()` 标准字符串 diff --git a/openspec/specs/frontend/spec.md b/openspec/specs/frontend/spec.md index d8ce9de..21c0daf 100644 --- a/openspec/specs/frontend/spec.md +++ b/openspec/specs/frontend/spec.md @@ -412,9 +412,11 @@ TBD - 提供供应商、模型配置和总览的前端管理界面 - **WHEN** 前端渲染启动参数设置表单 - **THEN** `server.readTimeout` 字段 SHALL 使用 Select 下拉组件,提供以下预设选项:5 秒、10 秒、15 秒、30 秒、1 分钟、2 分钟、5 分钟 -- **THEN** `server.writeTimeout` 字段 SHALL 使用 Select 下拉组件,提供与 readTimeout 相同的预设选项 +- **THEN** `server.readTimeout` 字段的 Select value SHALL 分别为 `5s`、`10s`、`15s`、`30s`、`1m0s`、`2m0s`、`5m0s` +- **THEN** `server.writeTimeout` 字段 SHALL 使用 Select 下拉组件,提供与 readTimeout 相同的预设选项和值 - **THEN** `database.connMaxLifetime` 字段 SHALL 使用 Select 下拉组件,提供以下预设选项:5 分钟、15 分钟、30 分钟、1 小时、2 小时、4 小时 -- **THEN** duration 字段的 Select value SHALL 使用 Go duration 字符串格式(如 `"30s"`、`"1h"`) +- **THEN** `database.connMaxLifetime` 字段的 Select value SHALL 分别为 `5m0s`、`15m0s`、`30m0s`、`1h0m0s`、`2h0m0s`、`4h0m0s` +- **THEN** duration 字段的 Select value SHALL 使用 Go `time.Duration.String()` 标准字符串格式 - **THEN** duration 字段的 Select label SHALL 使用中文单位显示(如 `"30 秒"`、`"1 小时"`) #### Scenario: 日志最大保留天数使用下拉预设选择