1
0

fix: 启动参数 duration 候选值对齐后端标准格式

前端 Select 使用 Go time.Duration.String() 标准字符串作为 value,
与后端查询/保存响应保持一致,解决保存后反显不匹配的问题。
This commit is contained in:
2026-05-08 14:18:09 +08:00
parent 6b00045f4e
commit c524e8f928
9 changed files with 197 additions and 16 deletions

View File

@@ -248,7 +248,7 @@ server 和 desktop 发布产物自包含运行时数据库迁移资源(通过
- **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效 - **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效
- **Server 模式**:查询返回当前运行有效配置,保存请求始终返回 403 - **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 页面诊断前后端版本一致性 - `GET /api/version` - 获取后端构建版本信息(`version``commit``build_time`),用于前端 About 页面诊断前后端版本一致性
@@ -281,7 +281,7 @@ database:
# dbname: nex # dbname: nex
max_idle_conns: 10 max_idle_conns: 10
max_open_conns: 100 max_open_conns: 100
conn_max_lifetime: 1h conn_max_lifetime: 1h0m0s
log: log:
level: info level: info

View File

@@ -364,7 +364,7 @@ database:
# dbname: nex # dbname: nex
max_idle_conns: 10 max_idle_conns: 10
max_open_conns: 100 max_open_conns: 100
conn_max_lifetime: 1h conn_max_lifetime: 1h0m0s
log: log:
level: info level: info

View File

@@ -66,6 +66,63 @@ func TestDurationConversion(t *testing.T) {
assert.Equal(t, cfg.Database.ConnMaxLifetime, parsed) 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 { func configToDTO(c *Config) struct {
Server struct { Server struct {
Port int `json:"port"` Port int `json:"port"`

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -416,3 +417,94 @@ func TestSettingsHandler_SaveStartupSettings_InvalidJSON(t *testing.T) {
assert.Equal(t, 400, w.Code) 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)
}

View File

@@ -31,7 +31,7 @@ const mockDesktopSettings: StartupSettings = {
dbname: 'nex', dbname: 'nex',
maxIdleConns: 10, maxIdleConns: 10,
maxOpenConns: 100, maxOpenConns: 100,
connMaxLifetime: '1h', connMaxLifetime: '1h0m0s',
}, },
log: { level: 'info', path: '/home/user/.nex/log', maxSize: 100, maxBackups: 10, maxAge: 30, compress: true }, log: { level: 'info', path: '/home/user/.nex/log', maxSize: 100, maxBackups: 10, maxAge: 30, compress: true },
}, },

View File

@@ -41,7 +41,7 @@ const mockDesktopSettings: StartupSettings = {
dbname: 'nex', dbname: 'nex',
maxIdleConns: 10, maxIdleConns: 10,
maxOpenConns: 100, maxOpenConns: 100,
connMaxLifetime: '1h', connMaxLifetime: '1h0m0s',
}, },
log: { log: {
level: 'info', level: 'info',

View File

@@ -9,18 +9,18 @@ const TIMEOUT_OPTIONS = [
{ label: '10 秒', value: '10s' }, { label: '10 秒', value: '10s' },
{ label: '15 秒', value: '15s' }, { label: '15 秒', value: '15s' },
{ label: '30 秒', value: '30s' }, { label: '30 秒', value: '30s' },
{ label: '1 分钟', value: '60s' }, { label: '1 分钟', value: '1m0s' },
{ label: '2 分钟', value: '120s' }, { label: '2 分钟', value: '2m0s' },
{ label: '5 分钟', value: '300s' }, { label: '5 分钟', value: '5m0s' },
] ]
const CONN_LIFETIME_OPTIONS = [ const CONN_LIFETIME_OPTIONS = [
{ label: '5 分钟', value: '5m' }, { label: '5 分钟', value: '5m0s' },
{ label: '15 分钟', value: '15m' }, { label: '15 分钟', value: '15m0s' },
{ label: '30 分钟', value: '30m' }, { label: '30 分钟', value: '30m0s' },
{ label: '1 小时', value: '1h' }, { label: '1 小时', value: '1h0m0s' },
{ label: '2 小时', value: '2h' }, { label: '2 小时', value: '2h0m0s' },
{ label: '4 小时', value: '4h' }, { label: '4 小时', value: '4h0m0s' },
] ]
const MAX_AGE_OPTIONS = [ const MAX_AGE_OPTIONS = [

View File

@@ -569,3 +569,33 @@ server 入口 SHALL 允许前端查看当前有效启动参数,但 SHALL NOT
- **THEN** 后端 SHALL 返回禁止修改错误 - **THEN** 后端 SHALL 返回禁止修改错误
- **THEN** 后端 SHALL NOT 写入配置文件 - **THEN** 后端 SHALL NOT 写入配置文件
- **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()` 标准字符串

View File

@@ -412,9 +412,11 @@ TBD - 提供供应商、模型配置和总览的前端管理界面
- **WHEN** 前端渲染启动参数设置表单 - **WHEN** 前端渲染启动参数设置表单
- **THEN** `server.readTimeout` 字段 SHALL 使用 Select 下拉组件提供以下预设选项5 秒、10 秒、15 秒、30 秒、1 分钟、2 分钟、5 分钟 - **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** `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 小时"` - **THEN** duration 字段的 Select label SHALL 使用中文单位显示(如 `"30 秒"``"1 小时"`
#### Scenario: 日志最大保留天数使用下拉预设选择 #### Scenario: 日志最大保留天数使用下拉预设选择