1
0

12 Commits

Author SHA1 Message Date
2dec9e5c54 feat: 增强桌面启动失败提示与测试覆盖 2026-05-08 23:42:48 +08:00
c524e8f928 fix: 启动参数 duration 候选值对齐后端标准格式
前端 Select 使用 Go time.Duration.String() 标准字符串作为 value,
与后端查询/保存响应保持一致,解决保存后反显不匹配的问题。
2026-05-08 14:18:09 +08:00
6b00045f4e feat: 启动参数超时和日志保留天数改用下拉预设选择 2026-05-08 00:26:35 +08:00
e719d3c8f1 chore: 追踪 .claude/settings.json 配置文件 2026-05-07 21:16:30 +08:00
6908b9653b feat: CI check job 扩展为三平台 matrix 并行 lint/test
将 test.yml 的 check job 从单平台 ubuntu 改为 ubuntu/macos/windows 三平台并行,
Linux 额外安装 libayatana-appindicator3-dev 以支持 systray CGo 编译。
2026-05-07 17:07:55 +08:00
d8e64ef0e9 chore: 更新应用图标资源 2026-05-07 15:34:25 +08:00
fb9f6d1d00 refactor: 统计页面改名为"总览"并提升至侧边栏首位
将侧边栏"用量统计"菜单项改名为"总览",移至第一位,
默认路由重定向从 /providers 改为 /stats
2026-05-07 15:05:45 +08:00
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
63 changed files with 4077 additions and 392 deletions

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"tdesign-mcp-server": {
"command": "bunx",
"args": ["tdesign-mcp-server@latest"]
}
}
}

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

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

@@ -0,0 +1,116 @@
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 (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libayatana-appindicator3-dev
- 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

5
.gitignore vendored
View File

@@ -399,7 +399,8 @@ env/
cython_debug/
# Custom
.claude
.claude/*
!.claude/settings.json
.opencode
.codex
openspec/changes/archive
@@ -409,6 +410,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 只读)
## 技术栈
@@ -126,7 +127,7 @@ make desktop-build-linux TARGET_ARCH=arm64
- 桌面应用需要 CGO 支持
- macOS: 自带 Xcode Command Line Tools
- Linux 构建: 需要 gcc、pkg-config、GTK3 开发包和 Ayatana AppIndicator 开发包Ubuntu/Debian: `libgtk-3-dev``libayatana-appindicator3-dev`
- Linux 运行: 需要 GTK3、Ayatana AppIndicator 和 xdg-utilsAppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含
- Linux 运行: 需要 GTK3、Ayatana AppIndicator 和 xdg-utils启动失败提示会 best-effort 使用 `notify-send``kdialog``zenity``xmessage`,这些通知/弹窗工具为软依赖,缺失时会降级到标准错误输出或日志;AppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含
- Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链desktop 使用 GUI linker flags 隐藏控制台窗口
- macOS DMG: 发布包暂不签名、不 notarize首次打开可能出现 Gatekeeper 提示
@@ -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 字段使用 Go `time.Duration.String()` 标准字符串格式(如 `30s``1m0s``1h0m0s`);配置文件中用户可手写任意合法 Go duration 字符串(如 `1h``30m`),保存时系统会统一为标准格式。
#### 版本信息
- `GET /api/version` - 获取后端构建版本信息(`version``commit``build_time`),用于前端 About 页面诊断前后端版本一致性
@@ -270,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
@@ -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

Binary file not shown.

BIN
assets/icon.ico LFS

Binary file not shown.

BIN
assets/icon.png LFS

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -4,17 +4,35 @@ package main
import (
"fmt"
"os/exec"
"strings"
"go.uber.org/zap"
)
func showError(title, message string) {
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`,
escapeAppleScript(message), escapeAppleScript(title))
if err := exec.Command("osascript", "-e", script).Run(); err != nil {
dialogLogger().Warn("显示错误对话框失败", zap.Error(err))
func platformStartupChannels(runner commandRunner) []promptChannel {
return []promptChannel{
{
name: "macos-notification",
available: func() error {
_, err := runner.LookPath("osascript")
return err
},
run: func(req promptRequest) error {
script := fmt.Sprintf(`display notification "%s" with title "%s" subtitle "%s"`,
escapeAppleScript(req.message), escapeAppleScript(req.title), escapeAppleScript(req.subtitle))
return runner.Run(promptCommandTimeout, nil, "osascript", "-e", script)
},
},
{
name: "macos-alert",
available: func() error {
_, err := runner.LookPath("osascript")
return err
},
run: func(req promptRequest) error {
script := fmt.Sprintf(`display alert "%s" message "%s" as critical buttons {"OK"} default button "OK"`,
escapeAppleScript(req.title), escapeAppleScript(req.message))
return runner.Run(promptCommandTimeout, nil, "osascript", "-e", script)
},
},
}
}

View File

@@ -0,0 +1,46 @@
//go:build darwin
package main
import (
"strings"
"testing"
)
func TestDarwinStartupChannelsBuildNotificationAndAlert(t *testing.T) {
runner := &fakeCommandRunner{paths: map[string]bool{"osascript": true}}
channels := platformStartupChannels(runner)
if len(channels) != 2 {
t.Fatalf("macOS 应有 notification 和 alert 两级通道,实际: %d", len(channels))
}
req := promptRequest{title: "Nex 启动失败", subtitle: "config", message: "路径 C:\\tmp 包含 \"quote\""}
for _, channel := range channels {
if err := channel.available(); err != nil {
t.Fatalf("通道 %s 应可用: %v", channel.name, err)
}
if err := channel.run(req); err != nil {
t.Fatalf("通道 %s 执行失败: %v", channel.name, err)
}
}
if len(runner.calls) != 2 {
t.Fatalf("应执行两次 osascript实际: %d", len(runner.calls))
}
if runner.calls[0].name != "osascript" || runner.calls[0].args[0] != "-e" {
t.Fatalf("notification 命令参数错误: %#v", runner.calls[0])
}
if script := runner.calls[0].args[1]; !strings.Contains(script, "display notification") || !strings.Contains(script, `\\tmp`) || !strings.Contains(script, `\"quote\"`) {
t.Fatalf("notification AppleScript 未正确构造或转义: %s", script)
}
if script := runner.calls[1].args[1]; !strings.Contains(script, "display alert") || !strings.Contains(script, "as critical") {
t.Fatalf("alert AppleScript 未使用 critical 告警: %s", script)
}
}
func TestEscapeAppleScript(t *testing.T) {
got := escapeAppleScript(`C:\tmp "quote"`)
if !strings.Contains(got, `C:\\tmp`) || !strings.Contains(got, `\"quote\"`) {
t.Fatalf("AppleScript 转义结果错误: %s", got)
}
}

View File

@@ -3,8 +3,9 @@
package main
import (
"errors"
"fmt"
"os/exec"
"os"
"sync"
)
@@ -12,56 +13,99 @@ type dialogToolType int
const (
toolNone dialogToolType = iota
toolZenity
toolKdialog
toolNotifySend
toolKdialogPassive
toolZenity
toolKdialogError
toolXmessage
)
var (
dialogTool dialogToolType
dialogToolOnce sync.Once
dialogTools map[string]bool
dialogToolOnce sync.Once
dialogToolNames = []string{"notify-send", "kdialog", "zenity", "xmessage"}
)
func init() {
dialogToolOnce.Do(detectDialogTool)
dialogToolOnce.Do(func() { detectDialogTools(defaultCommandRunner{}) })
}
func detectDialogTool() {
tools := []struct {
name string
typ dialogToolType
}{
{"zenity", toolZenity},
{"kdialog", toolKdialog},
{"notify-send", toolNotifySend},
{"xmessage", toolXmessage},
func platformStartupChannels(runner commandRunner) []promptChannel {
return []promptChannel{
linuxCommandChannel("notify-send", toolNotifySend, runner, linuxHasGraphicalSessionAndDBus, func(req promptRequest) []string {
return []string{"-u", "critical", "-a", appName, "-i", "nex", req.title, req.message}
}),
linuxCommandChannel("kdialog", toolKdialogPassive, runner, linuxHasGraphicalSession, func(req promptRequest) []string {
return []string{"--title", req.title, "--passivepopup", req.message, "10"}
}),
linuxCommandChannel("zenity", toolZenity, runner, linuxHasGraphicalSession, func(req promptRequest) []string {
return []string{"--error", fmt.Sprintf("--title=%s", req.title), fmt.Sprintf("--text=%s", req.message)}
}),
linuxCommandChannel("kdialog", toolKdialogError, runner, linuxHasGraphicalSession, func(req promptRequest) []string {
return []string{"--title", req.title, "--error", req.message}
}),
linuxCommandChannel("xmessage", toolXmessage, runner, linuxHasX11Display, func(req promptRequest) []string {
return []string{"-center", "-buttons", "OK:0", "-default", "OK", fmt.Sprintf("%s: %s", req.title, req.message)}
}),
}
}
for _, tool := range tools {
if _, err := exec.LookPath(tool.name); err == nil {
dialogTool = tool.typ
return
func detectDialogTools(runner commandRunner) {
dialogTools = make(map[string]bool, len(dialogToolNames))
for _, name := range dialogToolNames {
_, err := runner.LookPath(name)
dialogTools[name] = err == nil
}
}
func linuxCommandChannel(name string, typ dialogToolType, runner commandRunner, environmentOK func() error, args func(promptRequest) []string) promptChannel {
return promptChannel{
name: fmt.Sprintf("linux-%s-%d", name, typ),
available: func() error {
if err := linuxCommandAvailable(runner, name); err != nil {
return err
}
return environmentOK()
},
run: func(req promptRequest) error {
return runner.Run(promptCommandTimeout, nil, name, args(req)...)
},
}
}
func linuxCommandAvailable(runner commandRunner, name string) error {
if _, ok := runner.(defaultCommandRunner); ok {
dialogToolOnce.Do(func() { detectDialogTools(runner) })
if dialogTools[name] {
return nil
}
return fmt.Errorf("%s 不可用", name)
}
dialogTool = toolNone
_, err := runner.LookPath(name)
return err
}
func showError(title, message string) {
switch dialogTool {
case toolZenity:
exec.Command("zenity", "--error",
fmt.Sprintf("--title=%s", title),
fmt.Sprintf("--text=%s", message)).Run()
case toolKdialog:
exec.Command("kdialog", "--error", message, "--title", title).Run()
case toolNotifySend:
exec.Command("notify-send", "-u", "critical", title, message).Run()
case toolXmessage:
exec.Command("xmessage", "-center",
fmt.Sprintf("%s: %s", title, message)).Run()
default:
dialogLogger().Error("无法显示错误对话框")
func linuxHasGraphicalSession() error {
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return errors.New("缺少图形会话")
}
return nil
}
func linuxHasGraphicalSessionAndDBus() error {
if err := linuxHasGraphicalSession(); err != nil {
return err
}
if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
return errors.New("缺少 DBus session bus")
}
return nil
}
func linuxHasX11Display() error {
if os.Getenv("DISPLAY") == "" {
return errors.New("缺少 X11 DISPLAY")
}
return nil
}

View File

@@ -0,0 +1,61 @@
//go:build linux
package main
import "testing"
func TestLinuxStartupChannelsPriorityAndArguments(t *testing.T) {
t.Setenv("DISPLAY", ":0")
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/dbus")
runner := &fakeCommandRunner{paths: map[string]bool{
"notify-send": true,
"kdialog": true,
"zenity": true,
"xmessage": true,
}}
channels := platformStartupChannels(runner)
if len(channels) != 5 {
t.Fatalf("Linux 应有 5 个 UI 通道,实际: %d", len(channels))
}
req := promptRequest{title: "Nex 启动失败", message: "端口被占用"}
for _, channel := range channels {
if err := channel.available(); err != nil {
t.Fatalf("通道 %s 应可用: %v", channel.name, err)
}
if err := channel.run(req); err != nil {
t.Fatalf("通道 %s 执行失败: %v", channel.name, err)
}
}
wantNames := []string{"notify-send", "kdialog", "zenity", "kdialog", "xmessage"}
for i, want := range wantNames {
if got := runner.calls[i].name; got != want {
t.Fatalf("第 %d 个命令 = %s, want %s", i, got, want)
}
}
if got := runner.calls[0].args; len(got) < 2 || got[0] != "-u" || got[1] != "critical" {
t.Fatalf("notify-send 应使用 critical 参数,实际: %#v", got)
}
if got := runner.calls[1].args; len(got) < 3 || got[2] != "--passivepopup" {
t.Fatalf("kdialog 第一跳应使用 passivepopup实际: %#v", got)
}
if got := runner.calls[2].args; len(got) < 1 || got[0] != "--error" {
t.Fatalf("zenity 应使用 --error实际: %#v", got)
}
if got := runner.calls[4].args; len(got) < 1 || got[0] != "-center" {
t.Fatalf("xmessage 应居中显示,实际: %#v", got)
}
}
func TestLinuxNotifySendRequiresDBus(t *testing.T) {
t.Setenv("DISPLAY", ":0")
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "")
runner := &fakeCommandRunner{paths: map[string]bool{"notify-send": true}}
channels := platformStartupChannels(runner)
if err := channels[0].available(); err == nil {
t.Fatal("notify-send 缺少 DBus session bus 时应不可用")
}
}

View File

@@ -3,17 +3,21 @@
package main
import (
"encoding/base64"
"errors"
"fmt"
"syscall"
"unicode/utf16"
"unsafe"
"go.uber.org/zap"
)
const (
mbOK = 0x00000000
mbIconError = 0x10
mbIconInformation = 0x40
mbTaskModal = 0x00002000
mbSetForeground = 0x00010000
mbTopMost = 0x00040000
)
var (
@@ -25,12 +29,79 @@ var (
}
)
func showError(title, message string) {
if err := messageBox(title, message, mbIconError); err != nil {
if zapLogger != nil {
zapLogger.Warn("显示错误对话框失败", zap.Error(err))
func platformStartupChannels(runner commandRunner) []promptChannel {
return []promptChannel{
{
name: "windows-toast",
available: func() error {
_, err := findPowerShell(runner)
return err
},
run: func(req promptRequest) error {
name, err := findPowerShell(runner)
if err != nil {
return err
}
return runner.Run(promptCommandTimeout, []string{
"NEX_TOAST_TITLE=" + req.title,
"NEX_TOAST_BODY=" + req.message,
}, name, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodePowerShellCommand(windowsToastScript()))
},
},
{
name: "windows-messagebox",
available: func() error {
return messageBoxAvailable()
},
run: func(req promptRequest) error {
return messageBox(req.title, req.message, messageBoxStartupFlags())
},
},
}
}
func findPowerShell(runner commandRunner) (string, error) {
for _, name := range []string{"powershell.exe", "powershell"} {
if _, err := runner.LookPath(name); err == nil {
return name, nil
}
}
return "", fmt.Errorf("PowerShell 不可用")
}
func windowsToastScript() string {
return `$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template)
$texts = $xml.GetElementsByTagName('text')
$texts.Item(0).AppendChild($xml.CreateTextNode($env:NEX_TOAST_TITLE)) | Out-Null
$texts.Item(1).AppendChild($xml.CreateTextNode($env:NEX_TOAST_BODY)) | Out-Null
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Nex').Show($toast)`
}
func encodePowerShellCommand(script string) string {
encoded := utf16.Encode([]rune(script))
buf := make([]byte, 0, len(encoded)*2)
for _, value := range encoded {
buf = append(buf, byte(value), byte(value>>8))
}
return base64.StdEncoding.EncodeToString(buf)
}
func messageBoxAvailable() error {
if _, err := syscall.UTF16PtrFromString("Nex"); err != nil {
return err
}
if _, err := syscall.UTF16PtrFromString("test"); err != nil {
return err
}
return procMessageBoxW.Find()
}
func messageBoxStartupFlags() uint {
return mbOK | mbIconError | mbTaskModal | mbSetForeground | mbTopMost
}
func messageBox(title, message string, flags uint) error {

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"io/fs"
"net"
@@ -27,10 +28,10 @@ import (
"nex/backend/internal/service"
"nex/backend/pkg/buildinfo"
"github.com/getlantern/systray"
"github.com/gin-gonic/gin"
"github.com/gofrs/flock"
"go.uber.org/zap"
"gorm.io/gorm"
pkgLogger "nex/backend/pkg/logger"
)
@@ -40,31 +41,65 @@ var (
zapLogger *zap.Logger
shutdownCtx context.Context
shutdownCancel context.CancelFunc
desktopHooks = defaultDesktopRuntimeHooks()
)
type singletonLocker interface {
Lock() error
Unlock() error
}
type desktopRuntimeHooks struct {
loadConfig func() (*config.Config, config.ConfigMetadata, error)
newLock func(string) singletonLocker
listen func(int) (net.Listener, error)
upgradeLogger func(*zap.Logger, pkgLogger.Config) (*zap.Logger, error)
initDB func(*config.DatabaseConfig, *zap.Logger) (*gorm.DB, error)
closeDB func(*gorm.DB)
registerAdapters func(conversion.AdapterRegistry) error
setupStaticFiles func(*gin.Engine) error
startServer func(*http.Server, net.Listener, chan<- error, *zap.Logger)
setupSystray func(int, <-chan error) error
}
func defaultDesktopRuntimeHooks() desktopRuntimeHooks {
return desktopRuntimeHooks{
loadConfig: config.LoadDesktopConfigWithMetadata,
newLock: func(lockPath string) singletonLocker { return NewSingletonLock(lockPath) },
listen: listenDesktopPort,
upgradeLogger: pkgLogger.Upgrade,
initDB: database.Init,
closeDB: database.Close,
registerAdapters: registerDesktopAdapters,
setupStaticFiles: setupStaticFiles,
startServer: startDesktopServer,
setupSystray: setupSystray,
}
}
func main() {
minimalLogger := pkgLogger.NewMinimal()
cfg, err := config.LoadDesktopConfig()
if err != nil {
minimalLogger.Error("加载配置失败", zap.Error(err))
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
if err := runDesktop(minimalLogger); err != nil {
reportStartupFailure(err, dialogLogger())
os.Exit(1)
}
}
func runDesktop(minimalLogger *zap.Logger) error {
if minimalLogger == nil {
minimalLogger = pkgLogger.NewMinimal()
}
cfg, cfgMeta, err := desktopHooks.loadConfig()
if err != nil {
return newStartupError(phaseConfig, desktopConfigErrorMessage(getDesktopConfigPath(), err), err)
}
port := cfg.Server.Port
if err := checkPortAvailable(port); err != nil {
minimalLogger.Error("端口不可用", zap.Error(err))
showError(appName, err.Error())
os.Exit(1)
}
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
singleLock := desktopHooks.newLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
if err := singleLock.Lock(); err != nil {
minimalLogger.Error("已有 Nex 实例运行")
showError(appName, "已有 Nex 实例运行")
os.Exit(1)
return newStartupError(phaseSingleton, "已有 Nex 实例运行", err)
}
defer func() {
if err := singleLock.Unlock(); err != nil {
@@ -72,7 +107,13 @@ func main() {
}
}()
zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
listener, err := desktopHooks.listen(port)
if err != nil {
return newStartupError(phasePort, desktopPortUnavailableMessage(port), err)
}
defer listener.Close()
zapLogger, err = desktopHooks.upgradeLogger(minimalLogger, pkgLogger.Config{
Level: cfg.Log.Level,
Path: cfg.Log.Path,
MaxSize: cfg.Log.MaxSize,
@@ -81,7 +122,7 @@ func main() {
Compress: cfg.Log.Compress,
})
if err != nil {
minimalLogger.Fatal("初始化日志失败", zap.Error(err))
return newStartupError(phaseLogger, fmt.Sprintf("初始化日志失败\n\n日志目录: %s\n\n请检查目录权限或磁盘空间", cfg.Log.Path), err)
}
defer func() {
if err := zapLogger.Sync(); err != nil {
@@ -91,11 +132,17 @@ func main() {
cfg.PrintSummary(zapLogger)
db, err := database.Init(&cfg.Database, zapLogger)
db, err := desktopHooks.initDB(&cfg.Database, zapLogger)
if err != nil {
zapLogger.Fatal("初始化数据库失败", zap.Error(err))
phase := phaseDatabase
message := fmt.Sprintf("数据库初始化失败\n\n请检查数据库配置、文件权限或连接状态\n\n%v", err)
if errors.Is(err, database.ErrMigration) {
phase = phaseMigration
message = fmt.Sprintf("数据库迁移失败\n\n请查看日志或检查数据库迁移权限\n\n%v", err)
}
return newStartupError(phase, message, err)
}
defer database.Close(db)
defer desktopHooks.closeDB(db)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
@@ -118,11 +165,8 @@ func main() {
statsService := service.NewStatsService(statsRepo, statsBuffer)
registry := conversion.NewMemoryRegistry()
if err := registry.Register(openai.NewAdapter()); err != nil {
zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err))
}
if err := registry.Register(anthropic.NewAdapter()); err != nil {
zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err))
if err := desktopHooks.registerAdapters(registry); err != nil {
return newStartupError(phaseAdapter, startupInternalErrorMessage(), err)
}
engine := conversion.NewConversionEngine(registry, zapLogger)
@@ -133,6 +177,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,8 +187,10 @@ func main() {
r.Use(middleware.Logging(zapLogger))
r.Use(middleware.CORS())
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
setupStaticFiles(r)
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
if err := desktopHooks.setupStaticFiles(r); err != nil {
return newStartupError(phaseStaticResource, startupInternalErrorMessage(), err)
}
server = &http.Server{
Addr: desktopListenAddr(port),
@@ -153,29 +200,49 @@ func main() {
}
shutdownCtx, shutdownCancel = context.WithCancel(context.Background())
defer doShutdown()
serverErrCh := make(chan error, 1)
desktopHooks.startServer(server, listener, serverErrCh, zapLogger)
select {
case err := <-serverErrCh:
return newStartupError(phaseServer, startupServerErrorMessage(), err)
case <-time.After(50 * time.Millisecond):
}
if err := desktopHooks.setupSystray(port, serverErrCh); err != nil {
return err
}
select {
case err := <-serverErrCh:
return newStartupError(phaseServer, startupServerErrorMessage(), err)
default:
return nil
}
}
func registerDesktopAdapters(registry conversion.AdapterRegistry) error {
if err := registry.Register(openai.NewAdapter()); err != nil {
return err
}
return registry.Register(anthropic.NewAdapter())
}
func startDesktopServer(server *http.Server, listener net.Listener, serverErrCh chan<- error, logger *zap.Logger) {
go func() {
zapLogger.Info("AI Gateway 启动",
logger.Info("AI Gateway 启动",
zap.String("addr", server.Addr),
zap.String("version", buildinfo.Version()),
zap.String("commit", buildinfo.Commit()),
zap.String("build_time", buildinfo.BuildTime()))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
zapLogger.Fatal("服务器启动失败", zap.Error(err))
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
serverErrCh <- err
}
}()
go func() {
time.Sleep(500 * time.Millisecond)
if err := openBrowser(desktopURL(port)); err != nil {
zapLogger.Warn("无法打开浏览器", zap.Error(err))
}
}()
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 +271,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"})
})
@@ -216,12 +289,13 @@ func withProtocol(protocol string, next gin.HandlerFunc) gin.HandlerFunc {
}
}
func setupStaticFiles(r *gin.Engine) {
func setupStaticFiles(r *gin.Engine) error {
distFS, err := frontendDistFS()
if err != nil {
zapLogger.Fatal("无法加载前端资源", zap.Error(err))
return err
}
setupStaticFilesWithFS(r, distFS)
return nil
}
func frontendDistFS() (fs.FS, error) {
@@ -292,47 +366,6 @@ func setupStaticFilesWithFS(r *gin.Engine, distFS fs.FS) {
})
}
func setupSystray(port int) {
systray.Run(func() {
var icon []byte
var err error
if runtime.GOOS == "windows" {
icon, err = embedfs.Assets.ReadFile("assets/icon.ico")
} else {
icon, err = embedfs.Assets.ReadFile("assets/icon.png")
}
if err != nil {
zapLogger.Error("无法加载托盘图标", zap.Error(err))
}
systray.SetIcon(icon)
systray.SetTooltip(appTooltip)
mOpen := systray.AddMenuItem("打开管理界面", "在浏览器中打开")
systray.AddSeparator()
mStatus := systray.AddMenuItem("状态: 运行中", "")
mStatus.Disable()
mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "")
mPort.Disable()
systray.AddSeparator()
mQuit := systray.AddMenuItem("退出", "停止服务并退出")
go func() {
for {
select {
case <-mOpen.ClickedCh:
if err := openBrowser(desktopURL(port)); err != nil {
zapLogger.Warn("打开浏览器失败", zap.Error(err))
}
case <-mQuit.ClickedCh:
doShutdown()
systray.Quit()
return
}
}
}()
}, nil)
}
func doShutdown() {
if zapLogger != nil {
zapLogger.Info("正在关闭服务器...")
@@ -375,13 +408,12 @@ func desktopPortMenuTitle(port int) string {
return fmt.Sprintf("端口: %d", port)
}
func checkPortAvailable(port int) error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return fmt.Errorf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
}
ln.Close()
return nil
func listenDesktopPort(port int) (net.Listener, error) {
return net.Listen("tcp", desktopListenAddr(port))
}
func desktopPortUnavailableMessage(port int) string {
return fmt.Sprintf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
}
type SingletonLock struct {

View File

@@ -47,9 +47,15 @@ func TestMessageBoxW_WindowsOnly_FailureUsesReturnValue(t *testing.T) {
}
func TestShowError_WindowsBranch(t *testing.T) {
withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) {
return 0, syscall.Errno(5)
})
old := buildPromptChannels
buildPromptChannels = func(commandRunner) []promptChannel {
return []promptChannel{{
name: "fake-failed-channel",
available: func() error { return nil },
run: func(promptRequest) error { return syscall.Errno(5) },
}}
}
t.Cleanup(func() { buildPromptChannels = old })
defer func() {
if recovered := recover(); recovered != nil {
@@ -59,3 +65,42 @@ func TestShowError_WindowsBranch(t *testing.T) {
showError("测试错误", "这是一条测试错误消息")
}
func TestMessageBoxW_WindowsOnly_StartupFlags(t *testing.T) {
var gotFlags uintptr
withMessageBoxW(t, func(_, _, _, flags uintptr) (uintptr, error) {
gotFlags = flags
return 1, syscall.Errno(0)
})
if err := messageBox("测试标题", "测试消息", messageBoxStartupFlags()); err != nil {
t.Fatalf("MessageBoxW 应成功: %v", err)
}
for _, flag := range []uint{mbIconError, mbTaskModal, mbSetForeground, mbTopMost} {
if gotFlags&uintptr(flag) == 0 {
t.Fatalf("startup flags 缺少 0x%x实际: 0x%x", flag, gotFlags)
}
}
}
func TestWindowsStartupChannelsUseToastBeforeMessageBox(t *testing.T) {
runner := &fakeCommandRunner{paths: map[string]bool{"powershell.exe": true}}
channels := platformStartupChannels(runner)
if len(channels) != 2 {
t.Fatalf("Windows 应有 Toast 和 MessageBox 两级通道,实际: %d", len(channels))
}
if channels[0].name != "windows-toast" || channels[1].name != "windows-messagebox" {
t.Fatalf("Windows 通道顺序错误: %s, %s", channels[0].name, channels[1].name)
}
if err := channels[0].available(); err != nil {
t.Fatalf("PowerShell 存在时 Toast 通道应可用: %v", err)
}
if err := channels[0].run(promptRequest{title: "Nex 启动失败", message: "端口被占用"}); err != nil {
t.Fatalf("Toast fake runner 应执行成功: %v", err)
}
if len(runner.calls) != 1 || runner.calls[0].name != "powershell.exe" {
t.Fatalf("Toast 应调用 powershell.exe实际: %#v", runner.calls)
}
}

View File

@@ -9,87 +9,27 @@ import (
"time"
)
func TestCheckPortAvailable(t *testing.T) {
port := 19826
err := checkPortAvailable(port)
func TestListenDesktopPortReturnsReusableListener(t *testing.T) {
listener, err := listenDesktopPort(0)
if err != nil {
t.Fatalf("端口 %d 应该可用: %v", port, err)
}
t.Log("端口可用测试通过")
}
func TestCheckPortOccupied(t *testing.T) {
port := 19827
listener, err := net.Listen("tcp", ":19827") //nolint:gosec // 需要验证 checkPortAvailable 对通配地址占用的检测行为
if err != nil {
t.Fatalf("无法启动测试服务器: %v", err)
t.Fatalf("listener-first 应直接获取配置端口 listener: %v", err)
}
defer listener.Close()
time.Sleep(100 * time.Millisecond)
err = checkPortAvailable(port)
if err == nil {
t.Fatal("端口被占用时应该返回错误")
}
t.Log("端口占用检测测试通过")
}
func TestCheckPortAvailableAfterClose(t *testing.T) {
port := 19828
listener, err := net.Listen("tcp", "127.0.0.1:19828")
if err != nil {
t.Fatalf("无法启动测试服务器: %v", err)
}
server := &http.Server{ReadHeaderTimeout: time.Second}
defer server.Close()
done := make(chan struct{})
go func() {
defer close(done)
err := server.Serve(listener)
if err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) {
t.Errorf("serve failed: %v", err)
t.Errorf("使用同一个 listener 启动 server 失败: %v", err)
}
}()
time.Sleep(100 * time.Millisecond)
listener.Close()
time.Sleep(100 * time.Millisecond)
err = checkPortAvailable(port)
if err != nil {
t.Fatalf("端口关闭后应该可用: %v", err)
if err := server.Close(); err != nil {
t.Fatalf("关闭测试 server 失败: %v", err)
}
t.Log("端口关闭后可用测试通过")
}
func TestCheckPortAvailableErrorContainsPort(t *testing.T) {
port := 19829
listener, err := net.Listen("tcp", ":19829") //nolint:gosec
if err != nil {
t.Fatalf("无法启动测试服务器: %v", err)
}
defer listener.Close()
time.Sleep(100 * time.Millisecond)
err = checkPortAvailable(port)
if err == nil {
t.Fatal("端口被占用时应该返回错误")
}
if !strings.Contains(err.Error(), "19829") {
t.Fatalf("错误信息应包含端口号 19829实际: %v", err)
}
t.Log("端口错误信息包含端口号测试通过")
<-done
}
func TestGetDesktopConfigPath(t *testing.T) {

View File

@@ -0,0 +1,121 @@
package main
import (
"context"
"errors"
"io"
"os"
"os/exec"
"time"
"go.uber.org/zap"
)
const promptCommandTimeout = 5 * time.Second
type promptRequest struct {
title string
message string
subtitle string
}
type promptChannel struct {
name string
available func() error
run func(promptRequest) error
}
type commandRunner interface {
LookPath(file string) (string, error)
Run(timeout time.Duration, env []string, name string, args ...string) error
}
type defaultCommandRunner struct{}
var buildPromptChannels = platformStartupChannels
func (defaultCommandRunner) LookPath(file string) (string, error) {
return exec.LookPath(file)
}
func (defaultCommandRunner) Run(timeout time.Duration, env []string, name string, args ...string) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
if len(env) > 0 {
cmd.Env = append(os.Environ(), env...)
}
if err := cmd.Run(); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
return nil
}
func showError(title, message string) {
reportPrompt(promptRequest{title: title, message: message}, os.Stderr, dialogLogger())
}
func reportStartupFailure(err error, logger *zap.Logger) {
if err == nil {
return
}
var startupErr *startupError
if !errors.As(err, &startupErr) {
startupErr = newStartupError(phaseServer, startupServerErrorMessage(), err)
}
if logger == nil {
logger = dialogLogger()
}
logger.Error("desktop 启动失败",
zap.String("phase", startupErr.Phase()),
zap.Error(startupErr))
reportPrompt(promptRequest{
title: startupTitle(),
message: startupErr.UserMessage(),
subtitle: startupErr.Phase(),
}, os.Stderr, logger)
}
func reportPrompt(req promptRequest, fallback io.Writer, logger *zap.Logger) {
runPromptPipeline(req, buildPromptChannels(defaultCommandRunner{}), fallback, logger)
}
func runPromptPipeline(req promptRequest, channels []promptChannel, fallback io.Writer, logger *zap.Logger) {
if logger == nil {
logger = dialogLogger()
}
for _, channel := range channels {
if channel.available != nil {
if err := channel.available(); err != nil {
logger.Warn("提示通道不可用", zap.String("channel", channel.name), zap.Error(err))
continue
}
}
if err := channel.run(req); err != nil {
logger.Warn("提示通道执行失败", zap.String("channel", channel.name), zap.Error(err))
continue
}
return
}
writePromptFallback(fallback, req.title, req.message)
}
func writePromptFallback(w io.Writer, title, message string) {
if w == nil {
return
}
if _, err := io.WriteString(w, "错误: "+title+": "+message+"\n"); err != nil {
return
}
}

View File

@@ -0,0 +1,140 @@
package main
import (
"bytes"
"errors"
"fmt"
"os/exec"
"strings"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type commandCall struct {
timeout time.Duration
env []string
name string
args []string
}
type fakeCommandRunner struct {
paths map[string]bool
runErrs map[string]error
calls []commandCall
}
func (r *fakeCommandRunner) LookPath(file string) (string, error) {
if r.paths[file] {
return "/usr/bin/" + file, nil
}
return "", exec.ErrNotFound
}
func (r *fakeCommandRunner) Run(timeout time.Duration, env []string, name string, args ...string) error {
r.calls = append(r.calls, commandCall{
timeout: timeout,
env: append([]string(nil), env...),
name: name,
args: append([]string(nil), args...),
})
if err := r.runErrs[name]; err != nil {
return err
}
return nil
}
func TestRunPromptPipelineFallbackOrder(t *testing.T) {
var calls []string
channels := []promptChannel{
{
name: "unavailable",
available: func() error {
calls = append(calls, "available-1")
return errors.New("missing")
},
run: func(promptRequest) error {
calls = append(calls, "run-1")
return nil
},
},
{
name: "failed",
available: func() error {
calls = append(calls, "available-2")
return nil
},
run: func(promptRequest) error {
calls = append(calls, "run-2")
return errors.New("failed")
},
},
{
name: "success",
available: func() error {
calls = append(calls, "available-3")
return nil
},
run: func(promptRequest) error {
calls = append(calls, "run-3")
return nil
},
},
}
var fallback bytes.Buffer
runPromptPipeline(promptRequest{title: "Nex 启动失败", message: "启动失败"}, channels, &fallback, zap.NewNop())
want := []string{"available-1", "available-2", "run-2", "available-3", "run-3"}
if fmt.Sprint(calls) != fmt.Sprint(want) {
t.Fatalf("调用顺序 = %v, want %v", calls, want)
}
if fallback.Len() != 0 {
t.Fatalf("成功通道后不应写入 fallback实际: %s", fallback.String())
}
}
func TestRunPromptPipelineWritesFallback(t *testing.T) {
channels := []promptChannel{
{
name: "unavailable",
available: func() error { return errors.New("missing") },
run: func(promptRequest) error { return nil },
},
}
var fallback bytes.Buffer
runPromptPipeline(promptRequest{title: "Nex 启动失败", message: "端口被占用"}, channels, &fallback, zap.NewNop())
want := "错误: Nex 启动失败: 端口被占用\n"
if fallback.String() != want {
t.Fatalf("fallback = %q, want %q", fallback.String(), want)
}
}
func TestReportStartupFailureLogsRedactedError(t *testing.T) {
old := buildPromptChannels
buildPromptChannels = func(commandRunner) []promptChannel {
return []promptChannel{{name: "fake-success", run: func(promptRequest) error { return nil }}}
}
t.Cleanup(func() { buildPromptChannels = old })
core, logs := observer.New(zap.ErrorLevel)
logger := zap.New(core)
err := errors.New("数据库连接失败: nex:secret@tcp(localhost:3306)/nex password=secret api_key=sk-test")
reportStartupFailure(err, logger)
entries := logs.All()
if len(entries) != 1 {
t.Fatalf("应记录 1 条错误日志,实际: %d", len(entries))
}
fields := fmt.Sprint(entries[0].ContextMap())
for _, secret := range []string{"secret", "sk-test"} {
if strings.Contains(fields, secret) {
t.Fatalf("启动失败日志不应包含敏感信息 %q实际: %s", secret, fields)
}
}
}

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

@@ -0,0 +1,332 @@
package main
import (
"errors"
"fmt"
"net"
"net/http"
"path/filepath"
"sync/atomic"
"testing"
"time"
"nex/backend/internal/config"
"nex/backend/internal/conversion"
"nex/backend/internal/database"
pkgLogger "nex/backend/pkg/logger"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
type fakeDesktopLock struct {
lockErr error
unlockCount atomic.Int32
}
func (l *fakeDesktopLock) Lock() error {
return l.lockErr
}
func (l *fakeDesktopLock) Unlock() error {
l.unlockCount.Add(1)
return nil
}
func (l *fakeDesktopLock) unlocked() bool {
return l.unlockCount.Load() > 0
}
type recordingListener struct {
net.Listener
closeCount atomic.Int32
}
func newRecordingListener(t *testing.T) *recordingListener {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("创建测试 listener 失败: %v", err)
}
return &recordingListener{Listener: listener}
}
func (l *recordingListener) Close() error {
l.closeCount.Add(1)
return l.Listener.Close()
}
func (l *recordingListener) closed() bool {
return l.closeCount.Load() > 0
}
func testDesktopConfig(t *testing.T) *config.Config {
t.Helper()
tmpDir := t.TempDir()
cfg := config.DefaultConfig()
cfg.Server.Port = 0
cfg.Database.Driver = "sqlite"
cfg.Database.Path = filepath.Join(tmpDir, "config.db")
cfg.Log.Path = filepath.Join(tmpDir, "log")
return cfg
}
func installDesktopTestHooks(t *testing.T, cfg *config.Config, mutate func(*desktopRuntimeHooks)) {
t.Helper()
oldHooks := desktopHooks
oldServer := server
oldLogger := zapLogger
oldShutdownCtx := shutdownCtx
oldShutdownCancel := shutdownCancel
server = nil
zapLogger = nil
shutdownCtx = nil
shutdownCancel = nil
hooks := defaultDesktopRuntimeHooks()
if cfg != nil {
hooks.loadConfig = func() (*config.Config, config.ConfigMetadata, error) {
return cfg, config.ConfigMetadata{ConfigPath: filepath.Join(t.TempDir(), "config.yaml")}, nil
}
}
hooks.upgradeLogger = func(_ *zap.Logger, _ pkgLogger.Config) (*zap.Logger, error) {
return zap.NewNop(), nil
}
hooks.setupStaticFiles = func(*gin.Engine) error { return nil }
hooks.startServer = func(*http.Server, net.Listener, chan<- error, *zap.Logger) {}
hooks.setupSystray = func(int, <-chan error) error { return nil }
if mutate != nil {
mutate(&hooks)
}
desktopHooks = hooks
t.Cleanup(func() {
if server != nil {
_ = server.Close()
}
desktopHooks = oldHooks
server = oldServer
zapLogger = oldLogger
shutdownCtx = oldShutdownCtx
shutdownCancel = oldShutdownCancel
})
}
func requireStartupPhase(t *testing.T, err error, want startupPhase) {
t.Helper()
if err == nil {
t.Fatalf("期望 %s 阶段启动错误,实际 nil", want)
}
var startupErr *startupError
if !errors.As(err, &startupErr) {
t.Fatalf("期望 startupError实际: %T %v", err, err)
}
if startupErr.phase != want {
t.Fatalf("phase = %s, want %s", startupErr.phase, want)
}
}
func TestRunDesktopConfigFailureReturnsConfigPhase(t *testing.T) {
installDesktopTestHooks(t, nil, func(h *desktopRuntimeHooks) {
h.loadConfig = func() (*config.Config, config.ConfigMetadata, error) {
return nil, config.ConfigMetadata{}, errors.New("yaml 解析失败")
}
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, phaseConfig)
}
func TestRunDesktopSingletonFailurePrecedesPortListen(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{lockErr: errors.New("已有实例运行")}
listenCalled := false
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) {
listenCalled = true
return nil, errors.New("不应监听端口")
}
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, phaseSingleton)
if listenCalled {
t.Fatal("单实例锁失败时不应继续监听端口")
}
}
func TestRunDesktopPortFailureUnlocksSingleton(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{}
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) { return nil, errors.New("bind failed") }
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, phasePort)
if !lock.unlocked() {
t.Fatal("端口监听失败时应释放单实例锁")
}
}
func TestRunDesktopLoggerFailureClosesListenerAndUnlocks(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{}
listener := newRecordingListener(t)
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) { return listener, nil }
h.upgradeLogger = func(*zap.Logger, pkgLogger.Config) (*zap.Logger, error) {
return nil, errors.New("log permission denied")
}
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, phaseLogger)
if !listener.closed() {
t.Fatal("日志初始化失败时应关闭 listener")
}
if !lock.unlocked() {
t.Fatal("日志初始化失败时应释放单实例锁")
}
}
func TestRunDesktopDatabaseFailureClassification(t *testing.T) {
tests := []struct {
name string
err error
want startupPhase
}{
{name: "database", err: errors.New("open failed"), want: phaseDatabase},
{name: "migration", err: fmt.Errorf("%w: %w", database.ErrMigration, errors.New("goose failed")), want: phaseMigration},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{}
listener := newRecordingListener(t)
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) { return listener, nil }
h.initDB = func(*config.DatabaseConfig, *zap.Logger) (*gorm.DB, error) { return nil, tt.err }
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, tt.want)
if !listener.closed() {
t.Fatal("数据库失败时应关闭 listener")
}
if !lock.unlocked() {
t.Fatal("数据库失败时应释放单实例锁")
}
})
}
}
func TestRunDesktopInternalStartupFailurePhasesAndDatabaseCleanup(t *testing.T) {
tests := []struct {
name string
mutate func(*desktopRuntimeHooks)
want startupPhase
}{
{
name: "adapter",
mutate: func(h *desktopRuntimeHooks) {
h.registerAdapters = func(conversion.AdapterRegistry) error { return errors.New("adapter failed") }
},
want: phaseAdapter,
},
{
name: "static",
mutate: func(h *desktopRuntimeHooks) {
h.setupStaticFiles = func(*gin.Engine) error { return errors.New("missing frontend") }
},
want: phaseStaticResource,
},
{
name: "server",
mutate: func(h *desktopRuntimeHooks) {
h.startServer = func(_ *http.Server, _ net.Listener, errCh chan<- error, _ *zap.Logger) {
errCh <- errors.New("serve failed")
}
},
want: phaseServer,
},
{
name: "tray",
mutate: func(h *desktopRuntimeHooks) {
h.setupSystray = func(int, <-chan error) error {
return newStartupError(phaseTray, "托盘初始化失败", errors.New("tray failed"))
}
},
want: phaseTray,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{}
listener := newRecordingListener(t)
closeDBCalled := false
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) { return listener, nil }
h.closeDB = func(db *gorm.DB) {
closeDBCalled = true
database.Close(db)
}
tt.mutate(h)
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, tt.want)
if !closeDBCalled {
t.Fatal("数据库初始化后的启动失败应关闭数据库")
}
if !listener.closed() {
t.Fatal("数据库初始化后的启动失败应关闭 listener")
}
if !lock.unlocked() {
t.Fatal("数据库初始化后的启动失败应释放单实例锁")
}
})
}
}
func TestRunDesktopBrowserFailureRemainsNonFatal(t *testing.T) {
controller := newFakeTrayController()
notified := make(chan string, 1)
controller.run = func(onReady func(), _ func()) {
onReady()
<-controller.quitCh
}
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: time.Second,
iconLoader: func() ([]byte, error) { return []byte("icon"), nil },
openBrowser: func(string) error { return errors.New("no browser") },
notify: func(_, message string) {
notified <- message
controller.Quit()
},
logger: zap.NewNop(),
})
if err != nil {
t.Fatalf("浏览器打开失败不应导致 runSystray 返回 fatal: %v", err)
}
if got := <-notified; got == "" {
t.Fatal("浏览器打开失败应提示用户手动访问")
}
}

View File

@@ -0,0 +1,96 @@
package main
import (
"fmt"
"regexp"
)
type startupPhase string
const (
phaseConfig startupPhase = "config"
phaseSingleton startupPhase = "singleton"
phasePort startupPhase = "port"
phaseLogger startupPhase = "logger"
phaseDatabase startupPhase = "database"
phaseMigration startupPhase = "migration"
phaseAdapter startupPhase = "adapter"
phaseStaticResource startupPhase = "static"
phaseServer startupPhase = "server"
phaseTray startupPhase = "tray"
)
type startupError struct {
phase startupPhase
message string
cause error
}
func newStartupError(phase startupPhase, message string, cause error) *startupError {
return &startupError{
phase: phase,
message: redactSensitive(message),
cause: cause,
}
}
func (e *startupError) Error() string {
if e == nil {
return ""
}
if e.cause == nil {
return fmt.Sprintf("%s: %s", e.phase, e.message)
}
return fmt.Sprintf("%s: %s: %s", e.phase, e.message, redactSensitive(e.cause.Error()))
}
func (e *startupError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func (e *startupError) Phase() string {
if e == nil {
return ""
}
return string(e.phase)
}
func (e *startupError) UserMessage() string {
if e == nil {
return ""
}
return redactSensitive(e.message)
}
var sensitiveReplacers = []struct {
pattern *regexp.Regexp
replacement string
}{
{regexp.MustCompile(`(?i)(password\s*[:=]\s*)[^\s,;&]+`), `${1}<redacted>`},
{regexp.MustCompile(`(?i)(api[_-]?key\s*[:=]\s*)[^\s,;&]+`), `${1}<redacted>`},
{regexp.MustCompile(`(?i)(secret\s*[:=]\s*)[^\s,;&]+`), `${1}<redacted>`},
{regexp.MustCompile(`([^\s:/]+):([^\s@]+)@tcp\(`), `${1}:<redacted>@tcp(`},
{regexp.MustCompile(`(://[^\s:/]+):([^\s@]+)@`), `${1}:<redacted>@`},
}
func redactSensitive(s string) string {
for _, replacer := range sensitiveReplacers {
s = replacer.pattern.ReplaceAllString(s, replacer.replacement)
}
return s
}
func startupTitle() string {
return appName + " 启动失败"
}
func startupServerErrorMessage() string {
return "后端服务启动失败\n\n请检查端口占用、网络权限或查看日志获取更多信息"
}
func startupInternalErrorMessage() string {
return "应用初始化失败\n\n请查看日志或重新安装应用"
}

View File

@@ -0,0 +1,40 @@
package main
import (
"errors"
"strings"
"testing"
)
func TestStartupErrorContainsPhaseAndCause(t *testing.T) {
cause := errors.New("底层失败")
err := newStartupError(phaseDatabase, "数据库初始化失败", cause)
if err.Phase() != "database" {
t.Fatalf("phase = %q, want database", err.Phase())
}
if !errors.Is(err, cause) {
t.Fatal("startupError 应保留底层 cause")
}
if !strings.Contains(err.Error(), "database") {
t.Fatalf("错误字符串应包含 phase实际: %s", err.Error())
}
}
func TestStartupErrorRedactsSensitiveUserMessage(t *testing.T) {
message := "数据库初始化失败: nex:secret@tcp(localhost:3306)/nex password=secret api_key=sk-test"
err := newStartupError(phaseDatabase, message, errors.New("cause password=secret api_key=sk-test"))
userMessage := err.UserMessage()
for _, secret := range []string{"secret", "sk-test"} {
if strings.Contains(userMessage, secret) {
t.Fatalf("用户提示不应包含敏感信息 %q实际: %s", secret, userMessage)
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("日志错误字符串不应包含敏感信息 %q实际: %s", secret, err.Error())
}
}
if !strings.Contains(userMessage, "<redacted>") {
t.Fatalf("用户提示应包含脱敏占位符,实际: %s", userMessage)
}
}

View File

@@ -13,14 +13,15 @@ import (
func TestSetupStaticFiles(t *testing.T) {
gin.SetMode(gin.TestMode)
distFS, err := frontendDistFS()
if err != nil {
t.Skipf("跳过测试: 前端资源未构建: %v", err)
return
}
r := gin.New()
setupStaticFilesWithFS(r, distFS)
setupStaticFilesWithFS(r, fstest.MapFS{
"index.html": {Data: []byte("<html>fallback</html>")},
"icon.png": {Data: []byte("png")},
"assets/test.js": {Data: []byte("console.log('test')")},
"assets/test.css": {Data: []byte("body {}")},
"assets/test.svg": {Data: []byte("<svg></svg>")},
"assets/test.woff": {Data: []byte("font")},
})
t.Run("API 404", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/test", nil)
@@ -73,13 +74,12 @@ func TestSetupStaticFiles(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code == 200 {
expected := "application/javascript"
if w.Header().Get("Content-Type") != expected {
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
}
} else {
t.Log("文件不存在,跳过 MIME 类型验证")
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
}
expected := "application/javascript"
if w.Header().Get("Content-Type") != expected {
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
}
})
@@ -88,13 +88,12 @@ func TestSetupStaticFiles(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code == 200 {
expected := "text/css"
if w.Header().Get("Content-Type") != expected {
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
}
} else {
t.Log("文件不存在,跳过 MIME 类型验证")
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
}
expected := "text/css"
if w.Header().Get("Content-Type") != expected {
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
}
})
@@ -128,12 +127,6 @@ func TestSetupStaticFilesWithFS_IconPNG(t *testing.T) {
func TestWithProtocolAndStaticRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
distFS, err := frontendDistFS()
if err != nil {
t.Skipf("跳过测试: 前端资源未构建: %v", err)
return
}
r := gin.New()
var gotProtocol string
@@ -148,7 +141,10 @@ func TestWithProtocolAndStaticRoutes(t *testing.T) {
gotPath = c.Param("path")
c.JSON(http.StatusOK, gin.H{"protocol": gotProtocol, "path": gotPath})
}))
setupStaticFilesWithFS(r, distFS)
setupStaticFilesWithFS(r, fstest.MapFS{
"index.html": {Data: []byte("<html>fallback</html>")},
"assets/test.js": {Data: []byte("console.log('test')")},
})
t.Run("OpenAI route enters proxy handler wrapper", func(t *testing.T) {
gotProtocol = ""
@@ -199,14 +195,11 @@ func TestWithProtocolAndStaticRoutes(t *testing.T) {
if gotProtocol != "" || gotPath != "" {
t.Errorf("静态资源不应进入代理包装器,实际 protocol=%s path=%s", gotProtocol, gotPath)
}
if w.Code == http.StatusOK {
if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/javascript") {
t.Errorf("期望 JS Content-Type, 实际 %s", w.Header().Get("Content-Type"))
}
return
if w.Code != http.StatusOK {
t.Fatalf("期望静态资源返回 200, 实际 %d", w.Code)
}
if w.Code != http.StatusNotFound {
t.Errorf("期望静态资源返回 200 或 404, 实际 %d", w.Code)
if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/javascript") {
t.Errorf("期望 JS Content-Type, 实际 %s", w.Header().Get("Content-Type"))
}
})

231
backend/cmd/desktop/tray.go Normal file
View File

@@ -0,0 +1,231 @@
package main
import (
"fmt"
"runtime"
"sync"
"time"
"nex/embedfs"
"github.com/getlantern/systray"
"go.uber.org/zap"
)
const defaultTrayReadyTimeout = 5 * time.Second
type trayMenuItem interface {
Disable()
Clicked() <-chan struct{}
}
type trayController interface {
Run(onReady func(), onExit func())
Quit()
SetIcon(icon []byte)
SetTooltip(tooltip string)
AddMenuItem(title, tooltip string) trayMenuItem
AddSeparator()
}
type realTrayController struct{}
func (realTrayController) Run(onReady func(), onExit func()) {
systray.Run(onReady, onExit)
}
func (realTrayController) Quit() {
systray.Quit()
}
func (realTrayController) SetIcon(icon []byte) {
systray.SetIcon(icon)
}
func (realTrayController) SetTooltip(tooltip string) {
systray.SetTooltip(tooltip)
}
func (realTrayController) AddMenuItem(title, tooltip string) trayMenuItem {
return realTrayMenuItem{item: systray.AddMenuItem(title, tooltip)}
}
func (realTrayController) AddSeparator() {
systray.AddSeparator()
}
type realTrayMenuItem struct {
item *systray.MenuItem
}
func (m realTrayMenuItem) Disable() {
m.item.Disable()
}
func (m realTrayMenuItem) Clicked() <-chan struct{} {
return m.item.ClickedCh
}
type trayOptions struct {
controller trayController
readyTimeout time.Duration
iconLoader func() ([]byte, error)
openBrowser func(string) error
notify func(string, string)
logger *zap.Logger
fatalErrCh <-chan error
}
func setupSystray(port int, fatalErrCh <-chan error) error {
return runSystray(port, trayOptions{
controller: realTrayController{},
readyTimeout: defaultTrayReadyTimeout,
iconLoader: loadTrayIcon,
openBrowser: openBrowser,
notify: showError,
logger: dialogLogger(),
fatalErrCh: fatalErrCh,
})
}
func runSystray(port int, opts trayOptions) error {
if opts.controller == nil {
opts.controller = realTrayController{}
}
if opts.readyTimeout <= 0 {
opts.readyTimeout = defaultTrayReadyTimeout
}
if opts.iconLoader == nil {
opts.iconLoader = loadTrayIcon
}
if opts.openBrowser == nil {
opts.openBrowser = openBrowser
}
if opts.notify == nil {
opts.notify = showError
}
if opts.logger == nil {
opts.logger = dialogLogger()
}
readyCh := make(chan struct{})
doneCh := make(chan struct{})
errCh := make(chan error, 1)
var readyOnce sync.Once
var errOnce sync.Once
signalReady := func() {
readyOnce.Do(func() { close(readyCh) })
}
signalError := func(err error) {
errOnce.Do(func() { errCh <- err })
}
go monitorTrayStartup(port, opts, readyCh, doneCh, signalError)
opts.controller.Run(func() {
handleTrayReady(port, opts, signalReady, signalError)
}, nil)
close(doneCh)
select {
case err := <-errCh:
return err
default:
return nil
}
}
func monitorTrayStartup(port int, opts trayOptions, readyCh <-chan struct{}, doneCh <-chan struct{}, signalError func(error)) {
timer := time.NewTimer(opts.readyTimeout)
defer timer.Stop()
ready := false
for {
select {
case <-readyCh:
ready = true
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
openDesktopBrowser(port, opts)
readyCh = nil
case <-timer.C:
if !ready {
signalError(newStartupError(phaseTray, "托盘初始化超时", fmt.Errorf("托盘未在 %s 内 ready", opts.readyTimeout)))
opts.controller.Quit()
}
case err := <-opts.fatalErrCh:
if err != nil {
signalError(newStartupError(phaseServer, startupServerErrorMessage(), err))
opts.controller.Quit()
}
case <-doneCh:
return
}
}
}
func handleTrayReady(port int, opts trayOptions, signalReady func(), signalError func(error)) {
defer func() {
if recovered := recover(); recovered != nil {
err := fmt.Errorf("托盘初始化 panic: %v", recovered)
signalError(newStartupError(phaseTray, "托盘菜单初始化失败", err))
opts.controller.Quit()
}
}()
icon, err := opts.iconLoader()
if err != nil {
signalError(newStartupError(phaseTray, "托盘图标资源无法加载", err))
opts.controller.Quit()
return
}
opts.controller.SetIcon(icon)
opts.controller.SetTooltip(appTooltip)
mOpen := opts.controller.AddMenuItem("打开管理界面", "在浏览器中打开")
opts.controller.AddSeparator()
mStatus := opts.controller.AddMenuItem("状态: 运行中", "")
mStatus.Disable()
mPort := opts.controller.AddMenuItem(desktopPortMenuTitle(port), "")
mPort.Disable()
opts.controller.AddSeparator()
mQuit := opts.controller.AddMenuItem("退出", "停止服务并退出")
go func() {
for {
select {
case <-mOpen.Clicked():
if err := opts.openBrowser(desktopURL(port)); err != nil {
opts.logger.Warn("打开浏览器失败", zap.Error(err))
}
case <-mQuit.Clicked():
doShutdown()
opts.controller.Quit()
return
}
}
}()
signalReady()
}
func openDesktopBrowser(port int, opts trayOptions) {
url := desktopURL(port)
if err := opts.openBrowser(url); err != nil {
opts.logger.Warn("无法打开浏览器", zap.Error(err))
opts.notify(appName, fmt.Sprintf("无法自动打开浏览器,请手动访问 %s", url))
}
}
func loadTrayIcon() ([]byte, error) {
if runtime.GOOS == "windows" {
return embedfs.Assets.ReadFile("assets/icon.ico")
}
return embedfs.Assets.ReadFile("assets/icon.png")
}

View File

@@ -0,0 +1,169 @@
package main
import (
"errors"
"sync"
"testing"
"time"
"go.uber.org/zap"
)
type fakeTrayController struct {
run func(onReady func(), onExit func())
quitCh chan struct{}
quitOnce sync.Once
icon []byte
tooltip string
menuItems []*fakeTrayMenuItem
}
func newFakeTrayController() *fakeTrayController {
return &fakeTrayController{quitCh: make(chan struct{})}
}
func (c *fakeTrayController) Run(onReady func(), onExit func()) {
if c.run != nil {
c.run(onReady, onExit)
return
}
onReady()
<-c.quitCh
if onExit != nil {
onExit()
}
}
func (c *fakeTrayController) Quit() {
c.quitOnce.Do(func() { close(c.quitCh) })
}
func (c *fakeTrayController) SetIcon(icon []byte) {
c.icon = append([]byte(nil), icon...)
}
func (c *fakeTrayController) SetTooltip(tooltip string) {
c.tooltip = tooltip
}
func (c *fakeTrayController) AddMenuItem(title, tooltip string) trayMenuItem {
item := &fakeTrayMenuItem{clicked: make(chan struct{}), title: title, tooltip: tooltip}
c.menuItems = append(c.menuItems, item)
return item
}
func (c *fakeTrayController) AddSeparator() {}
type fakeTrayMenuItem struct {
clicked chan struct{}
title string
tooltip string
disabled bool
}
func (m *fakeTrayMenuItem) Disable() {
m.disabled = true
}
func (m *fakeTrayMenuItem) Clicked() <-chan struct{} {
return m.clicked
}
func TestRunSystrayReadyOpensBrowser(t *testing.T) {
controller := newFakeTrayController()
opened := make(chan string, 1)
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: time.Second,
iconLoader: func() ([]byte, error) { return []byte("icon"), nil },
openBrowser: func(url string) error {
opened <- url
controller.Quit()
return nil
},
notify: func(string, string) {},
logger: zap.NewNop(),
})
if err != nil {
t.Fatalf("托盘 ready 成功不应返回错误: %v", err)
}
if got := <-opened; got != "http://localhost:19826" {
t.Fatalf("浏览器 URL = %s", got)
}
if string(controller.icon) != "icon" {
t.Fatalf("应设置托盘图标")
}
if controller.tooltip != appTooltip {
t.Fatalf("tooltip = %q, want %q", controller.tooltip, appTooltip)
}
}
func TestRunSystrayReadyTimeoutReturnsTrayStartupError(t *testing.T) {
controller := newFakeTrayController()
controller.run = func(_ func(), _ func()) {
<-controller.quitCh
}
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: 10 * time.Millisecond,
iconLoader: func() ([]byte, error) { return []byte("icon"), nil },
openBrowser: func(string) error { return nil },
notify: func(string, string) {},
logger: zap.NewNop(),
})
if err == nil {
t.Fatal("托盘 ready timeout 应返回错误")
}
var startupErr *startupError
if !errors.As(err, &startupErr) || startupErr.Phase() != "tray" {
t.Fatalf("应返回 tray 阶段启动错误,实际: %v", err)
}
}
func TestRunSystrayIconLoadFailureReturnsTrayStartupError(t *testing.T) {
controller := newFakeTrayController()
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: time.Second,
iconLoader: func() ([]byte, error) { return nil, errors.New("missing icon") },
openBrowser: func(string) error { return nil },
notify: func(string, string) {},
logger: zap.NewNop(),
})
if err == nil {
t.Fatal("托盘图标加载失败应返回错误")
}
var startupErr *startupError
if !errors.As(err, &startupErr) || startupErr.Phase() != "tray" {
t.Fatalf("应返回 tray 阶段启动错误,实际: %v", err)
}
}
func TestRunSystrayBrowserOpenFailureIsNonFatal(t *testing.T) {
controller := newFakeTrayController()
notified := make(chan string, 1)
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: time.Second,
iconLoader: func() ([]byte, error) { return []byte("icon"), nil },
openBrowser: func(string) error { return errors.New("no browser") },
notify: func(_, message string) {
notified <- message
controller.Quit()
},
logger: zap.NewNop(),
})
if err != nil {
t.Fatalf("浏览器打开失败不应成为 fatal: %v", err)
}
if got := <-notified; got == "" {
t.Fatal("浏览器打开失败应提示用户")
}
}

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,151 @@
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 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"`
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

@@ -2,6 +2,7 @@ package database
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
@@ -17,6 +18,8 @@ import (
pkglogger "nex/backend/pkg/logger"
)
var ErrMigration = errors.New("数据库迁移失败")
func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) {
moduleLogger := pkglogger.WithModule(zapLogger, "database")
@@ -26,7 +29,7 @@ func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) {
}
if err := runMigrations(db, cfg.Driver, moduleLogger); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
return nil, fmt.Errorf("%w: %w", ErrMigration, err)
}
configurePool(db, cfg, moduleLogger)

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,510 @@
package handler
import (
"bytes"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"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)
}
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

@@ -1,6 +1,6 @@
# Nex Frontend
AI 网关管理前端,提供供应商配置和用量统计界面。
AI 网关管理前端,提供供应商配置和总览界面。
## 技术栈
@@ -97,7 +97,7 @@ frontend/
│ │ └── useVersion.ts
│ ├── pages/
│ │ ├── Providers/ # 供应商管理(含内嵌模型管理)
│ │ ├── Stats/ # 用量统计
│ │ ├── Stats/ # 总览
│ │ ├── Settings/ # 设置(开发中)
│ │ ├── About/ # 关于页面(品牌与版本信息)
│ │ └── NotFound.tsx
@@ -199,7 +199,7 @@ bun run test:e2e
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别,支持一键复制
- **UUID 自动生成**:创建模型时后端自动生成 UUID无需手动输入 ID
### 用量统计
### 总览
- 查看统计数据
- 按供应商筛选

View File

@@ -18,7 +18,7 @@ test.describe('侧边栏', () => {
test('应显示导航菜单项', async ({ page }) => {
const aside = page.locator('aside')
await expect(aside.getByText('供应商管理')).toBeVisible()
await expect(aside.getByText('用量统计')).toBeVisible()
await expect(aside.getByText('总览')).toBeVisible()
})
})
@@ -28,14 +28,14 @@ test.describe('页面导航', () => {
await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible()
})
test('应能切换到用量统计', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
test('应能切换到总览', async ({ page }) => {
await page.locator('aside').getByText('总览').click()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
})
test('应能切换回供应商管理', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await page.locator('aside').getByText('总览').click()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
await page.locator('aside').getByText('供应商管理').click()
await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible()
@@ -51,10 +51,10 @@ test.describe('页面导航', () => {
})
test('应在刷新后保持当前页面', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await page.locator('aside').getByText('总览').click()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
await page.reload()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
})
})

View File

@@ -41,7 +41,7 @@ test.describe('统计概览', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/stats')
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
})
test('应显示正确的总请求量', async ({ page }) => {
@@ -99,7 +99,7 @@ test.describe('统计筛选', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/stats')
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
})
test('按供应商筛选', async ({ page }) => {

View File

@@ -31,7 +31,7 @@ describe('AppLayout', () => {
renderWithRouter(<AppLayout />)
expect(screen.getAllByText('供应商管理').length).toBeGreaterThan(0)
expect(screen.getAllByText('用量统计').length).toBeGreaterThan(0)
expect(screen.getAllByText('总览').length).toBeGreaterThan(0)
})
it('renders settings menu item', () => {

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: '1h0m0s',
},
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: '1h0m0s',
},
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

@@ -20,7 +20,7 @@ export function AppLayout() {
const getPageTitle = () => {
if (location.pathname === '/providers') return '供应商管理'
if (location.pathname === '/stats') return '用量统计'
if (location.pathname === '/stats') return '总览'
if (location.pathname === '/settings') return '设置'
if (location.pathname === '/about') return '关于'
return APP_NAME
@@ -73,12 +73,12 @@ export function AppLayout() {
}
style={{ height: '100%' }}
>
<MenuItem value='/stats' icon={<ChartLineIcon />}>
</MenuItem>
<MenuItem value='/providers' icon={<ServerIcon />}>
</MenuItem>
<MenuItem value='/stats' icon={<ChartLineIcon />}>
</MenuItem>
<MenuItem value='/settings' icon={<SettingIcon />}>
</MenuItem>

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,298 @@
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 TIMEOUT_OPTIONS = [
{ label: '5 秒', value: '5s' },
{ label: '10 秒', value: '10s' },
{ label: '15 秒', value: '15s' },
{ label: '30 秒', value: '30s' },
{ label: '1 分钟', value: '1m0s' },
{ label: '2 分钟', value: '2m0s' },
{ label: '5 分钟', value: '5m0s' },
]
const CONN_LIFETIME_OPTIONS = [
{ 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 = [
{ label: '1 天', value: 1 },
{ label: '3 天', value: 3 },
{ label: '7 天', value: 7 },
{ label: '14 天', value: 14 },
{ label: '30 天', value: 30 },
{ label: '60 天', value: 60 },
{ label: '90 天', value: 90 },
]
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: '请选择读超时' }]}>
<Select options={TIMEOUT_OPTIONS} />
</Form.FormItem>
<Form.FormItem label='写超时' name='server.writeTimeout' rules={[{ required: true, message: '请选择写超时' }]}>
<Select options={TIMEOUT_OPTIONS} />
</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: '请选择连接最大生命周期' }]}
>
<Select options={CONN_LIFETIME_OPTIONS} />
</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: '请选择最大保留天数' }]}
>
<Select options={MAX_AGE_OPTIONS} />
</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

@@ -14,7 +14,7 @@ export function AppRoutes() {
<Suspense fallback={<Loading />}>
<Routes>
<Route element={<AppLayout />}>
<Route index element={<Navigate to='/providers' replace />} />
<Route index element={<Navigate to='/stats' replace />} />
<Route path='providers' element={<ProvidersPage />} />
<Route path='stats' element={<StatsPage />} />
<Route path='settings' element={<SettingsPage />} />

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
}

13
opencode.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"tdesign-mcp-server": {
"enabled": true,
"type": "local",
"command": [
"bunx",
"tdesign-mcp-server@latest"
],
}
}
}

View File

@@ -0,0 +1,202 @@
# 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始终执行三平台 matrix
- **WHEN** 测试 workflow 被调用(无论 `full` 值)
- **THEN** `check` job SHALL 始终执行
- **THEN** `check` job SHALL 使用 `strategy.matrix.os``ubuntu-latest``macos-latest``windows-latest` 三个平台并行运行
- **THEN** 每个平台 SHALL 按顺序执行checkout含 LFS→ setup Go → setup Bun → `make lint``make test`
- **THEN** 在 Linux 平台上lint/test 之前 SHALL 执行 `sudo apt-get install -y libayatana-appindicator3-dev` 安装系统依赖
- **THEN** macOS 和 Windows 平台 SHALL NOT 安装额外系统依赖
- **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 阻止后续步骤执行
- **THEN** 任一平台失败 SHALL 导致 `check` job 整体失败
#### Scenario: check job 平台特定依赖安装
- **WHEN** `check` job 在 Linux 平台运行
- **THEN** SHALL 在 lint 步骤之前执行系统依赖安装步骤
- **THEN** 该步骤 SHALL 使用 `if: runner.os == 'Linux'` 条件控制
- **THEN** 该步骤 SHALL 安装 `libayatana-appindicator3-dev`
#### Scenario: check job macOS 平台
- **WHEN** `check` job 在 macOS 平台运行
- **THEN** SHALL NOT 安装额外系统依赖
- **THEN** `make lint``make test` SHALL 正常执行
#### Scenario: check job Windows 平台
- **WHEN** `check` job 在 Windows 平台运行
- **THEN** SHALL NOT 安装额外系统依赖
- **THEN** `make lint``make test` 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 在各平台 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 使用隔离的资源,不干扰主环境。
### Requirement: Desktop 原生 UI 测试不依赖真实图形环境
CI 测试门禁 SHALL 允许验证 desktop 启动失败报告的 UI 无关逻辑,但 SHALL NOT 要求 GitHub Actions runner 或本地 CI 环境具备真实系统通知、模态弹窗或托盘可见性。
#### Scenario: CI 运行 desktop 启动失败测试
- **WHEN** `check` job 执行 `make test`
- **THEN** desktop 专属测试 SHALL 可以覆盖启动失败分类、提示通道选择、fallback 顺序和托盘 ready/timeout 逻辑
- **THEN** 测试 SHALL 使用 mock、fake runner 或接口注入验证调用意图
#### Scenario: CI 不验证真实原生 UI 展示
- **WHEN** `check` job 在 Linux、macOS 或 Windows runner 上运行
- **THEN** 测试 SHALL NOT 要求真实系统通知可见
- **THEN** 测试 SHALL NOT 要求真实模态弹窗被显示或被人工点击
- **THEN** 测试 SHALL NOT 要求真实托盘图标可见
- **THEN** runner 的通知权限、勿扰模式、DBus 状态或桌面会话差异 SHALL NOT 导致正常 CI 失败
#### Scenario: Linux CI 系统依赖边界
- **WHEN** Linux `check` job 安装 desktop 构建和测试所需系统依赖
- **THEN** 该依赖安装 SHALL NOT 被解释为需要在 CI 中验证真实 Linux 通知或弹窗展示
- **THEN** Linux 通知/弹窗命令 SHALL 在测试中通过 fake runner 覆盖
### 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,135 @@ 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 修改当前运行配置
### 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

@@ -6,6 +6,130 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
## Requirements
### Requirement: Desktop 启动失败报告
Desktop SHALL 将无法进入可用状态的启动失败统一转换为包含阶段、用户消息和底层原因的启动错误,并通过统一报告器提示用户。
#### Scenario: 配置加载或验证失败
- **WHEN** desktop 启动时 `~/.nex/config.yaml` 无法解析或配置验证失败
- **THEN** 系统 SHALL 生成 `config` 阶段启动错误
- **THEN** 错误提示 SHALL 包含配置文件路径和失败原因
- **THEN** 应用 SHALL 退出
#### Scenario: 日志初始化失败
- **WHEN** desktop 启动时完整 logger 初始化失败
- **THEN** 系统 SHALL 生成 `logger` 阶段启动错误
- **THEN** 错误提示 SHALL 描述日志初始化失败并包含可操作的路径或权限线索
- **THEN** 应用 SHALL 退出
#### Scenario: 数据库初始化或迁移失败
- **WHEN** desktop 启动时数据库打开、连接、初始化或迁移失败
- **THEN** 系统 SHALL 生成 `database``migration` 阶段启动错误
- **THEN** 错误提示 SHALL 描述数据库初始化失败或迁移失败
- **THEN** 错误提示 SHALL NOT 暴露 MySQL password、完整 DSN 或 API key 等敏感信息
- **THEN** 应用 SHALL 退出
#### Scenario: 嵌入资源或内部组件初始化失败
- **WHEN** desktop 启动时前端嵌入资源、协议 adapter 或其他内部组件初始化失败
- **THEN** 系统 SHALL 生成对应启动阶段的启动错误
- **THEN** 错误提示 SHALL 描述应用初始化失败并建议查看日志或重新安装
- **THEN** 应用 SHALL 退出
#### Scenario: 启动失败提示降级链
- **WHEN** desktop 生成 fatal 启动错误
- **THEN** 系统 SHALL 先尝试平台系统通知
- **THEN** 系统 SHALL 在通知不可用或返回失败时尝试平台模态弹窗
- **THEN** 系统 SHALL 在模态弹窗不可用或返回失败时输出到 stderr 或可用启动日志
- **THEN** 每一次提示通道失败 SHALL 被记录但 SHALL NOT 阻止后续 fallback
#### Scenario: 提示通道调用前可用性检查
- **WHEN** desktop 启动失败报告器准备调用任一系统通知或模态弹窗通道
- **THEN** 系统 SHALL 在调用前检查该通道对应命令、系统 API 或图形会话条件是否可用
- **THEN** 系统 SHALL 在通道不可用时跳过该通道并进入下一 fallback
- **THEN** 系统 SHALL NOT 因某个通知或弹窗工具缺失而中断后续降级链
#### Scenario: 浏览器打开失败为非 fatal
- **WHEN** desktop 后端服务和托盘已启动但浏览器自动打开失败
- **THEN** 系统 SHALL 记录 warning
- **THEN** 系统 SHALL 尝试通过非 fatal 提示告知用户可手动访问 `http://localhost:<server.port>`
- **THEN** 应用 SHALL 继续运行
### Requirement: macOS 通知和对话框降级策略
系统 SHALL 在 macOS 上优先使用通知中心提示 desktop 启动错误,并在通知不可用时降级到 AppleScript 模态告警。
#### Scenario: macOS 通知可用
- **WHEN** desktop 在 macOS 上生成启动错误且通知命令可用
- **THEN** 系统 SHALL 使用 `osascript display notification` 发送系统通知
- **THEN** 通知 SHALL 包含应用名称和错误描述文本
#### Scenario: macOS 通知失败
- **WHEN** macOS 系统通知命令不可用或返回失败
- **THEN** 系统 SHALL 使用 `osascript display alert` 显示模态告警
- **THEN** 模态告警 SHALL 使用 critical 错误语义
- **THEN** 告警 SHALL 包含应用名称和错误描述文本
#### Scenario: macOS UI 提示均失败
- **WHEN** macOS 系统通知和模态告警均失败
- **THEN** 系统 SHALL 降级输出到 stderr 或可用启动日志
### Requirement: Linux 启动错误提示降级策略
系统 SHALL 在 Linux 上为 desktop 启动错误按优先级使用可用的通知或对话框工具,优先使用系统通知栏,再降级到模态弹窗,最后降级到标准错误输出。该策略 SHALL 不移除既有通用信息提示能力。
#### Scenario: notify-send 可用
- **WHEN** desktop 在 Linux 上生成启动错误且 `notify-send` 命令可用并存在图形会话
- **THEN** 使用 `notify-send` 显示系统通知
- **AND** 错误通知使用 `-u critical` 参数
- **AND** 通知 SHALL 包含应用名称和错误描述文本
#### Scenario: kdialog passive popup 可用
- **WHEN** `notify-send` 不可用或返回失败且 `kdialog` 命令可用
- **THEN** 系统 SHALL 优先尝试使用 `kdialog --passivepopup` 显示系统通知式提示
#### Scenario: zenity 可用
- **WHEN** Linux 通知工具不可用或返回失败且系统检测到 `zenity` 命令可用
- **THEN** 使用 zenity 显示 GTK 风格错误对话框
- **AND** 错误对话框使用 `zenity --error` 命令
#### Scenario: kdialog 模态对话框可用
- **WHEN** `zenity` 不可用或返回失败且 `kdialog` 命令可用
- **THEN** 使用 kdialog 显示 KDE 风格错误对话框
- **AND** 错误对话框使用 `kdialog --error` 命令
#### Scenario: xmessage 可用
- **WHEN** `zenity``kdialog` 模态对话框均不可用或返回失败且 `xmessage` 命令可用
- **THEN** 使用 xmessage 显示基础 X11 对话框
- **AND** 对话框居中显示(`-center` 参数)
#### Scenario: 无 UI 工具可用
- **WHEN** 所有通知和对话框工具均不可用、图形会话不存在或所有 UI 提示均返回失败
- **THEN** 降级到标准错误输出
- **AND** 输出格式为 `错误: <title>: <message>`
#### Scenario: 工具检测缓存
- **WHEN** desktop 在 Linux 上启动
- **THEN** 系统检测一次可用通知和对话框工具
- **AND** 检测结果缓存在包级变量
- **AND** 后续提示调用直接使用缓存结果,不重复检测
### Requirement: 桌面应用启动
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。
@@ -36,7 +160,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`托盘初始化失败 SHALL 被视为 desktop fatal 启动失败。
#### Scenario: 托盘图标显示
@@ -60,11 +184,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
#### Scenario: 托盘 ready 后自动打开浏览器
- **WHEN** desktop 启动并完成托盘图标、菜单和点击事件初始化
- **THEN** 系统 SHALL 将托盘标记为 ready
- **THEN** 系统 SHALL 在托盘 ready 后自动打开 `http://localhost:<server.port>`
- **THEN** 系统 SHALL NOT 在托盘初始化失败时自动打开浏览器
#### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:<server.port>`
- **AND** 系统 SHALL 尝试显示非 fatal 提示,告知用户手动访问地址
#### Scenario: 退出应用
@@ -73,6 +205,13 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **AND** 托盘图标消失
- **AND** 应用进程退出
#### Scenario: 托盘初始化失败
- **WHEN** desktop 启动时托盘未在限定时间内 ready、托盘图标资源无法加载或托盘菜单无法完成初始化
- **THEN** 系统 SHALL 生成 `tray` 阶段启动错误
- **THEN** 系统 SHALL 通过启动失败提示降级链提示用户
- **THEN** 应用 SHALL 退出
### Requirement: 静态文件服务
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
@@ -126,19 +265,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 端口冲突检测
系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用
系统 SHALL 在启动时获取启动配置中的 `server.port` 对应监听端口,并使用同一个 listener 启动后端服务,避免端口预检测与实际监听之间的竞态
#### Scenario: 配置端口可用
- **WHEN** 启动配置中的 `server.port` 未被占用
- **THEN** 服务正常启动
- **THEN** 系统 SHALL 成功创建该端口的 listener
- **THEN** 后端服务 SHALL 使用该 listener 正常启动
#### Scenario: 配置端口被占用
- **WHEN** 启动配置中的 `server.port` 已被其他程序占用
- **THEN** 显示错误提示"端口 <server.port> 已被占用"
- **THEN** 系统 SHALL 生成 `port` 阶段启动错误
- **THEN** 错误提示 SHALL 包含"端口 <server.port> 已被占用"
- **AND** 应用退出
#### Scenario: 单实例优先于端口监听
- **WHEN** 用户尝试启动第二个 desktop 实例
- **THEN** 系统 SHALL 优先检测到已有实例持有文件锁
- **THEN** 系统 SHALL 显示"已有 Nex 实例运行"相关错误提示
- **THEN** 系统 SHALL NOT 将该场景误报为端口占用
### Requirement: 桌面配置源隔离和启动快照
desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。
@@ -312,74 +460,52 @@ desktop 内嵌前端 SHALL 使用同源相对路径访问后端 API不主动
### Requirement: Windows 原生对话框
系统 SHALL 在 Windows 上使用 `user32.dll``MessageBoxW` API 显示错误对话框,替代 `msg *` 命令。
系统 SHALL 在 Windows 上优先使用系统通知提示启动错误,并在通知不可用或失败时使用 `user32.dll``MessageBoxW` API 显示错误对话框,替代 `msg *` 命令。
#### Scenario: Windows 通知优先
- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等)
- **THEN** 系统 SHALL 优先尝试发送 Windows 系统通知
- **THEN** 通知 SHALL 包含应用名称和错误描述文本
#### Scenario: 错误提示对话框
- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等)
- **WHEN** Windows 系统通知不可用或返回失败
- **THEN** 使用 `MessageBoxW` 显示模态对话框
- **AND** 对话框标题栏显示应用名称
- **AND** 对话框包含错误描述文本
- **AND** 对话框显示错误图标MB_ICONERROR
- **AND** 对话框 SHALL 尽量置于前台或顶层显示
#### Scenario: Windows UI 提示均失败
- **WHEN** Windows 系统通知和 `MessageBoxW` 均失败
- **THEN** 系统 SHALL 记录提示失败原因
- **THEN** 系统 SHALL 降级输出到可用启动日志
#### Scenario: 非 Windows 平台不受影响
- **WHEN** 应用运行在 macOS 或 Linux 上
- **THEN** 错误对话框仍使用平台原有实现osascript / zenity
### Requirement: Linux 对话框降级策略
系统 SHALL 在 Linux 上按优先级检测并使用可用的对话框工具,确保在不同桌面环境下都能显示对话框。
#### Scenario: zenity 可用
- **WHEN** 系统检测到 `zenity` 命令可用
- **THEN** 使用 zenity 显示 GTK 风格对话框
- **AND** 错误对话框使用 `zenity --error` 命令
- **AND** 信息对话框使用 `zenity --info` 命令
#### Scenario: kdialog 可用
- **WHEN** zenity 不可用且 `kdialog` 命令可用
- **THEN** 使用 kdialog 显示 KDE 风格对话框
- **AND** 错误对话框使用 `kdialog --error` 命令
- **AND** 信息对话框使用 `kdialog --msgbox` 命令
#### Scenario: notify-send 可用
- **WHEN** zenity 和 kdialog 均不可用且 `notify-send` 命令可用
- **THEN** 使用 notify-send 显示系统通知
- **AND** 错误通知使用 `-u critical` 参数
- **AND** 信息通知使用默认参数
#### Scenario: xmessage 可用
- **WHEN** zenity、kdialog、notify-send 均不可用且 `xmessage` 命令可用
- **THEN** 使用 xmessage 显示基础 X11 对话框
- **AND** 对话框居中显示(`-center` 参数)
#### Scenario: 无对话框工具可用
- **WHEN** 所有对话框工具均不可用
- **THEN** 降级到标准错误输出
- **AND** 输出格式为 `错误: <title>: <message>`
#### Scenario: 工具检测缓存
- **WHEN** 应用启动
- **THEN** 系统检测一次可用对话框工具
- **AND** 检测结果缓存在包级变量
- **AND** 后续对话框调用直接使用缓存结果,不重复检测
- **THEN** 错误提示 SHALL 使用对应平台的通知和弹窗降级策略
### Requirement: macOS AppleScript 字符转义
系统 SHALL 对 AppleScript 对话框中的特殊字符进行转义,确保脚本正确执行。
系统 SHALL 对 AppleScript 通知、模态告警和对话框中的特殊字符进行转义,确保脚本正确执行。
#### Scenario: 转义反斜杠
- **WHEN** 对话框消息包含反斜杠字符 `\`
- **WHEN** AppleScript 提示文本包含反斜杠字符 `\`
- **THEN** 转义为 `\\`
#### Scenario: 转义双引号
- **WHEN** 对话框消息包含双引号字符 `"`
- **WHEN** AppleScript 提示文本包含双引号字符 `"`
- **THEN** 转义为 `\"`
#### Scenario: 多行文本处理
- **WHEN** 对话框消息包含换行符 `\n`
- **THEN** AppleScript 正确显示多行文本
- **WHEN** AppleScript 提示文本包含换行符 `\n`
- **THEN** AppleScript 通知或模态告警 SHALL 正确显示多行文本或保留可读换行语义
### Requirement: 桌面应用打包迁移资源

View File

@@ -2,7 +2,7 @@
## Purpose
TBD - 提供供应商、模型配置和用量统计的前端管理界面
TBD - 提供供应商、模型配置和总览的前端管理界面
## Requirements
@@ -374,13 +374,73 @@ 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** 前端渲染启动参数设置表单
- **THEN** `server.readTimeout` 字段 SHALL 使用 Select 下拉组件提供以下预设选项5 秒、10 秒、15 秒、30 秒、1 分钟、2 分钟、5 分钟
- **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` 字段的 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: 日志最大保留天数使用下拉预设选择
- **WHEN** 前端渲染启动参数设置表单中的 `log.maxAge` 字段
- **THEN** `log.maxAge` 字段 SHALL 使用 Select 下拉组件
- **THEN** Select SHALL 提供以下预设选项1 天、3 天、7 天、14 天、30 天、60 天、90 天
- **THEN** Select value SHALL 使用数字类型
- **THEN** Select label SHALL 使用中文单位显示(如 `"30 天"`
#### 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
@@ -448,7 +508,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
- **THEN** 侧边栏 SHALL 包含导航菜单
- **THEN** 导航菜单项 SHALL 包含供应商管理ServerIcon 图标、用量统计ChartLineIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
- **THEN** 导航菜单项 SHALL 按以下顺序包含总览ChartLineIcon 图标、供应商管理ServerIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
#### Scenario: 侧边栏折叠品牌显示
@@ -461,7 +521,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计"
- **WHEN** 用户点击导航中的"总览"
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"设置"
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
@@ -477,10 +537,10 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 应用启动
- **THEN** 前端 SHALL 使用 React Router v7 Library 模式BrowserRouter
- **THEN** `/providers` 路径 SHALL 显示供应商管理页面
- **THEN** `/stats` 路径 SHALL 显示用量统计页面
- **THEN** `/stats` 路径 SHALL 显示总览页面
- **THEN** `/settings` 路径 SHALL 显示设置页面
- **THEN** `/about` 路径 SHALL 显示关于页面
- **THEN** `/` 路径 SHALL 重定向到 `/providers`
- **THEN** `/` 路径 SHALL 重定向到 `/stats`
- **THEN** 不存在的路径 SHALL 显示 404 页面
#### Scenario: 路由级懒加载
@@ -494,7 +554,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计"
- **WHEN** 用户点击导航中的"总览"
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
#### Scenario: URL 同步

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 产生可直接公开的成功发布结果

View File

@@ -351,3 +351,34 @@
- **WHEN** 测试配置加载时指定不存在的配置文件路径
- **THEN** SHALL 返回默认配置值,不自动创建配置文件
- **THEN** 测试 SHALL 验证配置文件未被创建
### Requirement: Desktop 启动失败提示测试边界
系统 SHALL 为 desktop 启动失败报告建立 UI 无关测试覆盖,验证启动错误分类、提示通道选择和 fallback 行为,但 SHALL NOT 要求测试真实系统通知、模态弹窗或托盘 UI 可见性。
#### Scenario: 启动错误分类测试
- **WHEN** 运行 desktop 专属测试
- **THEN** 测试 SHALL 覆盖配置、单实例、端口、日志、数据库、迁移、静态资源、HTTP server 和托盘初始化失败的错误分类
- **THEN** 测试 SHALL 验证每类错误包含正确 phase 和用户可读消息
- **THEN** 测试 SHALL 验证敏感信息不会出现在用户提示文本中
#### Scenario: 提示通道选择测试
- **WHEN** 运行跨平台提示逻辑测试
- **THEN** 测试 SHALL 使用 fake runner 或 fake notifier 验证通知、模态弹窗和 stderr/log fallback 的调用顺序
- **THEN** 测试 SHALL 验证命令参数构造、AppleScript 转义、Windows MessageBox flags 和 Linux 工具优先级
- **THEN** 测试 SHALL NOT 调用真实 `osascript``notify-send``zenity``kdialog``xmessage` 或显示真实 `MessageBoxW`
#### Scenario: 托盘 ready/timeout 测试
- **WHEN** 运行托盘启动封装测试
- **THEN** 测试 SHALL 使用 fake systray runner 验证 ready 成功路径
- **THEN** 测试 SHALL 使用 fake systray runner 验证 ready timeout 会返回 `tray` 阶段 fatal 启动错误
- **THEN** 测试 SHALL NOT 要求真实桌面托盘图标出现
#### Scenario: 浏览器打开失败测试
- **WHEN** 测试浏览器自动打开失败
- **THEN** 测试 SHALL 验证该错误被记录为非 fatal warning
- **THEN** 测试 SHALL 验证应用启动流程不会因浏览器打开失败退出