1
0

5 Commits

Author SHA1 Message Date
e4c96da8a9 fix: CI 触发分支对齐仓库实际默认分支 master 2026-05-07 14:20:29 +08:00
1195e119c6 chore: 合并 dev-ci-optimize 至 master 2026-05-07 14:17:43 +08:00
4eeb14e844 feat: 新增启动参数设置页面,区分 desktop 可编辑与 server 只读 2026-05-07 14:10:56 +08:00
0d30ed9a0f feat: 新增开发 CI 流程,重构 test.yml 支持分层测试
新增 ci.yml,在 push(dev/main)和 PR 时触发快速检查(lint + 全量测试)
重构 test.yml,新增 full 参数控制是否运行 MySQL 和 E2E 测试
release.yml 调用 test.yml 时传 full: true,行为与重构前一致
同步更新 ci-test-gate spec
2026-05-07 12:43:08 +08:00
cd0b3e8fc1 feat: release CI 加入全流程测试门禁
新增独立可复用测试 workflow(test.yml),在 release 构建前串行执行
lint、默认测试、MySQL 测试和 E2E 测试,测试不通过则阻止发布构建。
2026-05-07 12:14:00 +08:00
24 changed files with 1895 additions and 43 deletions

14
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
name: CI
on:
push:
branches: [dev, master]
pull_request:
permissions:
contents: read
jobs:
check:
name: Check
uses: ./.github/workflows/test.yml

View File

@@ -37,9 +37,16 @@ jobs:
go run ./versionctl verify-tag "${GITHUB_REF_NAME}"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
test-gate:
name: Test Gate
needs: prepare
uses: ./.github/workflows/test.yml
with:
full: true
build-web:
name: Build Web Asset
needs: prepare
needs: [prepare, test-gate]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -81,7 +88,7 @@ jobs:
build-linux:
name: Build Linux ${{ matrix.arch }} Assets
needs: prepare
needs: [prepare, test-gate]
strategy:
fail-fast: false
matrix:
@@ -147,7 +154,7 @@ jobs:
build-windows:
name: Build Windows ${{ matrix.arch }} Assets
needs: prepare
needs: [prepare, test-gate]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
@@ -231,7 +238,7 @@ jobs:
build-macos:
name: Build macOS Assets
needs: prepare
needs: [prepare, test-gate]
runs-on: macos-15
permissions:
contents: read

109
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,109 @@
name: Test (Full)
on:
workflow_call:
inputs:
full:
description: "Run full test suite including MySQL and E2E"
required: false
default: false
type: boolean
permissions:
contents: read
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Lint
run: make lint
- name: Test
run: make test
mysql:
name: MySQL Tests
if: inputs.full
needs: check
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: testpass
MYSQL_DATABASE: nex_test
MYSQL_USER: nex_test
MYSQL_PASSWORD: testpass
ports:
- 13306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -ptestpass"
--health-interval=3s
--health-timeout=5s
--health-retries=10
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: MySQL tests
run: cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
e2e:
name: E2E Tests
if: inputs.full
needs: check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Playwright browsers
run: cd frontend && bunx playwright install --with-deps chromium
- name: E2E tests
run: cd frontend && bun run test:e2e

2
.gitignore vendored
View File

@@ -409,6 +409,8 @@ skills-lock.json
.worktrees
!scripts/build/
backend/bin
backend/server
backend/desktop
# Embedfs generated
embedfs/assets/

View File

@@ -27,7 +27,7 @@ nex/
│ │ ├── api/ # API 层(统一请求封装 + 字段转换)
│ │ ├── hooks/ # TanStack Query hooks
│ │ ├── components/ # 通用组件AppLayout
│ │ ├── pages/ # 页面Providers, Stats
│ │ ├── pages/ # 页面Providers, Stats, Settings
│ │ ├── routes/ # React Router 路由配置
│ │ ├── types/ # TypeScript 类型定义
│ │ └── __tests__/ # 单元测试 + 组件测试
@@ -57,6 +57,7 @@ nex/
- **多供应商管理**:配置和管理多个供应商(供应商 ID 仅限字母、数字、下划线)
- **用量统计**:按供应商、模型、日期统计请求数量
- **Web 配置界面**:提供供应商和模型配置管理
- **启动参数设置**:通过 Web 界面查看和编辑启动参数Desktop 可编辑、Server 只读)
## 技术栈
@@ -159,7 +160,7 @@ make server-build
### Release 产物
发布流程由 Git tag `vX.Y.Z` 触发GitHub Actions 会创建 Draft Release上传 server、web 和 desktop 三类产物,同时生成 `SHA256SUMS`
发布流程由 Git tag `vX.Y.Z` 触发GitHub Actions 会先通过全流程测试门禁,再构建并创建 Draft Release上传 server、web 和 desktop 三类产物,同时生成 `SHA256SUMS`
**server 产物**(不内置 Web 管理界面):
@@ -239,6 +240,16 @@ server 和 desktop 发布产物自包含运行时数据库迁移资源(通过
查询参数支持:`provider_id``model_name``start_date``end_date``group_by`
#### 启动参数设置
- `GET /api/settings/startup` - 查询启动参数设置
- `PUT /api/settings/startup` - 保存启动参数设置(仅 Desktop 模式)
**行为差异**
- **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效
- **Server 模式**:查询返回当前运行有效配置,保存请求始终返回 403
响应包含 `mode``editable``config_path``restart_required` 元数据和完整启动参数配置。Duration 字段使用字符串格式(如 `30s``1h`)。
#### 版本信息
- `GET /api/version` - 获取后端构建版本信息(`version``commit``build_time`),用于前端 About 页面诊断前后端版本一致性
@@ -418,9 +429,10 @@ make release-assets-macos
### GitHub Draft Release
- 推送 `vX.Y.Z` tag 后,`.github/workflows/release.yml` 会自动执行发布流水线
- 三个平台 job 会在正式构建前先检查 `go``bun` 和各自的平台打包工具链,缺失时快速失败并在日志中输出诊断信息
- 流水线会先校验 tag 与 `VERSION` 一致再执行全流程测试门禁lint、默认测试、MySQL 测试、E2E 测试),测试不通过则阻止构建
- 测试通过后,三个平台 job 并行构建,各 job 会在正式构建前先检查 `go``bun` 和各自的平台打包工具链,缺失时快速失败并在日志中输出诊断信息
- Windows 发布 job 在 `MSYS2 / MINGW64` shell 中执行,并继承 `setup-go` / `setup-bun` 准备好的工具链路径
- 流水线会先校验 tag 与 `VERSION` 一致,再构建以下资产并上传到 GitHub Draft Release
- 构建以下资产并上传到 GitHub Draft Release
- Linux server
- Windows server
- darwin-amd64 server

View File

@@ -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"})
})

View File

@@ -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>")},
})

View File

@@ -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"})
})

View File

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

View File

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

View 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
}

View 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),
})
}

View 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)
}

View File

@@ -0,0 +1,106 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import React from 'react'
import { MessagePlugin } from 'tdesign-react'
import { useStartupSettings, useSaveStartupSettings } from '@/hooks/useSettings'
import type { StartupSettings } from '@/types'
vi.mock('tdesign-react', () => ({
MessagePlugin: {
success: vi.fn(),
error: vi.fn(),
},
}))
const mockDesktopSettings: StartupSettings = {
mode: 'desktop',
editable: true,
configPath: '/home/user/.nex/config.yaml',
restartRequired: true,
config: {
server: { port: 9826, readTimeout: '30s', writeTimeout: '30s' },
database: {
driver: 'sqlite',
path: '/home/user/.nex/config.db',
host: '',
port: 3306,
user: '',
password: '',
dbname: 'nex',
maxIdleConns: 10,
maxOpenConns: 100,
connMaxLifetime: '1h',
},
log: { level: 'info', path: '/home/user/.nex/log', maxSize: 100, maxBackups: 10, maxAge: 30, compress: true },
},
}
const handlers = [
http.get('/api/settings/startup', () => {
return HttpResponse.json(mockDesktopSettings)
}),
http.put('/api/settings/startup', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({
...mockDesktopSettings,
config: (body as Record<string, unknown>).config,
})
}),
]
const server = setupServer(...handlers)
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('useStartupSettings', () => {
it('fetches startup settings', async () => {
const { result } = renderHook(() => useStartupSettings(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.mode).toBe('desktop')
expect(result.current.data?.editable).toBe(true)
expect(result.current.data?.configPath).toBe('/home/user/.nex/config.yaml')
expect(result.current.data?.restartRequired).toBe(true)
expect(result.current.data?.config.server.port).toBe(9826)
expect(result.current.data?.config.database.driver).toBe('sqlite')
expect(result.current.data?.config.log.level).toBe('info')
})
})
describe('useSaveStartupSettings', () => {
it('saves settings and shows success message for desktop', async () => {
const { result } = renderHook(() => useSaveStartupSettings(), { wrapper: createWrapper() })
result.current.mutate({ config: mockDesktopSettings.config })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(MessagePlugin.success).toHaveBeenCalledWith(
'配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效'
)
})
it('shows error message on failure', async () => {
server.use(
http.put('/api/settings/startup', () => {
return HttpResponse.json({ error: '保存失败' }, { status: 500 })
})
)
const { result } = renderHook(() => useSaveStartupSettings(), { wrapper: createWrapper() })
result.current.mutate({ config: mockDesktopSettings.config })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(MessagePlugin.error).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,169 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import React from 'react'
import { MemoryRouter } from 'react-router'
import { MessagePlugin } from 'tdesign-react'
import SettingsPage from '@/pages/Settings'
import type { StartupSettings } from '@/types'
vi.mock('tdesign-react', async (importOriginal) => {
const actual = await importOriginal<typeof import('tdesign-react')>()
return {
...actual,
MessagePlugin: {
success: vi.fn(),
error: vi.fn(),
},
}
})
const mockDesktopSettings: StartupSettings = {
mode: 'desktop',
editable: true,
configPath: '/home/user/.nex/config.yaml',
restartRequired: true,
config: {
server: {
port: 9826,
readTimeout: '30s',
writeTimeout: '30s',
},
database: {
driver: 'sqlite',
path: '/home/user/.nex/config.db',
host: '',
port: 3306,
user: '',
password: '',
dbname: 'nex',
maxIdleConns: 10,
maxOpenConns: 100,
connMaxLifetime: '1h',
},
log: {
level: 'info',
path: '/home/user/.nex/log',
maxSize: 100,
maxBackups: 10,
maxAge: 30,
compress: true,
},
},
}
const mockServerSettings: StartupSettings = {
...mockDesktopSettings,
mode: 'server',
editable: false,
restartRequired: false,
configPath: '/etc/nex/config.yaml',
}
const desktopHandlers = [
http.get('/api/settings/startup', () => HttpResponse.json(mockDesktopSettings)),
http.put('/api/settings/startup', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ ...mockDesktopSettings, config: (body as Record<string, unknown>).config })
}),
]
const serverHandlers = [http.get('/api/settings/startup', () => HttpResponse.json(mockServerSettings))]
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return ({ children }: { children: React.ReactNode }) => (
<MemoryRouter>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</MemoryRouter>
)
}
describe('SettingsPage', () => {
it('renders startup settings card', async () => {
const mswServer = setupServer(...desktopHandlers)
mswServer.listen({ onUnhandledRequest: 'bypass' })
render(<SettingsPage />, { wrapper: createWrapper() })
expect(await screen.findByText('服务配置')).toBeInTheDocument()
expect(screen.getByText('启动参数设置')).toBeInTheDocument()
mswServer.close()
})
})
describe('StartupSettingsCard - Desktop mode', () => {
const mswServer = setupServer(...desktopHandlers)
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => mswServer.resetHandlers())
afterAll(() => mswServer.close())
it('shows editable form with save button in desktop mode', async () => {
render(<SettingsPage />, { wrapper: createWrapper() })
expect(
await screen.findByText('Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效')
).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
})
it('shows form fields for server, database, and log', async () => {
render(<SettingsPage />, { wrapper: createWrapper() })
expect(await screen.findByText('服务配置')).toBeInTheDocument()
expect(screen.getByText('数据库配置')).toBeInTheDocument()
expect(screen.getByText('日志配置')).toBeInTheDocument()
})
it('shows success message on save', async () => {
render(<SettingsPage />, { wrapper: createWrapper() })
expect(await screen.findByText('服务配置')).toBeInTheDocument()
const saveButton = screen.getByRole('button', { name: '保存' })
await userEvent.click(saveButton)
await waitFor(() => {
expect(MessagePlugin.success).toHaveBeenCalledWith(
'配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效'
)
})
})
})
describe('StartupSettingsCard - Server mode', () => {
const mswServer = setupServer(...serverHandlers)
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => mswServer.resetHandlers())
afterAll(() => mswServer.close())
it('shows read-only form with server-only warning', async () => {
render(<SettingsPage />, { wrapper: createWrapper() })
expect(await screen.findByText('Server 模式下启动参数仅支持查看,不支持从前端编辑')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
})
})
function waitFor(fn: () => void, opts?: { timeout?: number }) {
return new Promise<void>((resolve, reject) => {
const start = Date.now()
const interval = setInterval(() => {
try {
fn()
clearInterval(interval)
resolve()
} catch {
if (Date.now() - start > (opts?.timeout ?? 3000)) {
clearInterval(interval)
reject(new Error('waitFor timeout'))
}
}
}, 50)
})
}

View File

@@ -0,0 +1,10 @@
import type { StartupSettings, SaveStartupSettingsInput } from '@/types'
import { request } from './client'
export async function getStartupSettings(): Promise<StartupSettings> {
return request<StartupSettings>('GET', '/api/settings/startup')
}
export async function saveStartupSettings(input: SaveStartupSettingsInput): Promise<StartupSettings> {
return request<StartupSettings>('PUT', '/api/settings/startup', input)
}

View File

@@ -0,0 +1,33 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { MessagePlugin } from 'tdesign-react'
import * as api from '@/api/settings'
import type { SaveStartupSettingsInput, ApiError } from '@/types'
export const settingsKeys = {
startup: ['settings', 'startup'] as const,
}
export function useStartupSettings() {
return useQuery({
queryKey: settingsKeys.startup,
queryFn: api.getStartupSettings,
staleTime: 0,
})
}
export function useSaveStartupSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: SaveStartupSettingsInput) => api.saveStartupSettings(input),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: settingsKeys.startup })
if (data.mode === 'desktop') {
MessagePlugin.success('配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效')
}
},
onError: (error: ApiError) => {
MessagePlugin.error(error.message)
},
})
}

View File

@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react'
import { Card, Form, Input, InputNumber, Select, Switch, Button, Alert, Divider, Space, Loading } from 'tdesign-react'
import { useStartupSettings, useSaveStartupSettings } from '@/hooks/useSettings'
import type { StartupConfig } from '@/types'
import type { SubmitContext } from 'tdesign-react/es/form/type'
const DURATION_PLACEHOLDERS = {
readTimeout: '例如 30s',
writeTimeout: '例如 30s',
connMaxLifetime: '例如 1h',
}
function flattenConfig(c: StartupConfig): Record<string, unknown> {
return {
'server.port': c.server.port,
'server.readTimeout': c.server.readTimeout,
'server.writeTimeout': c.server.writeTimeout,
'database.driver': c.database.driver,
'database.path': c.database.path,
'database.host': c.database.host,
'database.port': c.database.port,
'database.user': c.database.user,
'database.password': c.database.password,
'database.dbname': c.database.dbname,
'database.maxIdleConns': c.database.maxIdleConns,
'database.maxOpenConns': c.database.maxOpenConns,
'database.connMaxLifetime': c.database.connMaxLifetime,
'log.level': c.log.level,
'log.path': c.log.path,
'log.maxSize': c.log.maxSize,
'log.maxBackups': c.log.maxBackups,
'log.maxAge': c.log.maxAge,
'log.compress': c.log.compress,
}
}
export function StartupSettingsCard() {
const { data: settings, isLoading, isError } = useStartupSettings()
const saveMutation = useSaveStartupSettings()
const [form] = Form.useForm()
const isDesktop = settings?.mode === 'desktop'
const editable = settings?.editable ?? false
const [driver, setDriver] = useState<string>(settings?.config.database.driver ?? 'sqlite')
useEffect(() => {
if (settings?.config && form) {
form.setFieldsValue(flattenConfig(settings.config))
}
}, [form, settings?.config])
const handleDriverChange = (changedValues: Record<string, unknown>) => {
if ('database.driver' in changedValues) {
setDriver(changedValues['database.driver'] as string)
}
}
const isSqlite = driver === 'sqlite'
const isMysql = driver === 'mysql'
const handleSubmit = (context: SubmitContext) => {
if (context.validateResult !== true || !form) return
const values = form.getFieldsValue(true) as Record<string, unknown>
const config: StartupConfig = {
server: {
port: values['server.port'] as number,
readTimeout: values['server.readTimeout'] as string,
writeTimeout: values['server.writeTimeout'] as string,
},
database: {
driver: values['database.driver'] as string,
path: values['database.path'] as string,
host: values['database.host'] as string,
port: values['database.port'] as number,
user: values['database.user'] as string,
password: values['database.password'] as string,
dbname: values['database.dbname'] as string,
maxIdleConns: values['database.maxIdleConns'] as number,
maxOpenConns: values['database.maxOpenConns'] as number,
connMaxLifetime: values['database.connMaxLifetime'] as string,
},
log: {
level: values['log.level'] as string,
path: values['log.path'] as string,
maxSize: values['log.maxSize'] as number,
maxBackups: values['log.maxBackups'] as number,
maxAge: values['log.maxAge'] as number,
compress: values['log.compress'] as boolean,
},
}
saveMutation.mutate({ config })
}
if (isLoading) {
return (
<Card title='启动参数设置'>
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Loading text='加载中...' />
</div>
</Card>
)
}
if (isError || !settings) {
return (
<Card title='启动参数设置'>
<Alert theme='error' message='加载启动参数失败,请刷新页面重试' />
</Card>
)
}
return (
<Card title='启动参数设置'>
{isDesktop && (
<Alert
theme='info'
message='Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效'
style={{ marginBottom: 16 }}
/>
)}
{!editable && (
<Alert
theme='warning'
message='Server 模式下启动参数仅支持查看,不支持从前端编辑'
style={{ marginBottom: 16 }}
/>
)}
<Form
form={form}
layout='vertical'
labelWidth={140}
initialData={flattenConfig(settings.config)}
onSubmit={handleSubmit}
onValuesChange={handleDriverChange}
disabled={!editable}
>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}></div>
<Form.FormItem label='端口' name='server.port' rules={[{ required: true, message: '请输入端口' }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem label='读超时' name='server.readTimeout' rules={[{ required: true, message: '请输入读超时' }]}>
<Input placeholder={DURATION_PLACEHOLDERS.readTimeout} />
</Form.FormItem>
<Form.FormItem label='写超时' name='server.writeTimeout' rules={[{ required: true, message: '请输入写超时' }]}>
<Input placeholder={DURATION_PLACEHOLDERS.writeTimeout} />
</Form.FormItem>
<Divider />
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}></div>
<Form.FormItem label='驱动' name='database.driver' rules={[{ required: true, message: '请选择数据库驱动' }]}>
<Select>
<Select.Option value='sqlite'>SQLite</Select.Option>
<Select.Option value='mysql'>MySQL</Select.Option>
</Select>
</Form.FormItem>
{isSqlite && (
<Form.FormItem
label='数据库路径'
name='database.path'
rules={[{ required: true, message: '请输入数据库路径' }]}
>
<Input placeholder='例如 ~/.nex/config.db' />
</Form.FormItem>
)}
{isMysql && (
<>
<Form.FormItem
label='主机地址'
name='database.host'
rules={[{ required: true, message: '请输入主机地址' }]}
>
<Input placeholder='例如 localhost' />
</Form.FormItem>
<Form.FormItem label='端口' name='database.port' rules={[{ required: true, message: '请输入端口' }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem label='用户名' name='database.user' rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder='例如 root' />
</Form.FormItem>
<Form.FormItem label='密码' name='database.password'>
<Input placeholder='MySQL 密码' />
</Form.FormItem>
<Form.FormItem
label='数据库名'
name='database.dbname'
rules={[{ required: true, message: '请输入数据库名' }]}
>
<Input placeholder='例如 nex' />
</Form.FormItem>
</>
)}
<Form.FormItem
label='最大空闲连接数'
name='database.maxIdleConns'
rules={[{ required: true, message: '请输入最大空闲连接数' }]}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem
label='最大打开连接数'
name='database.maxOpenConns'
rules={[{ required: true, message: '请输入最大打开连接数' }]}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem
label='连接最大生命周期'
name='database.connMaxLifetime'
rules={[{ required: true, message: '请输入连接最大生命周期' }]}
>
<Input placeholder={DURATION_PLACEHOLDERS.connMaxLifetime} />
</Form.FormItem>
<Divider />
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}></div>
<Form.FormItem label='日志级别' name='log.level' rules={[{ required: true, message: '请选择日志级别' }]}>
<Select>
<Select.Option value='debug'>debug</Select.Option>
<Select.Option value='info'>info</Select.Option>
<Select.Option value='warn'>warn</Select.Option>
<Select.Option value='error'>error</Select.Option>
</Select>
</Form.FormItem>
<Form.FormItem label='日志路径' name='log.path' rules={[{ required: true, message: '请输入日志路径' }]}>
<Input placeholder='例如 ~/.nex/log' />
</Form.FormItem>
<Form.FormItem
label='单文件最大大小'
name='log.maxSize'
rules={[{ required: true, message: '请输入最大大小' }]}
>
<InputNumber min={1} suffix=' MB' style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem
label='最大备份数'
name='log.maxBackups'
rules={[{ required: true, message: '请输入最大备份数' }]}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem
label='最大保留天数'
name='log.maxAge'
rules={[{ required: true, message: '请输入最大保留天数' }]}
>
<InputNumber min={0} suffix=' 天' style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem label='压缩旧日志' name='log.compress'>
<Switch />
</Form.FormItem>
{editable && (
<Space style={{ marginTop: 16 }}>
<Button
theme='primary'
loading={saveMutation.isPending}
onClick={() => {
form?.submit()
}}
>
</Button>
</Space>
)}
</Form>
</Card>
)
}

View File

@@ -1,11 +1,5 @@
import { Card } from 'tdesign-react'
import { StartupSettingsCard } from './StartupSettingsCard'
export default function SettingsPage() {
return (
<Card title='设置'>
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
...
</div>
</Card>
)
return <StartupSettingsCard />
}

View File

@@ -92,3 +92,49 @@ export interface ApiErrorResponse {
error: string
code?: string
}
export interface StartupServerConfig {
port: number
readTimeout: string
writeTimeout: string
}
export interface StartupDatabaseConfig {
driver: string
path: string
host: string
port: number
user: string
password: string
dbname: string
maxIdleConns: number
maxOpenConns: number
connMaxLifetime: string
}
export interface StartupLogConfig {
level: string
path: string
maxSize: number
maxBackups: number
maxAge: number
compress: boolean
}
export interface StartupConfig {
server: StartupServerConfig
database: StartupDatabaseConfig
log: StartupLogConfig
}
export interface StartupSettings {
mode: 'server' | 'desktop'
editable: boolean
configPath: string
restartRequired: boolean
config: StartupConfig
}
export type SaveStartupSettingsInput = {
config: StartupConfig
}

View File

@@ -0,0 +1,151 @@
# CI Test Gate
## Purpose
定义 CI 全流程测试门禁,作为 release 和未来其他 CI 流程的前序质量检查,覆盖 lint、默认测试、MySQL 测试和 E2E 测试。
## Requirements
### Requirement: 独立可复用测试 workflow
系统 SHALL 提供独立的全流程测试 workflow`test.yml`),使用 `workflow_call` 触发器,通过 `full` 布尔参数控制测试分层执行。
#### Scenario: workflow_call 触发器
- **WHEN** 查看 `.github/workflows/test.yml` 的触发器配置
- **THEN** SHALL 使用 `on: workflow_call` 触发器
- **THEN** SHALL 声明 `inputs.full` 布尔参数,默认值为 `false`
- **THEN** SHALL NOT 使用 `push``pull_request` 等其他触发器
#### Scenario: 被其他 workflow 引用(快速模式)
- **WHEN** 其他 workflow 的 job 通过 `uses: ./.github/workflows/test.yml` 引用此 workflow 且未传 `full` 或传 `full: false`
- **THEN** test workflow SHALL 仅执行 `check` joblint + 全量测试)
- **THEN** test workflow SHALL NOT 执行 MySQL 测试和 E2E 测试
#### Scenario: 被其他 workflow 引用(完整模式)
- **WHEN** 其他 workflow 的 job 引用此 workflow 且传 `full: true`
- **THEN** test workflow SHALL 执行 `check``mysql``e2e` 三个 job
- **THEN** `mysql``e2e` job SHALL 在 `check` job 成功后并行执行
### Requirement: 全流程测试步骤编排
测试 workflow SHALL 将测试步骤拆分为 `check``mysql``e2e` 三个独立 job通过 `full` 参数和 `needs` 依赖控制执行。
#### Scenario: check job始终执行
- **WHEN** 测试 workflow 被调用(无论 `full` 值)
- **THEN** `check` job SHALL 始终执行
- **THEN** SHALL 在 `check` job 内按顺序执行checkout含 LFS→ setup Go → setup Bun → `make lint``make test`
- **THEN** `make lint` SHALL 覆盖 backend golangci-lint、frontend typecheck + eslint + prettier、versionctl golangci-lint
- **THEN** `make test` SHALL 覆盖 backend 核心测试、frontend Vitest 单元/组件测试、desktop 测试和 versionctl 测试
- **THEN** `make test` SHALL NOT 覆盖 MySQL 专项测试或 frontend E2E 测试
- **THEN** lint 或测试失败时 SHALL 阻止后续步骤执行
#### Scenario: mysql job仅 full=true
- **WHEN** 测试 workflow 被调用且 `full=true`,且 `check` job 成功
- **THEN** `mysql` job SHALL 执行
- **THEN** SHALL checkout 仓库代码
- **THEN** SHALL 安装 Go 工具链(使用 `go.work` 文件指定版本,缓存覆盖 `backend/go.sum``versionctl/go.sum`
- **THEN** SHALL 使用 GitHub Actions `services:` 声明 MySQL 8.0 容器
- **THEN** MySQL 容器 SHALL 映射端口 `13306:3306`
- **THEN** MySQL 容器 SHALL 配置 `MYSQL_DATABASE=nex_test``MYSQL_USER=nex_test``MYSQL_PASSWORD=testpass`
- **THEN** SHALL 执行 `cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1`
#### Scenario: mysql job 跳过
- **WHEN** 测试 workflow 被调用且 `full=false`
- **THEN** `mysql` job SHALL NOT 执行
#### Scenario: e2e job仅 full=true
- **WHEN** 测试 workflow 被调用且 `full=true`,且 `check` job 成功
- **THEN** `e2e` job SHALL 执行
- **THEN** SHALL checkout 仓库代码(含 LFS
- **THEN** SHALL 安装 Go 工具链(使用 `go.work` 文件指定版本,缓存覆盖 `backend/go.sum``versionctl/go.sum`
- **THEN** SHALL 安装 Bun 运行时
- **THEN** SHALL 安装 Playwright Chromium 浏览器:`cd frontend && bunx playwright install --with-deps chromium`
- **THEN** SHALL 执行 `cd frontend && bun run test:e2e`
- **THEN** Playwright SHALL 使用 CI 模式(`forbidOnly: true``retries: 2`
#### Scenario: e2e job 跳过
- **WHEN** 测试 workflow 被调用且 `full=false`
- **THEN** `e2e` job SHALL NOT 执行
#### Scenario: mysql 和 e2e 并行执行
- **WHEN** `full=true``check` job 成功
- **THEN** `mysql` job 和 `e2e` job SHALL 并行执行
- **THEN** 两个 job 之间 SHALL NOT 有 `needs` 依赖关系
#### Scenario: check 失败阻止后续 job
- **WHEN** `check` job 中 lint 或测试任一失败
- **THEN** `mysql``e2e` job SHALL NOT 执行
### Requirement: 开发 CI 自动触发
系统 SHALL 在 `push``dev``main` 分支)和所有 `pull_request` 事件时自动触发快速质量检查。
#### Scenario: push 到 dev 分支触发 CI
- **WHEN** 代码推送到 `dev` 分支
- **THEN** SHALL 触发 CI workflow
- **THEN** CI workflow SHALL 调用 `test.yml``full=false`
- **THEN** SHALL 仅执行 lint 和全量单元/集成测试
#### Scenario: push 到 main 分支触发 CI
- **WHEN** 代码推送到 `main` 分支
- **THEN** SHALL 触发 CI workflow
- **THEN** CI workflow SHALL 调用 `test.yml``full=false`
#### Scenario: Pull Request 触发 CI
- **WHEN** 创建或更新 Pull Request
- **THEN** SHALL 触发 CI workflow
- **THEN** CI workflow SHALL 调用 `test.yml``full=false`
#### Scenario: CI workflow 极简设计
- **WHEN** 查看 `.github/workflows/ci.yml`
- **THEN** SHALL 仅包含触发器配置和一个 job 引用 `test.yml`
- **THEN** SHALL NOT 定义任何直接执行的步骤
- **THEN** SHALL NOT 传递 `full: true`
### Requirement: 发布流水线使用完整测试模式
`release.yml` 调用 `test.yml` 时 SHALL 显式传递 `full: true`,确保发布流程执行完整测试。
#### Scenario: release 调用 test.yml 传 full: true
- **WHEN** 发布流水线的 `test-gate` job 引用 `test.yml`
- **THEN** SHALL 传递 `with: full: true`
- **THEN** 发布流水线 SHALL 执行 `check``mysql``e2e` 三个 job
- **THEN** 测试行为 SHALL 与重构前一致
### Requirement: 测试 workflow 工具链依赖
测试 workflow SHALL 在单个 ubuntu runner 上准备完整的工具链环境。
#### Scenario: 工具链安装
- **WHEN** 测试 workflow 开始执行
- **THEN** SHALL checkout 仓库代码并拉取 Git LFS 文件
- **THEN** SHALL 安装 Go 工具链(使用 `go.work` 文件指定版本)
- **THEN** SHALL 安装 Bun 运行时
- **THEN** Go 模块缓存 SHALL 覆盖 `backend/go.sum``versionctl/go.sum`
### Requirement: 测试 workflow 资源隔离
测试 workflow 中的各测试步骤 SHALL 使用隔离的资源,不干扰主环境。
#### Scenario: E2E 临时资源隔离
- **WHEN** E2E 测试运行
- **THEN** Go 后端 SHALL 使用临时目录的独立数据库文件(`/tmp/nex-e2e/test.db`
- **THEN** Go 后端 SHALL 使用临时目录的日志目录(`/tmp/nex-e2e/log/`
- **THEN** 临时资源 SHALL 在测试结束后自动清理

View File

@@ -467,3 +467,105 @@ server 入口 SHALL 支持通过环境变量设置所有配置项,符合 serve
- **WHEN** desktop 启动时存在 `NEX_SERVER_PORT``NEX_DATABASE_PATH``NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
- **THEN** SHALL NOT 读取这些环境变量作为配置源
- **THEN** SHALL 使用 `~/.nex/config.yaml` 和默认值加载配置
### Requirement: 启动参数设置查询
系统 SHALL 提供面向前端设置页的启动参数查询能力,按入口返回用于展示的启动参数设置视图和当前入口的可编辑状态。
#### Scenario: Desktop 查询配置文件编辑视图
- **WHEN** desktop 入口收到启动参数查询请求
- **THEN** 后端 SHALL 使用 desktop 配置语义读取 `~/.nex/config.yaml` 和默认值
- **THEN** 后端 SHALL 返回用于编辑配置文件的启动参数设置视图
- **THEN** 后端 SHALL NOT 将查询结果应用到当前运行配置快照
- **THEN** 返回配置 SHALL 包含 `server``database``log` 配置分组
- **THEN** 返回配置 SHALL 覆盖现有配置结构中的全部启动参数字段
- **THEN** 返回配置 SHALL 直接包含 `database.password` 字段值
#### Scenario: Server 查询当前有效启动参数
- **WHEN** server 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回当前运行进程启动后使用的有效配置
- **THEN** 返回配置 SHALL 包含 `server``database``log` 配置分组
- **THEN** 返回配置 SHALL 覆盖现有配置结构中的全部启动参数字段
- **THEN** 返回配置 SHALL 直接包含 `database.password` 字段值
#### Scenario: 查询返回入口模式元数据
- **WHEN** 前端请求启动参数设置
- **THEN** 后端 SHALL 返回当前入口模式,取值为 `server``desktop`
- **THEN** 后端 SHALL 返回 `editable` 表示当前入口是否允许前端保存启动参数
- **THEN** 后端 SHALL 返回配置文件路径
- **THEN** 后端 SHALL 返回 `restart_required` 表示保存后是否需要重启生效
#### Scenario: Desktop 查询返回可编辑元数据
- **WHEN** desktop 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回 `mode``desktop`
- **THEN** 后端 SHALL 返回 `editable` 为 true
- **THEN** 后端 SHALL 返回配置文件路径为默认配置文件 `~/.nex/config.yaml`
- **THEN** 后端 SHALL 返回 `restart_required` 为 true
#### Scenario: 查询不返回来源追踪信息
- **WHEN** 前端请求启动参数设置
- **THEN** 后端 SHALL NOT 要求返回每个字段的配置来源标签
- **THEN** 后端 SHALL NOT 要求区分当前运行值和配置文件值
### Requirement: Desktop 启动参数保存
desktop 入口 SHALL 允许前端通过设置页保存启动参数到默认配置文件,并保持当前运行时配置快照不变。
#### Scenario: Desktop 保存有效启动参数
- **WHEN** desktop 入口收到有效的启动参数保存请求
- **THEN** 后端 SHALL 验证请求配置符合现有配置验证规则
- **THEN** 后端 SHALL 将配置保存到 `~/.nex/config.yaml`
- **THEN** 保存的配置文件权限 SHALL 符合现有配置文件安全要求
- **THEN** 后端 SHALL 返回保存后的启动参数设置
#### Scenario: Desktop 保存时创建配置文件
- **WHEN** desktop 入口收到有效的启动参数保存请求
- **AND** `~/.nex/config.yaml` 不存在
- **THEN** 后端 SHALL 创建配置文件并写入提交的配置
- **THEN** 后端 SHALL NOT 在查询启动参数时自动创建配置文件
#### Scenario: Desktop 保存不动态应用配置
- **WHEN** desktop 入口成功保存启动参数
- **THEN** 当前运行中的配置快照 SHALL 保持不变
- **THEN** 当前运行中的 HTTP server、数据库连接、日志器和请求处理 SHALL NOT 因保存操作而重建或中断
- **THEN** 保存后的配置 SHALL 在下一次 desktop 启动时生效
- **THEN** 后端 SHALL NOT 自动重启 desktop
#### Scenario: Desktop 拒绝无效启动参数
- **WHEN** desktop 入口收到无效的启动参数保存请求
- **THEN** 后端 SHALL 返回验证错误
- **THEN** 后端 SHALL NOT 写入无效配置到 `~/.nex/config.yaml`
### Requirement: Server 启动参数只读
server 入口 SHALL 允许前端查看当前有效启动参数,但 SHALL NOT 允许前端保存或修改启动参数。
#### Scenario: Server 查询只读元数据
- **WHEN** server 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回 `mode``server`
- **THEN** 后端 SHALL 返回 `editable` 为 false
- **THEN** 后端 SHALL 返回 `restart_required` 为 false
- **THEN** 后端 SHALL 返回 server 启动时实际解析到的配置文件路径
#### Scenario: Server 查询返回自定义配置文件路径
- **WHEN** server 入口使用 `--config /path/to/custom.yaml` 启动
- **AND** server 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回配置文件路径 `/path/to/custom.yaml`
#### Scenario: Server 拒绝保存启动参数
- **WHEN** server 入口收到启动参数保存请求
- **THEN** 后端 SHALL 返回禁止修改错误
- **THEN** 后端 SHALL NOT 写入配置文件
- **THEN** 后端 SHALL NOT 修改当前运行配置

View File

@@ -374,13 +374,54 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 提供设置页面
前端 SHALL 提供设置页面。
前端 SHALL 提供设置页面,并在设置页面中以独立 Card 展示启动参数设置
#### Scenario: 显示设置页面
- **WHEN** 用户访问设置页面
- **THEN** 前端 SHALL 显示设置页面
- **THEN** 开发中提示文字颜色 SHALL 使用 \`var(--td-text-color-placeholder)\` Token
- **THEN** 前端 SHALL 显示标题为“启动参数设置”的 Card
- **THEN** 启动参数设置 Card SHALL 与未来其他设置 Card 在视觉结构上保持独立
#### Scenario: Desktop 模式显示可编辑启动参数
- **WHEN** 后端返回启动参数设置 `editable` 为 true
- **THEN** 前端 SHALL 在“启动参数设置” Card 中显示可编辑表单
- **THEN** 表单 SHALL 覆盖 `server``database``log` 配置分组
- **THEN** 前端 SHALL 提示“Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效”
- **THEN** 前端 SHALL 显示保存按钮
- **THEN** 前端 SHALL 在保存成功后提示“配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效”
#### Scenario: Server 模式显示只读启动参数
- **WHEN** 后端返回启动参数设置 `editable` 为 false
- **THEN** 前端 SHALL 在“启动参数设置” Card 中显示只读表单
- **THEN** 所有启动参数字段 SHALL 不可编辑
- **THEN** 前端 SHALL 隐藏或禁用保存按钮
- **THEN** 前端 SHALL 提示“Server 模式下启动参数仅支持查看,不支持从前端编辑”
#### Scenario: 启动参数展示内容
- **WHEN** 前端渲染启动参数设置表单
- **THEN** 前端 SHALL 直接展示后端返回的启动参数设置值
- **THEN** 前端 SHALL NOT 区分当前运行值和配置文件值
- **THEN** 前端 SHALL NOT 展示配置来源标签
- **THEN** 前端 SHALL 直接展示 `database.password` 字段值
#### Scenario: 数据库驱动表单切换
- **WHEN** 启动参数设置中的 `database.driver``sqlite`
- **THEN** 前端 SHALL 允许配置 SQLite 数据库路径
- **THEN** 前端 SHALL 弱化或禁用 MySQL 专属字段
- **WHEN** 启动参数设置中的 `database.driver``mysql`
- **THEN** 前端 SHALL 允许配置 MySQL host、port、user、password、dbname 字段
- **THEN** 前端 SHALL 弱化或禁用 SQLite 专属路径字段
#### Scenario: 启动参数保存失败
- **WHEN** 用户保存启动参数且后端返回验证错误或保存错误
- **THEN** 前端 SHALL 显示用户可理解的错误提示
- **THEN** 前端 SHALL 保持用户当前填写内容,便于修正后重新保存
### Requirement: 显示统一模型 ID

View File

@@ -8,12 +8,12 @@
### Requirement: Tag 驱动发布流水线
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。发布流水线 SHALL 使用 `./versionctl` 而非 `./backend/cmd/versionctl` 调用版本管理工具。
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。发布流水线 SHALL 使用 `./versionctl` 而非 `./backend/cmd/versionctl` 调用版本管理工具。发布流水线 SHALL 在进入构建阶段前完成全流程测试验证,测试未通过 SHALL NOT 执行任何构建。
#### Scenario: 有效发布 tag
- **WHEN** 仓库收到 `v1.2.3` tag push
- **THEN** 发布流水线 SHALL 启动版本校验、构建和 Release 组装步骤
- **THEN** 发布流水线 SHALL 启动版本校验、全流程测试、构建和 Release 组装步骤
- **AND** 版本校验步骤 SHALL 使用 `go run ./versionctl print``go run ./versionctl verify-tag` 获取并验证版本
#### Scenario: 普通分支推送
@@ -21,6 +21,19 @@
- **WHEN** 仓库收到非 tag 的分支 push
- **THEN** 系统 SHALL NOT 创建 GitHub Release
#### Scenario: 测试门禁阻止构建
- **WHEN** 发布流水线中全流程测试步骤lint、默认测试、MySQL 测试、E2E 测试)任一失败
- **THEN** 发布流水线 SHALL NOT 执行任何平台构建
- **THEN** 发布流水线 SHALL NOT 创建 Draft Release
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
#### Scenario: 测试通过后并行构建
- **WHEN** 全流程测试全部通过
- **THEN** web、Linux、Windows、macOS 构建 SHALL 并行执行
- **AND** 所有构建 job SHALL 依赖 `prepare``test-gate`
### Requirement: 发布流水线 Go 模块缓存覆盖
发布流水线 SHALL 在所有 Go module 的 go.sum 文件存在时正确设置 Go 模块缓存路径,确保新增的 `versionctl` module 依赖也被缓存。
@@ -198,7 +211,7 @@
#### Scenario: 发布成功时创建 Draft Release
- **WHEN** 版本校验通过且 server、web、desktop 的全部目标发布资产构建完成
- **WHEN** 版本校验通过、全流程测试通过且 server、web、desktop 的全部目标发布资产构建完成
- **THEN** 系统 SHALL 创建或更新与该 tag 对应的 GitHub Draft Release
- **AND** 系统 SHALL 上传 server、web 与 desktop 的全部发布资产
- **AND** 系统 SHALL 上传 `SHA256SUMS`
@@ -211,7 +224,7 @@
#### Scenario: 构建失败时阻止完成发布
- **WHEN** 任一目标发布资产构建失败、打包失败、校验失败、artifact 上传为空版本校验失败
- **WHEN** 任一目标发布资产构建失败、打包失败、校验失败、artifact 上传为空版本校验失败或全流程测试失败
- **THEN** 发布流水线 SHALL 失败
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果