1
0

7 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
51 changed files with 2208 additions and 375 deletions

8
.claude/settings.json Normal file
View File

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

View File

@@ -14,8 +14,11 @@ permissions:
jobs: jobs:
check: check:
name: Check name: Check (${{ matrix.os }})
runs-on: ubuntu-latest runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps: steps:
- name: Checkout - name: Checkout
@@ -23,6 +26,10 @@ jobs:
with: with:
lfs: true 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 - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:

3
.gitignore vendored
View File

@@ -399,7 +399,8 @@ env/
cython_debug/ cython_debug/
# Custom # Custom
.claude .claude/*
!.claude/settings.json
.opencode .opencode
.codex .codex
openspec/changes/archive openspec/changes/archive

View File

@@ -127,7 +127,7 @@ make desktop-build-linux TARGET_ARCH=arm64
- 桌面应用需要 CGO 支持 - 桌面应用需要 CGO 支持
- macOS: 自带 Xcode Command Line Tools - macOS: 自带 Xcode Command Line Tools
- Linux 构建: 需要 gcc、pkg-config、GTK3 开发包和 Ayatana AppIndicator 开发包Ubuntu/Debian: `libgtk-3-dev``libayatana-appindicator3-dev` - 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 隐藏控制台窗口 - Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链desktop 使用 GUI linker flags 隐藏控制台窗口
- macOS DMG: 发布包暂不签名、不 notarize首次打开可能出现 Gatekeeper 提示 - macOS DMG: 发布包暂不签名、不 notarize首次打开可能出现 Gatekeeper 提示
@@ -248,7 +248,7 @@ server 和 desktop 发布产物自包含运行时数据库迁移资源(通过
- **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效 - **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效
- **Server 模式**:查询返回当前运行有效配置,保存请求始终返回 403 - **Server 模式**:查询返回当前运行有效配置,保存请求始终返回 403
响应包含 `mode``editable``config_path``restart_required` 元数据和完整启动参数配置。Duration 字段使用字符串格式(如 `30s``1h` 响应包含 `mode``editable``config_path``restart_required` 元数据和完整启动参数配置。Duration 字段使用 Go `time.Duration.String()` 标准字符串格式(如 `30s``1m0s``1h0m0s`);配置文件中用户可手写任意合法 Go duration 字符串(如 `1h``30m`),保存时系统会统一为标准格式
#### 版本信息 #### 版本信息
- `GET /api/version` - 获取后端构建版本信息(`version``commit``build_time`),用于前端 About 页面诊断前后端版本一致性 - `GET /api/version` - 获取后端构建版本信息(`version``commit``build_time`),用于前端 About 页面诊断前后端版本一致性
@@ -281,7 +281,7 @@ database:
# dbname: nex # dbname: nex
max_idle_conns: 10 max_idle_conns: 10
max_open_conns: 100 max_open_conns: 100
conn_max_lifetime: 1h conn_max_lifetime: 1h0m0s
log: log:
level: info level: info

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 # dbname: nex
max_idle_conns: 10 max_idle_conns: 10
max_open_conns: 100 max_open_conns: 100
conn_max_lifetime: 1h conn_max_lifetime: 1h0m0s
log: log:
level: info level: info

View File

@@ -4,17 +4,35 @@ package main
import ( import (
"fmt" "fmt"
"os/exec"
"strings" "strings"
"go.uber.org/zap"
) )
func showError(title, message string) { func platformStartupChannels(runner commandRunner) []promptChannel {
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`, return []promptChannel{
escapeAppleScript(message), escapeAppleScript(title)) {
if err := exec.Command("osascript", "-e", script).Run(); err != nil { name: "macos-notification",
dialogLogger().Warn("显示错误对话框失败", zap.Error(err)) 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 package main
import ( import (
"errors"
"fmt" "fmt"
"os/exec" "os"
"sync" "sync"
) )
@@ -12,56 +13,99 @@ type dialogToolType int
const ( const (
toolNone dialogToolType = iota toolNone dialogToolType = iota
toolZenity
toolKdialog
toolNotifySend toolNotifySend
toolKdialogPassive
toolZenity
toolKdialogError
toolXmessage toolXmessage
) )
var ( var (
dialogTool dialogToolType dialogTools map[string]bool
dialogToolOnce sync.Once dialogToolOnce sync.Once
dialogToolNames = []string{"notify-send", "kdialog", "zenity", "xmessage"}
) )
func init() { func init() {
dialogToolOnce.Do(detectDialogTool) dialogToolOnce.Do(func() { detectDialogTools(defaultCommandRunner{}) })
} }
func detectDialogTool() { func platformStartupChannels(runner commandRunner) []promptChannel {
tools := []struct { return []promptChannel{
name string linuxCommandChannel("notify-send", toolNotifySend, runner, linuxHasGraphicalSessionAndDBus, func(req promptRequest) []string {
typ dialogToolType return []string{"-u", "critical", "-a", appName, "-i", "nex", req.title, req.message}
}{ }),
{"zenity", toolZenity}, linuxCommandChannel("kdialog", toolKdialogPassive, runner, linuxHasGraphicalSession, func(req promptRequest) []string {
{"kdialog", toolKdialog}, return []string{"--title", req.title, "--passivepopup", req.message, "10"}
{"notify-send", toolNotifySend}, }),
{"xmessage", toolXmessage}, 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 { func detectDialogTools(runner commandRunner) {
if _, err := exec.LookPath(tool.name); err == nil { dialogTools = make(map[string]bool, len(dialogToolNames))
dialogTool = tool.typ for _, name := range dialogToolNames {
return _, 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) { func linuxHasGraphicalSession() error {
switch dialogTool { if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
case toolZenity: return errors.New("缺少图形会话")
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("无法显示错误对话框")
} }
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 package main
import ( import (
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"syscall" "syscall"
"unicode/utf16"
"unsafe" "unsafe"
"go.uber.org/zap"
) )
const ( const (
mbOK = 0x00000000
mbIconError = 0x10 mbIconError = 0x10
mbIconInformation = 0x40 mbIconInformation = 0x40
mbTaskModal = 0x00002000
mbSetForeground = 0x00010000
mbTopMost = 0x00040000
) )
var ( var (
@@ -25,12 +29,79 @@ var (
} }
) )
func showError(title, message string) { func platformStartupChannels(runner commandRunner) []promptChannel {
if err := messageBox(title, message, mbIconError); err != nil { return []promptChannel{
if zapLogger != nil { {
zapLogger.Warn("显示错误对话框失败", zap.Error(err)) 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 { func messageBox(title, message string, flags uint) error {

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"net" "net"
@@ -27,10 +28,10 @@ import (
"nex/backend/internal/service" "nex/backend/internal/service"
"nex/backend/pkg/buildinfo" "nex/backend/pkg/buildinfo"
"github.com/getlantern/systray"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gofrs/flock" "github.com/gofrs/flock"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
pkgLogger "nex/backend/pkg/logger" pkgLogger "nex/backend/pkg/logger"
) )
@@ -40,31 +41,65 @@ var (
zapLogger *zap.Logger zapLogger *zap.Logger
shutdownCtx context.Context shutdownCtx context.Context
shutdownCancel context.CancelFunc 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() { func main() {
minimalLogger := pkgLogger.NewMinimal() minimalLogger := pkgLogger.NewMinimal()
if err := runDesktop(minimalLogger); err != nil {
cfg, cfgMeta, err := config.LoadDesktopConfigWithMetadata() reportStartupFailure(err, dialogLogger())
if err != nil {
minimalLogger.Error("加载配置失败", zap.Error(err))
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
os.Exit(1) 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 port := cfg.Server.Port
if err := checkPortAvailable(port); err != nil { singleLock := desktopHooks.newLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
minimalLogger.Error("端口不可用", zap.Error(err))
showError(appName, err.Error())
os.Exit(1)
}
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
if err := singleLock.Lock(); err != nil { if err := singleLock.Lock(); err != nil {
minimalLogger.Error("已有 Nex 实例运行") return newStartupError(phaseSingleton, "已有 Nex 实例运行", err)
showError(appName, "已有 Nex 实例运行")
os.Exit(1)
} }
defer func() { defer func() {
if err := singleLock.Unlock(); err != nil { 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, Level: cfg.Log.Level,
Path: cfg.Log.Path, Path: cfg.Log.Path,
MaxSize: cfg.Log.MaxSize, MaxSize: cfg.Log.MaxSize,
@@ -81,7 +122,7 @@ func main() {
Compress: cfg.Log.Compress, Compress: cfg.Log.Compress,
}) })
if err != nil { if err != nil {
minimalLogger.Fatal("初始化日志失败", zap.Error(err)) return newStartupError(phaseLogger, fmt.Sprintf("初始化日志失败\n\n日志目录: %s\n\n请检查目录权限或磁盘空间", cfg.Log.Path), err)
} }
defer func() { defer func() {
if err := zapLogger.Sync(); err != nil { if err := zapLogger.Sync(); err != nil {
@@ -91,11 +132,17 @@ func main() {
cfg.PrintSummary(zapLogger) cfg.PrintSummary(zapLogger)
db, err := database.Init(&cfg.Database, zapLogger) db, err := desktopHooks.initDB(&cfg.Database, zapLogger)
if err != nil { 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) providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db) modelRepo := repository.NewModelRepository(db)
@@ -118,11 +165,8 @@ func main() {
statsService := service.NewStatsService(statsRepo, statsBuffer) statsService := service.NewStatsService(statsRepo, statsBuffer)
registry := conversion.NewMemoryRegistry() registry := conversion.NewMemoryRegistry()
if err := registry.Register(openai.NewAdapter()); err != nil { if err := desktopHooks.registerAdapters(registry); err != nil {
zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err)) return newStartupError(phaseAdapter, startupInternalErrorMessage(), err)
}
if err := registry.Register(anthropic.NewAdapter()); err != nil {
zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err))
} }
engine := conversion.NewConversionEngine(registry, zapLogger) engine := conversion.NewConversionEngine(registry, zapLogger)
@@ -144,7 +188,9 @@ func main() {
r.Use(middleware.CORS()) r.Use(middleware.CORS())
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler) setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
setupStaticFiles(r) if err := desktopHooks.setupStaticFiles(r); err != nil {
return newStartupError(phaseStaticResource, startupInternalErrorMessage(), err)
}
server = &http.Server{ server = &http.Server{
Addr: desktopListenAddr(port), Addr: desktopListenAddr(port),
@@ -154,26 +200,46 @@ func main() {
} }
shutdownCtx, shutdownCancel = context.WithCancel(context.Background()) 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() { go func() {
zapLogger.Info("AI Gateway 启动", logger.Info("AI Gateway 启动",
zap.String("addr", server.Addr), zap.String("addr", server.Addr),
zap.String("version", buildinfo.Version()), zap.String("version", buildinfo.Version()),
zap.String("commit", buildinfo.Commit()), zap.String("commit", buildinfo.Commit()),
zap.String("build_time", buildinfo.BuildTime())) zap.String("build_time", buildinfo.BuildTime()))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
zapLogger.Fatal("服务器启动失败", zap.Error(err)) 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, settingsHandler *handler.SettingsHandler) { func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
@@ -223,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() distFS, err := frontendDistFS()
if err != nil { if err != nil {
zapLogger.Fatal("无法加载前端资源", zap.Error(err)) return err
} }
setupStaticFilesWithFS(r, distFS) setupStaticFilesWithFS(r, distFS)
return nil
} }
func frontendDistFS() (fs.FS, error) { func frontendDistFS() (fs.FS, error) {
@@ -299,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() { func doShutdown() {
if zapLogger != nil { if zapLogger != nil {
zapLogger.Info("正在关闭服务器...") zapLogger.Info("正在关闭服务器...")
@@ -382,13 +408,12 @@ func desktopPortMenuTitle(port int) string {
return fmt.Sprintf("端口: %d", port) return fmt.Sprintf("端口: %d", port)
} }
func checkPortAvailable(port int) error { func listenDesktopPort(port int) (net.Listener, error) {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) return net.Listen("tcp", desktopListenAddr(port))
if err != nil { }
return fmt.Errorf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
} func desktopPortUnavailableMessage(port int) string {
ln.Close() return fmt.Sprintf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
return nil
} }
type SingletonLock struct { type SingletonLock struct {

View File

@@ -47,9 +47,15 @@ func TestMessageBoxW_WindowsOnly_FailureUsesReturnValue(t *testing.T) {
} }
func TestShowError_WindowsBranch(t *testing.T) { func TestShowError_WindowsBranch(t *testing.T) {
withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) { old := buildPromptChannels
return 0, syscall.Errno(5) 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() { defer func() {
if recovered := recover(); recovered != nil { if recovered := recover(); recovered != nil {
@@ -59,3 +65,42 @@ func TestShowError_WindowsBranch(t *testing.T) {
showError("测试错误", "这是一条测试错误消息") 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" "time"
) )
func TestCheckPortAvailable(t *testing.T) { func TestListenDesktopPortReturnsReusableListener(t *testing.T) {
port := 19826 listener, err := listenDesktopPort(0)
err := checkPortAvailable(port)
if err != nil { if err != nil {
t.Fatalf("端口 %d 应该可用: %v", port, err) t.Fatalf("listener-first 应直接获取配置端口 listener: %v", 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)
} }
defer listener.Close() 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} server := &http.Server{ReadHeaderTimeout: time.Second}
defer server.Close() done := make(chan struct{})
go func() { go func() {
defer close(done)
err := server.Serve(listener) err := server.Serve(listener)
if err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) { 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) if err := server.Close(); err != nil {
t.Fatalf("关闭测试 server 失败: %v", err)
listener.Close()
time.Sleep(100 * time.Millisecond)
err = checkPortAvailable(port)
if err != nil {
t.Fatalf("端口关闭后应该可用: %v", err)
} }
<-done
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("端口错误信息包含端口号测试通过")
} }
func TestGetDesktopConfigPath(t *testing.T) { 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

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

@@ -66,6 +66,63 @@ func TestDurationConversion(t *testing.T) {
assert.Equal(t, cfg.Database.ConnMaxLifetime, parsed) assert.Equal(t, cfg.Database.ConnMaxLifetime, parsed)
} }
func TestSaveConfigToPath_DurationFormat(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
cfg := DefaultConfig()
cfg.Server.ReadTimeout = 30 * time.Second
cfg.Server.WriteTimeout = 1 * time.Minute
cfg.Database.ConnMaxLifetime = 1 * time.Hour
err := SaveConfigToPath(cfg, configPath)
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, "conn_max_lifetime: 1h0m0s")
assert.Contains(t, content, "read_timeout: 30s")
assert.Contains(t, content, "write_timeout: 1m0s")
}
func TestSaveAndReload_DurationRoundTrip(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
yamlContent := `
server:
port: 9826
read_timeout: 30s
write_timeout: 1m
database:
driver: sqlite
path: ` + filepath.Join(dir, "test.db") + `
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 30m
log:
level: info
path: ` + filepath.Join(dir, "log") + `
max_size: 100
max_backups: 10
max_age: 30
compress: true
`
require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0o600))
cfg, err := LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 30*time.Minute, cfg.Database.ConnMaxLifetime)
err = SaveConfigToPath(cfg, configPath)
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Contains(t, string(data), "conn_max_lifetime: 30m0s")
}
func configToDTO(c *Config) struct { func configToDTO(c *Config) struct {
Server struct { Server struct {
Port int `json:"port"` Port int `json:"port"`

View File

@@ -2,6 +2,7 @@ package database
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -17,6 +18,8 @@ import (
pkglogger "nex/backend/pkg/logger" pkglogger "nex/backend/pkg/logger"
) )
var ErrMigration = errors.New("数据库迁移失败")
func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) { func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) {
moduleLogger := pkglogger.WithModule(zapLogger, "database") 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 { 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) configurePool(db, cfg, moduleLogger)

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -416,3 +417,94 @@ func TestSettingsHandler_SaveStartupSettings_InvalidJSON(t *testing.T) {
assert.Equal(t, 400, w.Code) assert.Equal(t, 400, w.Code)
} }
func TestSettingsHandler_GetStartupSettings_DurationNormalization(t *testing.T) {
cfg, configPath := createTestConfig(t)
yamlContent := `
server:
port: 9826
read_timeout: 30s
write_timeout: 1m
database:
driver: sqlite
path: ` + cfg.Database.Path + `
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 30m
log:
level: info
path: ` + cfg.Log.Path + `
max_size: 100
max_backups: 10
max_age: 30
compress: true
`
require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0o600))
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "1m0s", resp.Config.Server.WriteTimeout)
assert.Equal(t, "30m0s", resp.Config.Database.ConnMaxLifetime)
}
func TestSettingsHandler_SaveStartupSettings_StandardDurationRoundTrip(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
tmpDir := t.TempDir()
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "30s",
"write_timeout": "1m0s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": filepath.Join(tmpDir, "test.db"),
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h0m0s",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(tmpDir, "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "1m0s", resp.Config.Server.WriteTimeout)
assert.Equal(t, "1h0m0s", resp.Config.Database.ConnMaxLifetime)
savedCfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 1*time.Hour, savedCfg.Database.ConnMaxLifetime)
}

View File

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

View File

@@ -18,7 +18,7 @@ test.describe('侧边栏', () => {
test('应显示导航菜单项', async ({ page }) => { test('应显示导航菜单项', async ({ page }) => {
const aside = page.locator('aside') const aside = page.locator('aside')
await expect(aside.getByText('供应商管理')).toBeVisible() 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() await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible()
}) })
test('应能切换到用量统计', async ({ page }) => { test('应能切换到总览', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click() await page.locator('aside').getByText('总览').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible() await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
}) })
test('应能切换回供应商管理', async ({ page }) => { test('应能切换回供应商管理', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click() await page.locator('aside').getByText('总览').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible() await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
await page.locator('aside').getByText('供应商管理').click() await page.locator('aside').getByText('供应商管理').click()
await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible() await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible()
@@ -51,10 +51,10 @@ test.describe('页面导航', () => {
}) })
test('应在刷新后保持当前页面', async ({ page }) => { test('应在刷新后保持当前页面', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click() await page.locator('aside').getByText('总览').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible() await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
await page.reload() 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 }) => { test.beforeEach(async ({ page }) => {
await page.goto('/stats') await page.goto('/stats')
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible() await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
}) })
test('应显示正确的总请求量', async ({ page }) => { test('应显示正确的总请求量', async ({ page }) => {
@@ -99,7 +99,7 @@ test.describe('统计筛选', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/stats') await page.goto('/stats')
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible() await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
}) })
test('按供应商筛选', async ({ page }) => { test('按供应商筛选', async ({ page }) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,34 @@ import { useStartupSettings, useSaveStartupSettings } from '@/hooks/useSettings'
import type { StartupConfig } from '@/types' import type { StartupConfig } from '@/types'
import type { SubmitContext } from 'tdesign-react/es/form/type' import type { SubmitContext } from 'tdesign-react/es/form/type'
const DURATION_PLACEHOLDERS = { const TIMEOUT_OPTIONS = [
readTimeout: '例如 30s', { label: '5 秒', value: '5s' },
writeTimeout: '例如 30s', { label: '10 秒', value: '10s' },
connMaxLifetime: '例如 1h', { 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> { function flattenConfig(c: StartupConfig): Record<string, unknown> {
return { return {
@@ -140,11 +163,11 @@ export function StartupSettingsCard() {
<Form.FormItem label='端口' name='server.port' rules={[{ required: true, message: '请输入端口' }]}> <Form.FormItem label='端口' name='server.port' rules={[{ required: true, message: '请输入端口' }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} /> <InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.FormItem> </Form.FormItem>
<Form.FormItem label='读超时' name='server.readTimeout' rules={[{ required: true, message: '请输入读超时' }]}> <Form.FormItem label='读超时' name='server.readTimeout' rules={[{ required: true, message: '请选择读超时' }]}>
<Input placeholder={DURATION_PLACEHOLDERS.readTimeout} /> <Select options={TIMEOUT_OPTIONS} />
</Form.FormItem> </Form.FormItem>
<Form.FormItem label='写超时' name='server.writeTimeout' rules={[{ required: true, message: '请输入写超时' }]}> <Form.FormItem label='写超时' name='server.writeTimeout' rules={[{ required: true, message: '请选择写超时' }]}>
<Input placeholder={DURATION_PLACEHOLDERS.writeTimeout} /> <Select options={TIMEOUT_OPTIONS} />
</Form.FormItem> </Form.FormItem>
<Divider /> <Divider />
@@ -212,9 +235,9 @@ export function StartupSettingsCard() {
<Form.FormItem <Form.FormItem
label='连接最大生命周期' label='连接最大生命周期'
name='database.connMaxLifetime' name='database.connMaxLifetime'
rules={[{ required: true, message: '请输入连接最大生命周期' }]} rules={[{ required: true, message: '请选择连接最大生命周期' }]}
> >
<Input placeholder={DURATION_PLACEHOLDERS.connMaxLifetime} /> <Select options={CONN_LIFETIME_OPTIONS} />
</Form.FormItem> </Form.FormItem>
<Divider /> <Divider />
@@ -248,9 +271,9 @@ export function StartupSettingsCard() {
<Form.FormItem <Form.FormItem
label='最大保留天数' label='最大保留天数'
name='log.maxAge' name='log.maxAge'
rules={[{ required: true, message: '请输入最大保留天数' }]} rules={[{ required: true, message: '请选择最大保留天数' }]}
> >
<InputNumber min={0} suffix=' 天' style={{ width: '100%' }} /> <Select options={MAX_AGE_OPTIONS} />
</Form.FormItem> </Form.FormItem>
<Form.FormItem label='压缩旧日志' name='log.compress'> <Form.FormItem label='压缩旧日志' name='log.compress'>
<Switch /> <Switch />

View File

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

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

@@ -33,15 +33,38 @@
测试 workflow SHALL 将测试步骤拆分为 `check``mysql``e2e` 三个独立 job通过 `full` 参数和 `needs` 依赖控制执行。 测试 workflow SHALL 将测试步骤拆分为 `check``mysql``e2e` 三个独立 job通过 `full` 参数和 `needs` 依赖控制执行。
#### Scenario: check job始终执行 #### Scenario: check job始终执行,三平台 matrix
- **WHEN** 测试 workflow 被调用(无论 `full` 值) - **WHEN** 测试 workflow 被调用(无论 `full` 值)
- **THEN** `check` job SHALL 始终执行 - **THEN** `check` job SHALL 始终执行
- **THEN** SHALL 在 `check` job 内按顺序执行checkout含 LFS→ setup Go → setup Bun → `make lint``make test` - **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 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 覆盖 backend 核心测试、frontend Vitest 单元/组件测试、desktop 测试和 versionctl 测试
- **THEN** `make test` SHALL NOT 覆盖 MySQL 专项测试或 frontend E2E 测试 - **THEN** `make test` SHALL NOT 覆盖 MySQL 专项测试或 frontend E2E 测试
- **THEN** lint 或测试失败时 SHALL 阻止后续步骤执行 - **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 #### Scenario: mysql job仅 full=true
@@ -129,20 +152,48 @@
### Requirement: 测试 workflow 工具链依赖 ### Requirement: 测试 workflow 工具链依赖
测试 workflow SHALL 在单个 ubuntu runner 上准备完整的工具链环境。 测试 workflow SHALL 在各平台 runner 上准备完整的工具链环境。
#### Scenario: 工具链安装 #### Scenario: 工具链安装(三平台)
- **WHEN** 测试 workflow 开始执行 - **WHEN** 测试 workflow 开始执行
- **THEN** SHALL checkout 仓库代码并拉取 Git LFS 文件 - **THEN** 每个平台 SHALL checkout 仓库代码并拉取 Git LFS 文件
- **THEN** SHALL 安装 Go 工具链(使用 `go.work` 文件指定版本) - **THEN** 每个平台 SHALL 安装 Go 工具链(使用 `go.work` 文件指定版本)
- **THEN** SHALL 安装 Bun 运行时 - **THEN** 每个平台 SHALL 安装 Bun 运行时
- **THEN** Go 模块缓存 SHALL 覆盖 `backend/go.sum``versionctl/go.sum` - **THEN** Go 模块缓存 SHALL 覆盖 `backend/go.sum``versionctl/go.sum`
### Requirement: 测试 workflow 资源隔离 ### Requirement: 测试 workflow 资源隔离
测试 workflow 中的各测试步骤 SHALL 使用隔离的资源,不干扰主环境。 测试 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 临时资源隔离 #### Scenario: E2E 临时资源隔离
- **WHEN** E2E 测试运行 - **WHEN** E2E 测试运行

View File

@@ -569,3 +569,33 @@ server 入口 SHALL 允许前端查看当前有效启动参数,但 SHALL NOT
- **THEN** 后端 SHALL 返回禁止修改错误 - **THEN** 后端 SHALL 返回禁止修改错误
- **THEN** 后端 SHALL NOT 写入配置文件 - **THEN** 后端 SHALL NOT 写入配置文件
- **THEN** 后端 SHALL NOT 修改当前运行配置 - **THEN** 后端 SHALL NOT 修改当前运行配置
### Requirement: 启动参数 duration 标准格式
系统 SHALL 在启动参数设置查询、保存响应和配置文件保存中使用 Go `time.Duration.String()` 标准字符串表示 duration 字段,同时继续接受合法 Go duration 字符串作为输入。
#### Scenario: 查询启动参数时返回标准 duration 字符串
- **WHEN** 后端查询启动参数设置
- **THEN** 返回配置中的 `server.read_timeout``server.write_timeout``database.conn_max_lifetime` SHALL 使用 Go `time.Duration.String()` 标准字符串格式
- **THEN** 若配置文件中 `database.conn_max_lifetime``30m`,返回值 SHALL 为 `30m0s`
- **THEN** 若配置文件中 `database.conn_max_lifetime``1h`,返回值 SHALL 为 `1h0m0s`
#### Scenario: 保存启动参数时接受标准 duration 字符串
- **WHEN** desktop 入口收到包含 `database.conn_max_lifetime: 1h0m0s` 的有效启动参数保存请求
- **THEN** 后端 SHALL 成功解析该 duration 字符串
- **THEN** 后端 SHALL 将配置保存到默认配置文件
- **THEN** 保存响应中的 `database.conn_max_lifetime` SHALL 为 `1h0m0s`
#### Scenario: 保存启动参数时写入标准 duration 字符串
- **WHEN** desktop 入口成功保存启动参数配置
- **THEN** 写入配置文件的 `server.read_timeout``server.write_timeout``database.conn_max_lifetime` SHALL 使用 Go `time.Duration.String()` 标准字符串格式
- **THEN** 若保存请求中的 `database.conn_max_lifetime` 语义为 30 分钟,配置文件中的值 SHALL 为 `30m0s`
#### Scenario: 读取用户手写的合法 duration 字符串
- **WHEN** 配置文件中 duration 字段使用合法 Go duration 字符串,例如 `30m``30m0s`
- **THEN** 后端 SHALL 正确解析配置文件
- **THEN** 后端返回启动参数设置时 SHALL 将语义等价的 duration 统一为 Go `time.Duration.String()` 标准字符串

View File

@@ -6,6 +6,130 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
## Requirements ## 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: 桌面应用启动 ### Requirement: 桌面应用启动
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。 系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。
@@ -36,7 +160,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 系统托盘 ### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port` 系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`托盘初始化失败 SHALL 被视为 desktop fatal 启动失败。
#### Scenario: 托盘图标显示 #### Scenario: 托盘图标显示
@@ -60,11 +184,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **WHEN** 用户点击托盘菜单"打开管理界面" - **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:<server.port>` - **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
#### Scenario: 托盘 ready 后自动打开浏览器
- **WHEN** desktop 启动并完成托盘图标、菜单和点击事件初始化
- **THEN** 系统 SHALL 将托盘标记为 ready
- **THEN** 系统 SHALL 在托盘 ready 后自动打开 `http://localhost:<server.port>`
- **THEN** 系统 SHALL NOT 在托盘初始化失败时自动打开浏览器
#### Scenario: 浏览器打开失败 #### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等) - **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用 - **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:<server.port>` - **AND** 用户可手动访问 `http://localhost:<server.port>`
- **AND** 系统 SHALL 尝试显示非 fatal 提示,告知用户手动访问地址
#### Scenario: 退出应用 #### Scenario: 退出应用
@@ -73,6 +205,13 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **AND** 托盘图标消失 - **AND** 托盘图标消失
- **AND** 应用进程退出 - **AND** 应用进程退出
#### Scenario: 托盘初始化失败
- **WHEN** desktop 启动时托盘未在限定时间内 ready、托盘图标资源无法加载或托盘菜单无法完成初始化
- **THEN** 系统 SHALL 生成 `tray` 阶段启动错误
- **THEN** 系统 SHALL 通过启动失败提示降级链提示用户
- **THEN** 应用 SHALL 退出
### Requirement: 静态文件服务 ### Requirement: 静态文件服务
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。 系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
@@ -126,19 +265,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 端口冲突检测 ### Requirement: 端口冲突检测
系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用 系统 SHALL 在启动时获取启动配置中的 `server.port` 对应监听端口,并使用同一个 listener 启动后端服务,避免端口预检测与实际监听之间的竞态
#### Scenario: 配置端口可用 #### Scenario: 配置端口可用
- **WHEN** 启动配置中的 `server.port` 未被占用 - **WHEN** 启动配置中的 `server.port` 未被占用
- **THEN** 服务正常启动 - **THEN** 系统 SHALL 成功创建该端口的 listener
- **THEN** 后端服务 SHALL 使用该 listener 正常启动
#### Scenario: 配置端口被占用 #### Scenario: 配置端口被占用
- **WHEN** 启动配置中的 `server.port` 已被其他程序占用 - **WHEN** 启动配置中的 `server.port` 已被其他程序占用
- **THEN** 显示错误提示"端口 <server.port> 已被占用" - **THEN** 系统 SHALL 生成 `port` 阶段启动错误
- **THEN** 错误提示 SHALL 包含"端口 <server.port> 已被占用"
- **AND** 应用退出 - **AND** 应用退出
#### Scenario: 单实例优先于端口监听
- **WHEN** 用户尝试启动第二个 desktop 实例
- **THEN** 系统 SHALL 优先检测到已有实例持有文件锁
- **THEN** 系统 SHALL 显示"已有 Nex 实例运行"相关错误提示
- **THEN** 系统 SHALL NOT 将该场景误报为端口占用
### Requirement: 桌面配置源隔离和启动快照 ### Requirement: 桌面配置源隔离和启动快照
desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。 desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。
@@ -312,74 +460,52 @@ desktop 内嵌前端 SHALL 使用同源相对路径访问后端 API不主动
### Requirement: Windows 原生对话框 ### 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: 错误提示对话框 #### Scenario: 错误提示对话框
- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等) - **WHEN** Windows 系统通知不可用或返回失败
- **THEN** 使用 `MessageBoxW` 显示模态对话框 - **THEN** 使用 `MessageBoxW` 显示模态对话框
- **AND** 对话框标题栏显示应用名称 - **AND** 对话框标题栏显示应用名称
- **AND** 对话框包含错误描述文本 - **AND** 对话框包含错误描述文本
- **AND** 对话框显示错误图标MB_ICONERROR - **AND** 对话框显示错误图标MB_ICONERROR
- **AND** 对话框 SHALL 尽量置于前台或顶层显示
#### Scenario: Windows UI 提示均失败
- **WHEN** Windows 系统通知和 `MessageBoxW` 均失败
- **THEN** 系统 SHALL 记录提示失败原因
- **THEN** 系统 SHALL 降级输出到可用启动日志
#### Scenario: 非 Windows 平台不受影响 #### Scenario: 非 Windows 平台不受影响
- **WHEN** 应用运行在 macOS 或 Linux 上 - **WHEN** 应用运行在 macOS 或 Linux 上
- **THEN** 错误对话框仍使用平台原有实现osascript / zenity - **THEN** 错误提示 SHALL 使用对应平台的通知和弹窗降级策略
### 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** 后续对话框调用直接使用缓存结果,不重复检测
### Requirement: macOS AppleScript 字符转义 ### Requirement: macOS AppleScript 字符转义
系统 SHALL 对 AppleScript 对话框中的特殊字符进行转义,确保脚本正确执行。 系统 SHALL 对 AppleScript 通知、模态告警和对话框中的特殊字符进行转义,确保脚本正确执行。
#### Scenario: 转义反斜杠 #### Scenario: 转义反斜杠
- **WHEN** 对话框消息包含反斜杠字符 `\`
- **WHEN** AppleScript 提示文本包含反斜杠字符 `\`
- **THEN** 转义为 `\\` - **THEN** 转义为 `\\`
#### Scenario: 转义双引号 #### Scenario: 转义双引号
- **WHEN** 对话框消息包含双引号字符 `"`
- **WHEN** AppleScript 提示文本包含双引号字符 `"`
- **THEN** 转义为 `\"` - **THEN** 转义为 `\"`
#### Scenario: 多行文本处理 #### Scenario: 多行文本处理
- **WHEN** 对话框消息包含换行符 `\n`
- **THEN** AppleScript 正确显示多行文本 - **WHEN** AppleScript 提示文本包含换行符 `\n`
- **THEN** AppleScript 通知或模态告警 SHALL 正确显示多行文本或保留可读换行语义
### Requirement: 桌面应用打包迁移资源 ### Requirement: 桌面应用打包迁移资源

View File

@@ -2,7 +2,7 @@
## Purpose ## Purpose
TBD - 提供供应商、模型配置和用量统计的前端管理界面 TBD - 提供供应商、模型配置和总览的前端管理界面
## Requirements ## Requirements
@@ -408,6 +408,25 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** 前端 SHALL NOT 展示配置来源标签 - **THEN** 前端 SHALL NOT 展示配置来源标签
- **THEN** 前端 SHALL 直接展示 `database.password` 字段值 - **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: 数据库驱动表单切换 #### Scenario: 数据库驱动表单切换
- **WHEN** 启动参数设置中的 `database.driver``sqlite` - **WHEN** 启动参数设置中的 `database.driver``sqlite`
@@ -489,7 +508,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex` - **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称 - **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
- **THEN** 侧边栏 SHALL 包含导航菜单 - **THEN** 侧边栏 SHALL 包含导航菜单
- **THEN** 导航菜单项 SHALL 包含供应商管理ServerIcon 图标、用量统计ChartLineIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标) - **THEN** 导航菜单项 SHALL 按以下顺序包含总览ChartLineIcon 图标、供应商管理ServerIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
#### Scenario: 侧边栏折叠品牌显示 #### Scenario: 侧边栏折叠品牌显示
@@ -502,7 +521,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户点击导航中的"供应商管理" - **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项 - **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计" - **WHEN** 用户点击导航中的"总览"
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项 - **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"设置" - **WHEN** 用户点击导航中的"设置"
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项 - **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
@@ -518,10 +537,10 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 应用启动 - **WHEN** 应用启动
- **THEN** 前端 SHALL 使用 React Router v7 Library 模式BrowserRouter - **THEN** 前端 SHALL 使用 React Router v7 Library 模式BrowserRouter
- **THEN** `/providers` 路径 SHALL 显示供应商管理页面 - **THEN** `/providers` 路径 SHALL 显示供应商管理页面
- **THEN** `/stats` 路径 SHALL 显示用量统计页面 - **THEN** `/stats` 路径 SHALL 显示总览页面
- **THEN** `/settings` 路径 SHALL 显示设置页面 - **THEN** `/settings` 路径 SHALL 显示设置页面
- **THEN** `/about` 路径 SHALL 显示关于页面 - **THEN** `/about` 路径 SHALL 显示关于页面
- **THEN** `/` 路径 SHALL 重定向到 `/providers` - **THEN** `/` 路径 SHALL 重定向到 `/stats`
- **THEN** 不存在的路径 SHALL 显示 404 页面 - **THEN** 不存在的路径 SHALL 显示 404 页面
#### Scenario: 路由级懒加载 #### Scenario: 路由级懒加载
@@ -535,7 +554,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户点击导航中的"供应商管理" - **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项 - **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计" - **WHEN** 用户点击导航中的"总览"
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项 - **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
#### Scenario: URL 同步 #### Scenario: URL 同步

View File

@@ -351,3 +351,34 @@
- **WHEN** 测试配置加载时指定不存在的配置文件路径 - **WHEN** 测试配置加载时指定不存在的配置文件路径
- **THEN** SHALL 返回默认配置值,不自动创建配置文件 - **THEN** SHALL 返回默认配置值,不自动创建配置文件
- **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 验证应用启动流程不会因浏览器打开失败退出