1
0

31 Commits

Author SHA1 Message Date
e4c96da8a9 fix: CI 触发分支对齐仓库实际默认分支 master 2026-05-07 14:20:29 +08:00
1195e119c6 chore: 合并 dev-ci-optimize 至 master 2026-05-07 14:17:43 +08:00
4eeb14e844 feat: 新增启动参数设置页面,区分 desktop 可编辑与 server 只读 2026-05-07 14:10:56 +08:00
0d30ed9a0f feat: 新增开发 CI 流程,重构 test.yml 支持分层测试
新增 ci.yml,在 push(dev/main)和 PR 时触发快速检查(lint + 全量测试)
重构 test.yml,新增 full 参数控制是否运行 MySQL 和 E2E 测试
release.yml 调用 test.yml 时传 full: true,行为与重构前一致
同步更新 ci-test-gate spec
2026-05-07 12:43:08 +08:00
cd0b3e8fc1 feat: release CI 加入全流程测试门禁
新增独立可复用测试 workflow(test.yml),在 release 构建前串行执行
lint、默认测试、MySQL 测试和 E2E 测试,测试不通过则阻止发布构建。
2026-05-07 12:14:00 +08:00
c04a13bf8a refactor: 重写 Git hooks 体系,委托已有检查、新增模板与 LFS 校验
pre-commit 代码检查改为委托 _backend-lint / _versionctl-lint / _frontend-check,新增 LFS 指针校验;commit-msg 新增多行空行格式校验和模板注释忽略,移除 CJK/Python 字符集检测;新增 prepare-commit-msg 提交信息模板;hooks-install 增加 source 文件存在性校验;前端 check 补入 tsc -b 类型检查并修复暴露的类型错误
2026-05-06 13:44:28 +08:00
5513f0c13d feat: 区分 server 与 desktop 配置加载入口,取消自动创建配置文件
- config.go 重构:抽取 loadConfig 共享逻辑,新增 LoadServerConfig/LoadDesktopConfig/LoadDesktopConfigAtPath,LoadConfig 保持向后兼容
- setupConfigFile 移除 SafeWriteConfigAs 自动创建逻辑,文件不存在时仅使用默认值
- cmd/desktop 切换为 LoadDesktopConfig,端口/HTTP/浏览器/托盘统一使用 cfg.Server.Port
- cmd/server 显式使用 LoadServerConfig 明确入口语义
- 提取 desktop 可测 helper:desktopListenAddr/desktopURL/desktopPortMenuTitle/desktopConfigErrorMessage
- 新增测试:desktop 忽略 CLI/env/未知参数、配置快照不变、无效配置文件不静默回退、端口 helper 一致性
- README 区分 server/desktop 配置源,移除首次启动自动创建配置文件描述
- 同步 delta specs 到 openspec/specs/ 主规范
2026-05-06 11:59:19 +08:00
598e2acb7e feat: 供应商列表 Base URL、API Key 和模型列表统一模型 ID 增加一键复制按钮 2026-05-06 00:43:48 +08:00
4870d29638 fix: pre-commit Go lint 按包目录分组执行,修复测试文件 typecheck 失败
将逐文件 lint 改为按包目录去重分组,同包的 _test.go 与被测文件在同一轮
typecheck 中参与分析,避免 undefined 错误。
2026-05-05 23:52:43 +08:00
8600a39b6c fix: 发布产物自包含数据库迁移资源,修复 macOS DMG 安装后无法启动
使用 go:embed 嵌入迁移 SQL 到二进制,移除 runtime.Caller 源码路径依赖,
server 和 desktop 发布产物均可在无源码目录环境下完成数据库初始化和迁移。
2026-05-05 23:47:58 +08:00
407d008e19 chore: 版本升迁 v0.1.7 2026-05-05 22:00:04 +08:00
a2751eab31 feat: 原生 Git hooks 方案,增强版本升迁工作流 2026-05-05 21:58:30 +08:00
5655fc5560 chore: 移除 Windows arm64 构建与发布支持
Windows ARM64 使用场景极少,windows-11-arm runner 上 MSYS2
CLANGARM64 交叉编译不稳定,CGO 编译问题难以排查,维护成本
远超收益。移除 arm64 的 CI 矩阵条目、Makefile Windows 变量、
versionctl 资产白名单、README 文档和规范中的相关需求。
Linux 和 macOS arm64 不受影响。
2026-05-05 20:20:04 +08:00
49b47a1ae0 chore: 版本升迁 v0.1.6 2026-05-05 19:31:21 +08:00
bcf82d42bc fix: 修复 Windows arm64 可执行文件图标嵌入构建失败
MSYS2 CLANGARM64 环境下使用 llvm-windres,需使用 LLVM target triple
(aarch64-w64-mingw32) 而非 GNU BFD 格式 (pe-aarch64)。新增双格式变量
并增强 windres 检测逻辑,通过 --version 输出区分 GNU/LLVM 工具。
2026-05-05 19:30:22 +08:00
394025c8ea chore: 版本升迁 v0.1.5 2026-05-05 18:59:09 +08:00
34bd749741 fix: 修复发布流水线 Windows arm64 CGO 工具链和 macOS 磁盘空间问题
- Windows arm64: 在 workflow matrix 中设置 CC=clang/CXX=clang++ 并注入构建环境
- macOS: 在 Makefile 关键节点清理中间产物和临时目录释放磁盘空间
- 预检步骤改用 $CC/$CXX 替代硬编码编译器名称,与 matrix 声明保持一致
- 同步新增 release-pipeline spec 需求: Windows arm64 CGO 编译器指定
2026-05-05 18:58:50 +08:00
290f299e22 chore: 版本升迁 v0.1.4 2026-05-05 13:15:45 +08:00
859dec8ada fix: 修复 Windows arm64 发布构建 CI 失败
将 Windows arm64 构建从 x86_64 runner 上的交叉编译改为使用 windows-11-arm
原生 ARM64 runner,消除 CLANGARM64 环境在 x86_64 上的 Exec format error。
同时去掉 Linux/Windows 构建步骤中冗余的 TARGET_ARCH 显式传参,统一依赖
Makefile 中 go env GOARCH 自动检测。
2026-05-05 13:15:00 +08:00
993c0a72d6 chore: 版本升迁 v0.1.3 2026-05-05 12:38:12 +08:00
c9c3a84b33 feat: 扩展发布打包支持多组件多架构多格式产物
- 新增 web 组件独立发布为 nex-web_<version>.tar.gz
- server 新增 arm64 架构、macOS universal、Windows arm64 产物
- desktop 新增 arm64 架构支持(Linux/Windows)
- Linux desktop 新增 AppImage、deb、rpm 安装包格式
- macOS desktop 新增 unsigned DMG 安装包
- 统一发布资产命名为 {component}_{version}_{platform}_{arch}.{ext}
- 新增 SHA256SUMS 校验和清单覆盖全部发布资产
- versionctl 新增 asset-name CLI 支持按参数生成资产文件名
- Makefile release target 重构为组件/平台/架构参数化
- GitHub Actions release workflow 扩展多组件多架构构建矩阵
- 同步更新 openspec 主规范(desktop-app/release-pipeline/workspace-command-flows)
2026-05-05 12:36:33 +08:00
6de7a2d2e1 chore: 版本升迁 v0.1.2 2026-05-05 09:58:08 +08:00
6181923d8d fix: 修复发布流水线 LFS 资产校验 2026-05-05 09:57:02 +08:00
235efb0e62 chore: 版本升迁 v0.1.1 2026-05-05 04:39:31 +08:00
6b1af27ea2 fix: 移除 version-bump 的工作区干净检查 2026-05-05 04:39:21 +08:00
32f48777f3 feat: make version-bump 默认 BUMP=patch,无需显式传参 2026-05-05 04:32:38 +08:00
bc7a7c6e81 feat: 迁移 versionctl 为独立模块并新增 make version-bump 命令
- 将 backend/cmd/versionctl 和 backend/pkg/projectversion 迁移至独立 versionctl/ Go 模块
- 新增 bump 子命令支持 major/minor/patch 和指定版本号,含版本倒退防护
- 新增 make version-bump 编排完整升迁流程(bump + sync + check + commit + tag)
- 更新所有引用路径:根 Makefile、backend/Makefile、release.yml、.golangci.yml
- 新增 versionctl/.golangci.yml(精简配置)和 Makefile(lint/test/coverage)
- 根 Makefile lint/test 集成 versionctl 模块
- 同步 openspec specs:新增 version-bump spec,更新 release-pipeline spec
2026-05-05 04:18:10 +08:00
3cd0458c2c fix: 修复 golangci-lint 报告的 gosec/gocyclo/forbidigo 问题 2026-05-05 03:35:20 +08:00
8eea30ea11 feat: 统一品牌标识、关于页面三卡片布局与版本诊断功能
- 统一品牌为 Nex:侧边栏、托盘 tooltip、HTML 标题、favicon (PNG 替代 SVG)
- 重构关于页面为三卡片布局(品牌/版本/链接),版本状态 Tag 绝对定位右上角
- 新增 GET /api/version 后端接口,返回 version/commit/build_time
- 新增前端版本一致性诊断:匹配/不匹配/不可判断三种状态
- 同步 delta specs 到主 specs 并归档变更
2026-05-05 03:28:22 +08:00
9e33e570af fix: 降低请求生命周期日志级别 2026-05-05 01:54:53 +08:00
7653385838 fix: 加固发布流水线运行环境
修复 Windows 发布作业在 MSYS2 环境下无法访问 Go 工具链的问题。

为三平台发布增加工具链预检并升级 release workflow 运行时兼容性,减少版本检查噪音和 CI 告警。
2026-05-05 01:27:38 +08:00
110 changed files with 6952 additions and 695 deletions

3
.gitattributes vendored
View File

@@ -1,8 +1,9 @@
* text=auto eol=lf
assets/*.png filter=lfs diff=lfs merge=lfs -text
assets/**/*.png filter=lfs diff=lfs merge=lfs -text
assets/*.icns filter=lfs diff=lfs merge=lfs -text
assets/**/*.icns filter=lfs diff=lfs merge=lfs -text
assets/*.ico filter=lfs diff=lfs merge=lfs -text
assets/**/*.ico filter=lfs diff=lfs merge=lfs -text
frontend/public/*.png filter=lfs diff=lfs merge=lfs -text
frontend/public/**/*.png filter=lfs diff=lfs merge=lfs -text

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

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

View File

@@ -18,42 +18,129 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Verify tag and VERSION
id: version
run: |
version=$(go run ./backend/cmd/versionctl print)
go run ./backend/cmd/versionctl verify-tag "${GITHUB_REF_NAME}"
version=$(go run ./versionctl print)
go run ./versionctl verify-tag "${GITHUB_REF_NAME}"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
build-linux:
name: Build Linux Assets
test-gate:
name: Test Gate
needs: prepare
uses: ./.github/workflows/test.yml
with:
full: true
build-web:
name: Build Web Asset
needs: [prepare, test-gate]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Linux desktop build dependencies
- name: Preflight web release toolchain
run: |
set -euo pipefail
command -v go
go version
command -v bun
bun --version
make release-assets-check
- name: Build web release asset
run: make release-assets-web
- name: Upload web release asset
uses: actions/upload-artifact@v4
with:
name: release-web
path: build/release/*
if-no-files-found: error
build-linux:
name: Build Linux ${{ matrix.arch }} Assets
needs: [prepare, test-gate]
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Linux desktop and package dependencies
run: |
sudo apt-get update
sudo apt-get install -y libayatana-appindicator3-dev libgtk-3-dev
sudo apt-get install -y curl file libayatana-appindicator3-dev libgtk-3-dev rpm
- name: Preflight Linux release toolchain
run: |
set -euo pipefail
printf 'runner arch: %s\n' "$(uname -m)"
command -v go
go version
command -v bun
bun --version
command -v gcc
gcc --version
command -v pkg-config
pkg-config --modversion ayatana-appindicator3-0.1
pkg-config --modversion gtk+-3.0
command -v curl
command -v dpkg-deb
dpkg-deb --version
command -v rpmbuild
rpmbuild --version
make release-assets-check
- name: Build Linux release assets
run: make release-assets-linux
@@ -61,23 +148,41 @@ jobs:
- name: Upload Linux release assets
uses: actions/upload-artifact@v4
with:
name: release-linux
name: release-linux-${{ matrix.arch }}
path: build/release/*
if-no-files-found: error
build-windows:
name: Build Windows Assets
needs: prepare
runs-on: windows-latest
name: Build Windows ${{ matrix.arch }} Assets
needs: [prepare, test-gate]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: windows-latest
msystem: MINGW64
cc: gcc
cxx: g++
packages: >-
make
mingw-w64-x86_64-gcc
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -85,39 +190,89 @@ jobs:
- name: Setup MSYS2 toolchain
uses: msys2/setup-msys2@v2
with:
msystem: ${{ matrix.msystem }}
path-type: inherit
update: true
install: >-
make
mingw-w64-x86_64-gcc
install: ${{ matrix.packages }}
- name: Preflight Windows release toolchain
shell: msys2 {0}
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: |
set -euo pipefail
command -v go
go version
command -v bun
bun --version
command -v make
make --version
command -v "$CC"
"$CC" --version
command -v "$CXX"
"$CXX" --version
command -v windres
windres --version
if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
else
command -v powershell
powershell -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
fi
make release-assets-check
- name: Build Windows release assets
shell: msys2 {0}
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: make release-assets-windows
- name: Upload Windows release assets
uses: actions/upload-artifact@v4
with:
name: release-windows
name: release-windows-${{ matrix.arch }}
path: build/release/*
if-no-files-found: error
build-macos:
name: Build macOS Assets
needs: prepare
runs-on: macos-latest
needs: [prepare, test-gate]
runs-on: macos-15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Preflight macOS release toolchain
run: |
set -euo pipefail
printf 'runner arch: %s\n' "$(uname -m)"
command -v go
go version
command -v bun
bun --version
command -v ditto
command -v hdiutil
xcrun --find lipo
xcrun --find vtool
make release-assets-check
- name: Build macOS release assets
run: make release-assets-macos
@@ -126,14 +281,18 @@ jobs:
with:
name: release-macos
path: build/release/*
if-no-files-found: error
draft-release:
name: Create Draft Release
needs: [prepare, build-linux, build-windows, build-macos]
needs: [prepare, build-web, build-linux, build-windows, build-macos]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download release assets
uses: actions/download-artifact@v4
with:
@@ -141,6 +300,9 @@ jobs:
merge-multiple: true
path: dist
- name: Generate checksums
run: make release-assets-checksums RELEASE_DIR=dist
- name: Publish draft release
uses: softprops/action-gh-release@v2
with:

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

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

3
.gitignore vendored
View File

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

444
Makefile
View File

@@ -1,51 +1,189 @@
.PHONY: \
lint test clean \
version-sync version-check \
lint test clean hooks-install hooks-check hooks-test \
version-sync version-check version-bump \
server-run server-build server-lint server-test server-clean \
desktop-build-mac desktop-build-win desktop-build-linux \
desktop-lint desktop-test desktop-clean \
release-assets-linux release-assets-windows release-assets-macos \
release-assets-check release-assets-web release-assets-linux release-assets-windows release-assets-macos release-assets-checksums \
release-assets-server-linux release-assets-server-windows release-assets-server-macos \
release-assets-desktop-linux release-assets-desktop-windows release-assets-desktop-macos \
_backend-lint _backend-test _backend-clean _backend-build \
_versionctl-lint _versionctl-test \
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \
_hooks-pre-commit _check-clean-worktree \
_desktop-test _desktop-clean _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource \
_server-run-backend _server-run-frontend
_server-run-backend _server-run-frontend \
_check-linux-target-arch _check-windows-target-arch _ensure-appimagetool \
_package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm \
_package-macos-zip _package-macos-dmg
VERSION := $(shell go run ./backend/cmd/versionctl print)
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
GO_LDFLAGS := -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
GO_LDFLAGS_WIN := $(GO_LDFLAGS) -H=windowsgui
RELEASE_DIR := build/release
SERVER_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server linux amd64)
SERVER_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server windows amd64)
SERVER_DARWIN_AMD64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin amd64)
SERVER_DARWIN_ARM64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin arm64)
DESKTOP_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop linux)
DESKTOP_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop windows)
DESKTOP_MACOS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop macos)
# Delay shell lookups until a target needs them, then cache the result for this make run.
lazy_shell = $(or $($(1)),$(eval $(1) := $(shell $(2)))$($(1)))
VERSION = $(call lazy_shell,_VERSION,go run ./versionctl print)
GIT_COMMIT ?= $(call lazy_shell,_GIT_COMMIT,git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
BUILD_TIME ?= $(call lazy_shell,_BUILD_TIME,date -u +"%Y-%m-%dT%H:%M:%SZ")
TARGET_ARCH ?= $(call lazy_shell,_TARGET_ARCH,go env GOARCH)
GO_LDFLAGS = -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
GO_LDFLAGS_WIN = $(GO_LDFLAGS) -H=windowsgui
RELEASE_DIR ?= build/release
LINUX_DESKTOP_BINARY = build/nex-linux-$(TARGET_ARCH)
WINDOWS_DESKTOP_BINARY = build/nex-win-$(TARGET_ARCH).exe
WINDOWS_SERVER_BINARY = build/nex-server-windows-$(TARGET_ARCH).exe
WINDRES ?= windres
ifeq ($(TARGET_ARCH),arm64)
APPIMAGE_ARCH := aarch64
DEB_ARCH := arm64
RPM_ARCH := aarch64
else
APPIMAGE_ARCH := x86_64
DEB_ARCH := amd64
RPM_ARCH := x86_64
endif
WINDOWS_WINDRES_FORMAT_BFD := pe-x86-64
WINDOWS_WINDRES_FORMAT_LLVM := x86_64-w64-mingw32
WINDOWS_RESOURCE := rsrc_windows_amd64.syso
APPIMAGETOOL_PATH := build/tools/appimagetool-$(APPIMAGE_ARCH).AppImage
APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage
APPIMAGETOOL ?= $(APPIMAGETOOL_PATH)
# ============================================
# 全局命令
# ============================================
lint: _backend-lint _frontend-check
lint: _backend-lint _frontend-check _versionctl-lint
@printf 'Lint complete\n'
test: _backend-test _frontend-test _desktop-test
test: _backend-test _frontend-test _desktop-test _versionctl-test
@printf 'All tests passed\n'
clean: _backend-clean _frontend-clean _desktop-clean
@printf 'Clean complete\n'
# ============================================
# Git hooks
# ============================================
hooks-install:
@hooks_dir=$$(git rev-parse --git-path hooks); \
mkdir -p "$$hooks_dir"; \
for hook in pre-commit commit-msg prepare-commit-msg; do \
src="scripts/git-hooks/$$hook"; \
if [ ! -f "$$src" ]; then \
printf 'ERROR: source hook not found: %s\n' "$$src" >&2; \
exit 1; \
fi; \
cp "$$src" "$$hooks_dir/$$hook"; \
chmod +x "$$hooks_dir/$$hook"; \
done; \
printf 'Installed Git hooks to %s\n' "$$hooks_dir"
hooks-check:
@hooks_dir=$$(git rev-parse --git-path hooks); \
status=0; \
for hook in pre-commit commit-msg prepare-commit-msg; do \
if [ -x "$$hooks_dir/$$hook" ]; then \
printf 'OK: %s\n' "$$hook"; \
else \
printf 'MISSING: %s (%s/%s)\n' "$$hook" "$$hooks_dir" "$$hook"; \
status=1; \
fi; \
done; \
exit $$status
hooks-test:
@scripts/git-hooks/test-hooks.sh
_hooks-pre-commit:
@set -ef; \
staged_files=$$(git diff --cached --name-only --diff-filter=ACM); \
if [ -z "$$staged_files" ]; then \
printf 'No staged files to check\n'; \
exit 0; \
fi; \
run_backend_lint=; \
run_versionctl_lint=; \
run_frontend_check=; \
lfs_patterns=$$(grep 'filter=lfs' .gitattributes 2>/dev/null | awk '{print $$1}' || true); \
for file in $$staged_files; do \
[ -n "$$file" ] || continue; \
if git show ":$$file" 2>/dev/null | grep -Eq '^(<<<<<<<|=======|>>>>>>>)'; then \
printf 'Found conflict markers in staged file: %s\n' "$$file" >&2; \
printf 'Resolve conflict markers before committing.\n' >&2; \
exit 1; \
fi; \
size=$$(git cat-file -s ":$$file" 2>/dev/null || printf '0'); \
if [ "$$size" -gt 512000 ] 2>/dev/null; then \
if git show ":$$file" 2>/dev/null | LC_ALL=C grep -Iq .; then \
printf 'Warning: large staged text file (%s bytes): %s\n' "$$size" "$$file" >&2; \
fi; \
fi; \
if [ -n "$$lfs_patterns" ]; then \
for lfs_pat in $$lfs_patterns; do \
case "$$file" in $$lfs_pat) \
content=$$(git show ":$$file" 2>/dev/null | head -1); \
case "$$content" in \
"version https://git-lfs.github.com/spec/v1"*) ;; \
*) \
printf 'LFS-tracked file not using LFS pointer: %s\n' "$$file" >&2; \
printf 'Run "git lfs install" and re-add this file.\n' >&2; \
exit 1; \
;; \
esac; \
break; \
;; \
esac; \
done; \
fi; \
case "$$file" in \
backend/*.go) run_backend_lint=1 ;; \
versionctl/*.go) run_versionctl_lint=1 ;; \
frontend/*.ts|frontend/*.tsx|frontend/*.scss) run_frontend_check=1 ;; \
esac; \
done; \
if [ -n "$$run_backend_lint" ]; then \
printf 'Running backend lint...\n'; \
$(MAKE) _backend-lint; \
fi; \
if [ -n "$$run_versionctl_lint" ]; then \
printf 'Running versionctl lint...\n'; \
$(MAKE) _versionctl-lint; \
fi; \
if [ -n "$$run_frontend_check" ]; then \
printf 'Running frontend check...\n'; \
$(MAKE) _frontend-check; \
fi; \
printf 'Pre-commit checks passed\n'
# ============================================
# 版本管理
# ============================================
version-sync:
go run ./backend/cmd/versionctl sync
go run ./versionctl sync
version-check:
go run ./backend/cmd/versionctl check
go run ./versionctl check
version-bump: BUMP ?= patch
version-bump: lint test _check-clean-worktree
@set -e; \
bump_arg="$(if $(SET_VERSION),$(SET_VERSION),$(BUMP))"; \
new_version=$$(go run ./versionctl bump "$$bump_arg"); \
git add VERSION frontend/; \
git commit -m "chore: 版本升迁 v$$new_version"; \
git tag "v$$new_version"; \
printf '版本升迁完成: v%s\n' "$$new_version"
_check-clean-worktree:
@if [ -n "$$(git status --porcelain)" ]; then \
printf '工作区不干净,请先提交或清理改动后再执行版本升迁。\n' >&2; \
git status --short; \
exit 1; \
fi
# ============================================
# Server 模式
@@ -81,37 +219,37 @@ desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embe
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-arm64 ./cmd/desktop
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./cmd/desktop
lipo -create build/nex-mac-arm64 build/nex-mac-amd64 -output build/nex-mac-universal
lipo -info build/nex-mac-universal | grep -q 'x86_64 arm64'
rm -f build/nex-mac-arm64 build/nex-mac-amd64
@printf 'Packaging macOS app bundle...\n'
rm -rf build/Nex.app
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
cp build/nex-mac-universal build/Nex.app/Contents/MacOS/nex
@if [ -f assets/icon.icns ]; then \
cp assets/icon.icns build/Nex.app/Contents/Resources/; \
else \
printf 'Missing assets/icon.icns\n'; \
exit 1; \
fi
@MIN_MACOS_VERSION=$$(vtool -show-build build/nex-mac-universal | awk '/minos / {print $$2; exit}'); \
if [ -z "$$MIN_MACOS_VERSION" ]; then \
printf 'Unable to read macOS minimum version\n'; \
exit 1; \
fi; \
go run ./backend/cmd/versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
go run ./versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
chmod +x build/Nex.app/Contents/MacOS/nex
@printf 'macOS desktop build complete\n'
desktop-build-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource
@printf 'Building Windows desktop...\n'
ifeq ($(OS),Windows_NT)
powershell -NoProfile -Command "New-Item -ItemType Directory -Path 'build' -Force | Out-Null"
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop
else
desktop-build-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource _check-windows-target-arch
@printf 'Building Windows desktop $(TARGET_ARCH)...\n'
mkdir -p build
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop
endif
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../$(WINDOWS_DESKTOP_BINARY) ./cmd/desktop
@printf 'Windows desktop build complete\n'
desktop-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs
@printf 'Building Linux desktop...\n'
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-linux-amd64 ./cmd/desktop
desktop-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _check-linux-target-arch
@printf 'Building Linux desktop $(TARGET_ARCH)...\n'
mkdir -p build
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(LINUX_DESKTOP_BINARY) ./cmd/desktop
@printf 'Linux desktop build complete\n'
desktop-lint: _backend-lint _frontend-check
@@ -131,71 +269,215 @@ _desktop-clean:
_desktop-prepare-frontend: _frontend-install
@printf 'Preparing frontend for desktop...\n'
ifeq ($(OS),Windows_NT)
powershell -NoProfile -Command "Copy-Item -LiteralPath 'frontend/.env.desktop' -Destination 'frontend/.env.production.local' -Force"
cd frontend && bun run build
powershell -NoProfile -Command "Remove-Item -LiteralPath 'frontend/.env.production.local' -Force -ErrorAction SilentlyContinue"
else
cd frontend && cp .env.desktop .env.production.local
cd frontend && bun run build
rm -f frontend/.env.production.local
endif
_desktop-prepare-embedfs:
@printf 'Preparing embedded filesystem...\n'
ifeq ($(OS),Windows_NT)
powershell -NoProfile -Command "Remove-Item -LiteralPath 'embedfs/assets' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath 'embedfs/frontend-dist' -Recurse -Force -ErrorAction SilentlyContinue; Copy-Item -LiteralPath 'assets' -Destination 'embedfs/assets' -Recurse; Copy-Item -LiteralPath 'frontend/dist' -Destination 'embedfs/frontend-dist' -Recurse"
else
rm -rf embedfs/assets embedfs/frontend-dist
cp -r assets embedfs/assets
cp -r frontend/dist embedfs/frontend-dist
endif
_desktop-prepare-windows-resource:
@printf 'Preparing Windows executable icon...\n'
ifeq ($(OS),Windows_NT)
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso
else
@if command -v x86_64-w64-mingw32-windres >/dev/null 2>&1; then \
cd backend/cmd/desktop && x86_64-w64-mingw32-windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \
elif command -v windres >/dev/null 2>&1; then \
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \
else \
printf 'Missing windres for Windows icon resource generation\n'; \
exit 1; \
fi
endif
_desktop-prepare-windows-resource: _check-windows-target-arch
@printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n'
@WINDRES_CMD="$(WINDRES)"; \
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_BFD)"; \
if command -v llvm-windres >/dev/null 2>&1; then \
WINDRES_CMD=llvm-windres; \
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
elif "$$WINDRES_CMD" --version 2>&1 | grep -qi LLVM; then \
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
fi; \
command -v "$$WINDRES_CMD" >/dev/null 2>&1 || { printf 'Missing windres tool: %s\n' "$$WINDRES_CMD"; exit 1; }; \
cd backend/cmd/desktop && "$$WINDRES_CMD" -O coff -F "$$WINDRES_FMT" -i icon_windows.rc -o $(WINDOWS_RESOURCE)
# ============================================
# 发布资产
# ============================================
release-assets-linux: version-check desktop-build-linux
release-assets-check:
go run ./versionctl release-assets-check
@printf 'Release assets check passed\n'
release-assets-web: version-check release-assets-check _frontend-build
rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-amd64 ./cmd/server
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_LINUX_ASSET)" nex-server-linux-amd64
tar -C build -czf "$(RELEASE_DIR)/$(DESKTOP_LINUX_ASSET)" nex-linux-amd64
asset=$$(go run ./versionctl asset-name web tar.gz); \
tar -C frontend -czf "$(RELEASE_DIR)/$$asset" dist
release-assets-windows: version-check desktop-build-win
ifeq ($(OS),Windows_NT)
powershell -NoProfile -Command "Remove-Item -LiteralPath '$(RELEASE_DIR)' -Recurse -Force -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Path '$(RELEASE_DIR)' -Force | Out-Null"
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-server-win-amd64.exe ./cmd/server
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-server-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(SERVER_WINDOWS_ASSET)' -Force"
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(DESKTOP_WINDOWS_ASSET)' -Force"
else
@printf 'release-assets-windows requires Windows\n'
@exit 1
endif
release-assets-macos: version-check desktop-build-mac
release-assets-linux: version-check release-assets-check _check-linux-target-arch
rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-amd64 ./cmd/server
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-arm64 ./cmd/server
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_AMD64_ASSET)" nex-server-darwin-amd64
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_ARM64_ASSET)" nex-server-darwin-arm64
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$(DESKTOP_MACOS_ASSET)"
@$(MAKE) release-assets-server-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
@$(MAKE) release-assets-desktop-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
release-assets-windows: version-check release-assets-check _check-windows-target-arch
rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
@$(MAKE) release-assets-server-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
@$(MAKE) release-assets-desktop-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
release-assets-macos: version-check release-assets-check
rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
@$(MAKE) release-assets-server-macos RELEASE_DIR="$(RELEASE_DIR)"
@$(MAKE) release-assets-desktop-macos RELEASE_DIR="$(RELEASE_DIR)"
release-assets-server-linux: version-check _check-linux-target-arch
mkdir -p build "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-$(TARGET_ARCH) ./cmd/server
asset=$$(go run ./versionctl asset-name server linux $(TARGET_ARCH) tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-linux-$(TARGET_ARCH)
release-assets-server-windows: version-check _check-windows-target-arch
mkdir -p build "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(WINDOWS_SERVER_BINARY) ./cmd/server
asset=$$(go run ./versionctl asset-name server windows $(TARGET_ARCH) zip); \
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_SERVER_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
release-assets-server-macos: version-check
mkdir -p build "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-amd64 ./cmd/server
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-arm64 ./cmd/server
lipo -create build/nex-server-macos-amd64 build/nex-server-macos-arm64 -output build/nex-server-macos-universal
lipo -info build/nex-server-macos-universal | grep -q 'x86_64 arm64'
asset=$$(go run ./versionctl asset-name server macos amd64 tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-amd64
asset=$$(go run ./versionctl asset-name server macos arm64 tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-arm64
asset=$$(go run ./versionctl asset-name server macos universal tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-universal
rm -f build/nex-server-macos-amd64 build/nex-server-macos-arm64 build/nex-server-macos-universal
release-assets-desktop-linux: version-check release-assets-check desktop-build-linux _package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm
release-assets-desktop-windows: version-check release-assets-check desktop-build-win
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name desktop windows $(TARGET_ARCH) zip); \
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_DESKTOP_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
release-assets-desktop-macos: version-check release-assets-check desktop-build-mac _package-macos-zip _package-macos-dmg
rm -rf build/Nex.app build/dmg
release-assets-checksums:
@cd "$(RELEASE_DIR)" && \
rm -f SHA256SUMS && \
for asset in *; do \
[ -f "$$asset" ] || continue; \
if command -v sha256sum >/dev/null 2>&1; then \
sha256sum "$$asset"; \
elif command -v shasum >/dev/null 2>&1; then \
shasum -a 256 "$$asset"; \
else \
printf 'Missing sha256sum or shasum\n' >&2; \
exit 1; \
fi; \
done > SHA256SUMS && \
test -s SHA256SUMS
_check-linux-target-arch:
@if [ "$(TARGET_ARCH)" != "amd64" ] && [ "$(TARGET_ARCH)" != "arm64" ]; then \
printf 'Unsupported Linux TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
exit 1; \
fi
_check-windows-target-arch:
@if [ "$(TARGET_ARCH)" != "amd64" ]; then \
printf 'Unsupported Windows TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
exit 1; \
fi
_ensure-appimagetool:
@mkdir -p build/tools
@if [ ! -x "$(APPIMAGETOOL)" ]; then \
printf 'Downloading appimagetool for %s...\n' "$(APPIMAGE_ARCH)"; \
command -v curl >/dev/null 2>&1 || { printf 'Missing curl for appimagetool download\n'; exit 1; }; \
curl -L "$(APPIMAGETOOL_URL)" -o "$(APPIMAGETOOL)"; \
chmod +x "$(APPIMAGETOOL)"; \
fi; \
printf 'Using appimagetool: %s\n' "$(APPIMAGETOOL)"
_package-linux-tar:
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-linux-$(TARGET_ARCH)
_package-linux-appimage: _ensure-appimagetool
mkdir -p "$(RELEASE_DIR)"
appdir="build/appimage/nex-$(TARGET_ARCH).AppDir"; \
rm -rf "$$appdir"; \
mkdir -p "$$appdir/usr/bin" "$$appdir/usr/share/applications" "$$appdir/usr/share/icons"; \
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$appdir/usr/bin/nex"; \
install -m 0644 packaging/linux/nex.desktop "$$appdir/nex.desktop"; \
install -m 0644 packaging/linux/nex.desktop "$$appdir/usr/share/applications/nex.desktop"; \
install -m 0755 packaging/linux/AppRun "$$appdir/AppRun"; \
cp -R assets/icons/hicolor "$$appdir/usr/share/icons/"; \
cp assets/icon.png "$$appdir/nex.png"; \
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) AppImage); \
ARCH=$(APPIMAGE_ARCH) APPIMAGE_EXTRACT_AND_RUN=1 "$(APPIMAGETOOL)" "$$appdir" "$(RELEASE_DIR)/$$asset"; \
chmod +x "$(RELEASE_DIR)/$$asset"; \
test -s "$(RELEASE_DIR)/$$asset"
_package-linux-deb:
mkdir -p "$(RELEASE_DIR)"
pkgdir="build/pkg/deb/nex-$(TARGET_ARCH)"; \
rm -rf "$$pkgdir"; \
mkdir -p "$$pkgdir/DEBIAN" "$$pkgdir/usr/bin" "$$pkgdir/usr/share/applications" "$$pkgdir/usr/share/icons"; \
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$pkgdir/usr/bin/nex"; \
install -m 0644 packaging/linux/nex.desktop "$$pkgdir/usr/share/applications/nex.desktop"; \
cp -R assets/icons/hicolor "$$pkgdir/usr/share/icons/"; \
printf '%s\n' \
'Package: nex' \
'Version: $(VERSION)' \
'Section: utils' \
'Priority: optional' \
'Architecture: $(DEB_ARCH)' \
'Maintainer: Nex Maintainers <noreply@example.com>' \
'Depends: libgtk-3-0, libayatana-appindicator3-1, xdg-utils' \
'Description: AI Gateway desktop application' \
' Nex is an AI Gateway desktop application.' \
> "$$pkgdir/DEBIAN/control"; \
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) deb); \
dpkg-deb --build --root-owner-group "$$pkgdir" "$(RELEASE_DIR)/$$asset"; \
dpkg-deb -I "$(RELEASE_DIR)/$$asset" >/dev/null
_package-linux-rpm:
mkdir -p "$(RELEASE_DIR)"
topdir="$(abspath build/rpmbuild-$(TARGET_ARCH))"; \
rm -rf "$$topdir"; \
mkdir -p "$$topdir/BUILD" "$$topdir/BUILDROOT" "$$topdir/RPMS" "$$topdir/SOURCES" "$$topdir/SPECS" "$$topdir/SRPMS"; \
rpmbuild -bb --target "$(RPM_ARCH)" \
--define "_topdir $$topdir" \
--define "nex_version $(VERSION)" \
--define "nex_binary $(abspath $(LINUX_DESKTOP_BINARY))" \
--define "nex_desktop_file $(abspath packaging/linux/nex.desktop)" \
--define "nex_icons_dir $(abspath assets/icons/hicolor)" \
packaging/linux/nex.spec; \
rpm_file=$$(find "$$topdir/RPMS" -type f -name '*.rpm' | sort | tail -n 1); \
test -n "$$rpm_file"; \
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) rpm); \
cp "$$rpm_file" "$(RELEASE_DIR)/$$asset"; \
rpm -qip "$(RELEASE_DIR)/$$asset" >/dev/null
_package-macos-zip:
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name desktop macos universal zip); \
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$$asset"
_package-macos-dmg:
mkdir -p "$(RELEASE_DIR)"
dmgdir="build/dmg/Nex"; \
rm -rf "$$dmgdir"; \
mkdir -p "$$dmgdir"; \
cp -R build/Nex.app "$$dmgdir/Nex.app"; \
ln -s /Applications "$$dmgdir/Applications"; \
asset=$$(go run ./versionctl asset-name desktop macos universal dmg); \
hdiutil create -volname Nex -srcfolder "$$dmgdir" -ov -format UDZO "$(RELEASE_DIR)/$$asset"; \
hdiutil verify "$(RELEASE_DIR)/$$asset" >/dev/null && \
rm -rf "$$dmgdir"
# ============================================
# 共享 helper targets
@@ -213,6 +495,12 @@ _backend-test:
_backend-clean:
@$(MAKE) -C backend clean
_versionctl-lint:
@$(MAKE) -C versionctl lint
_versionctl-test:
@$(MAKE) -C versionctl test
_frontend-install:
cd frontend && bun install

133
README.md
View File

@@ -27,7 +27,7 @@ nex/
│ │ ├── api/ # API 层(统一请求封装 + 字段转换)
│ │ ├── hooks/ # TanStack Query hooks
│ │ ├── components/ # 通用组件AppLayout
│ │ ├── pages/ # 页面Providers, Stats
│ │ ├── pages/ # 页面Providers, Stats, Settings
│ │ ├── routes/ # React Router 路由配置
│ │ ├── types/ # TypeScript 类型定义
│ │ └── __tests__/ # 单元测试 + 组件测试
@@ -39,6 +39,8 @@ nex/
│ ├── icon.icns # macOS 应用图标
│ └── icon.ico # Windows 应用图标
├── packaging/ # 桌面发布包元数据Linux desktop entry、RPM spec 等)
└── README.md # 本文件
```
@@ -55,6 +57,7 @@ nex/
- **多供应商管理**:配置和管理多个供应商(供应商 ID 仅限字母、数字、下划线)
- **用量统计**:按供应商、模型、日期统计请求数量
- **Web 配置界面**:提供供应商和模型配置管理
- **启动参数设置**:通过 Web 界面查看和编辑启动参数Desktop 可编辑、Server 只读)
## 技术栈
@@ -64,7 +67,7 @@ nex/
- **ORM**: GORM
- **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack结构化日志 + 日志轮转 + 模块标识)
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值
- **配置**: Viper + pflagServer 多层配置Desktop 配置文件快照
- **验证**: go-playground/validator/v10
- **迁移**: goose
@@ -91,7 +94,7 @@ JSON: {"level":"info","logger":"handler.proxy","msg":"处理请求","method":
- **图表库**: Recharts
- **路由**: React Router v7
- **数据获取**: TanStack Query v5
- **样式**: SCSS Modules
- **样式**: TDesign 组件 props 优先TDesign tokens 次之SCSS 作为兜底补充
- **测试**: Vitest + React Testing Library + Playwright
## 快速开始
@@ -109,10 +112,13 @@ make desktop-build-win
# Linux
make desktop-build-linux
# Linux arm64
make desktop-build-linux TARGET_ARCH=arm64
```
**使用桌面应用**
- 双击启动应用macOS: Nex.appWindows: nex-win-amd64.exeLinux: nex-linux-amd64
- 双击启动应用macOS: Nex.appWindows: nex-win-amd64.exeLinux: nex-linux-amd64 / nex-linux-arm64
- 系统托盘图标出现,浏览器自动打开管理界面
- 点击托盘图标显示菜单,可打开管理界面或退出
- 关闭浏览器后服务继续运行,可通过托盘重新打开
@@ -120,8 +126,10 @@ make desktop-build-linux
**注意事项**
- 桌面应用需要 CGO 支持
- macOS: 自带 Xcode Command Line Tools
- Linux: 自带 gcc部分桌面环境需要 `libappindicator3-dev`
- Windows: 需要 MinGW-w64 或在 Windows 环境构建
- Linux 构建: 需要 gcc、pkg-config、GTK3 开发包和 Ayatana AppIndicator 开发包Ubuntu/Debian: `libgtk-3-dev``libayatana-appindicator3-dev`
- Linux 运行: 需要 GTK3、Ayatana AppIndicator 和 xdg-utilsAppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含
- Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链desktop 使用 GUI linker flags 隐藏控制台窗口
- macOS DMG: 发布包暂不签名、不 notarize首次打开可能出现 Gatekeeper 提示
**Linux 桌面环境兼容性**
- GNOME: 需要 AppIndicator 扩展
@@ -140,7 +148,6 @@ make server-run
- 前端开发服务器:`http://localhost:5173`
前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动:
- 创建配置文件 `~/.nex/config.yaml`
- 初始化数据库 `~/.nex/config.db`
- 运行数据库迁移
- 创建日志目录 `~/.nex/log/`
@@ -151,6 +158,40 @@ make server-run
make server-build
```
### Release 产物
发布流程由 Git tag `vX.Y.Z` 触发GitHub Actions 会先通过全流程测试门禁,再构建并创建 Draft Release上传 server、web 和 desktop 三类产物,同时生成 `SHA256SUMS`
**server 产物**(不内置 Web 管理界面):
| 平台 | 产物 |
|------|------|
| Linux amd64 | `nex-server_<version>_linux_amd64.tar.gz` |
| Linux arm64 | `nex-server_<version>_linux_arm64.tar.gz` |
| macOS amd64 | `nex-server_<version>_macos_amd64.tar.gz` |
| macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` |
| macOS universal | `nex-server_<version>_macos_universal.tar.gz` |
| Windows amd64 | `nex-server_<version>_windows_amd64.zip` |
**web 产物**
| 内容 | 产物 |
|------|------|
| `frontend/dist` | `nex-web_<version>.tar.gz` |
**desktop 产物**
| 平台 | 产物 |
|------|------|
| Linux amd64 | `nex-desktop_<version>_linux_amd64.tar.gz``.AppImage``.deb``.rpm` |
| Linux arm64 | `nex-desktop_<version>_linux_arm64.tar.gz``.AppImage``.deb``.rpm` |
| macOS universal | `nex-desktop_<version>_macos_universal.zip``nex-desktop_<version>_macos_universal.dmg` |
| Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` |
Linux deb 包声明 `libgtk-3-0``libayatana-appindicator3-1``xdg-utils` 运行依赖rpm 包声明 `gtk3``libayatana-appindicator-gtk3``xdg-utils` 运行依赖。Rocky Linux 9 等发行版可能需要启用 EPEL 才能解析 Ayatana AppIndicator 依赖。
server 和 desktop 发布产物自包含运行时数据库迁移资源(通过 `go:embed` 嵌入二进制),安装后首次启动不再依赖仓库源码目录。
## API 接口
### 代理接口(对外部应用)
@@ -199,13 +240,29 @@ make server-build
查询参数支持:`provider_id``model_name``start_date``end_date``group_by`
#### 启动参数设置
- `GET /api/settings/startup` - 查询启动参数设置
- `PUT /api/settings/startup` - 保存启动参数设置(仅 Desktop 模式)
**行为差异**
- **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效
- **Server 模式**:查询返回当前运行有效配置,保存请求始终返回 403
响应包含 `mode``editable``config_path``restart_required` 元数据和完整启动参数配置。Duration 字段使用字符串格式(如 `30s``1h`)。
#### 版本信息
- `GET /api/version` - 获取后端构建版本信息(`version``commit``build_time`),用于前端 About 页面诊断前后端版本一致性
## 配置
配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
配置方式取决于启动模式:
- **Server 模式**`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
- **Desktop 模式**`cmd/desktop`):仅支持配置文件 `~/.nex/config.yaml` > 默认值,修改配置文件后需重启 desktop 生效
### 配置文件
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成
配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件
```yaml
server:
@@ -235,9 +292,9 @@ log:
compress: true
```
### 环境变量
### 环境变量(仅 Server 模式)
所有配置项支持环境变量,使用 `NEX_` 前缀:
Server 模式下,所有配置项支持环境变量,使用 `NEX_` 前缀:
```bash
export NEX_SERVER_PORT=9000
@@ -255,7 +312,11 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转大写 + 下划线(如 `server.port``NEX_SERVER_PORT`)。
### CLI 参数
**Desktop 模式不支持环境变量覆盖。**Desktop 仅从 `~/.nex/config.yaml` 和默认值读取配置。
### CLI 参数(仅 Server 模式)
Server 模式下,支持命令行参数:
```bash
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
@@ -263,6 +324,8 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转 kebab-case`server.port``--server-port`)。
**Desktop 不支持命令行参数覆盖配置。**Desktop 忽略所有 CLI 参数,仅从 `~/.nex/config.yaml` 读取。
### 数据文件
- `~/.nex/config.yaml` - 配置文件
@@ -286,7 +349,13 @@ backend 分类测试、MySQL 专项测试和前端 E2E 测试请分别查看 `ba
```bash
# 首次克隆后安装 Git hooks
lefthook install
make hooks-install
# 检查 Git hooks 安装状态
make hooks-check
# 运行 Git hooks 回归测试
make hooks-test
# 全局命令
make lint # 前后端共享检查
@@ -309,6 +378,12 @@ make desktop-test # desktop 专属测试
make desktop-clean # 清理 desktop 产物
```
Git hooks 使用仓库内 `scripts/git-hooks/` 的原生脚本,不依赖额外 hook 框架。当前 hooks 包含:
- pre-commit检查 staged files 的冲突标记、大文件告警和 LFS 指针并按文件类型委托后端、versionctl、前端检查
- prepare-commit-msg在编辑器打开时提供提交信息模板辅助填写 `类型: 简短描述` 和多行说明
- commit-msg校验提交信息格式为 `类型: 简短描述`多行说明需在首行后保留空行提交描述按项目规范使用中文hook 不做字符集检测
## 版本与发布
### 统一版本源
@@ -318,27 +393,24 @@ make desktop-clean # 清理 desktop 产物
### 本地版本演进
1. 手工修改根目录 `VERSION` 为新的 `x.y.z`
2. 同步镜像文件:
```bash
# 递增版本(自动 lint + test + 工作区检查 + sync/check + commit + tag
make version-bump BUMP=minor
# 或指定具体版本号
make version-bump SET_VERSION=1.0.0
# 推送到远程
git push --follow-tags
```
手动同步和校验:
```bash
make version-sync
```
3. 校验版本一致性:
```bash
make version-check
```
4. 提交版本变更后,创建发布 tag
```bash
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin main
git push origin vX.Y.Z
```
### 本地生成发布资产
```bash
@@ -357,7 +429,10 @@ make release-assets-macos
### GitHub Draft Release
- 推送 `vX.Y.Z` tag 后,`.github/workflows/release.yml` 会自动执行发布流水线
- 流水线会先校验 tag 与 `VERSION` 一致,再构建以下资产并上传到 GitHub Draft Release
- 流水线会先校验 tag 与 `VERSION` 一致,再执行全流程测试门禁lint、默认测试、MySQL 测试、E2E 测试),测试不通过则阻止构建
- 测试通过后,三个平台 job 并行构建,各 job 会在正式构建前先检查 `go``bun` 和各自的平台打包工具链,缺失时快速失败并在日志中输出诊断信息
- Windows 发布 job 在 `MSYS2 / MINGW64` shell 中执行,并继承 `setup-go` / `setup-bun` 准备好的工具链路径
- 构建以下资产并上传到 GitHub Draft Release
- Linux server
- Windows server
- darwin-amd64 server

View File

@@ -1 +1 @@
0.1.0
0.1.7

View File

@@ -5,7 +5,7 @@
migrate-up migrate-down migrate-status migrate-create \
mysql-up mysql-down mysql-test mysql-test-quick
VERSION := $(shell go run ./cmd/versionctl print)
VERSION := $(shell go run ../versionctl print)
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
GO_LDFLAGS := -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)

View File

@@ -54,7 +54,7 @@ func NewProxyHandler(..., logger *zap.Logger) *ProxyHandler {
使用 `pkg/logger/field.go` 中定义的字段构造函数:
```go
logger.Info("请求开始",
logger.Debug("请求开始",
pkglogger.Method("POST"),
pkglogger.Path("/v1/chat"),
pkglogger.RequestID("xxx"),
@@ -72,7 +72,7 @@ GORM 日志自动桥接到 zapSQL 查询映射到 Debug 级别。
- **ORM**: GORM
- **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值
- **配置**: Viper + pflagServer 多层配置Desktop 配置文件快照
- **验证**: go-playground/validator/v10
- **迁移**: goose
@@ -164,7 +164,11 @@ backend/
│ └── validator/ # 验证器
│ └── validator.go
├── migrations/ # 数据库迁移
── 20260421000001_initial_schema.sql
── embed.go # go:embed 迁移资源入口
│ ├── sqlite/
│ │ └── 20260421000001_initial_schema.sql
│ └── mysql/
│ └── 20260421000001_initial_schema.sql
├── tests/ # 集成测试
│ ├── helpers.go # 测试辅助函数
│ ├── config/ # 测试配置
@@ -330,15 +334,18 @@ go mod download
go run cmd/server/main.go
```
服务将在端口 9826 启动。首次启动会自动创建配置文件和运行数据库迁移。
服务将在端口 9826 启动。首次启动会自动运行数据库迁移。
## 配置
配置支持多种方式:配置文件、环境变量、命令行参数,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
配置方式取决于启动入口:
- **Server 入口**`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
- **Desktop 入口**`cmd/desktop`):仅支持 `~/.nex/config.yaml` > 默认值,不支持 CLI 参数和 `NEX_*` 环境变量覆盖,修改配置文件后需重启生效
### 配置文件
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成。
配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件:
```yaml
server:
@@ -368,9 +375,9 @@ log:
compress: true
```
### 环境变量
### 环境变量(仅 Server 入口)
所有配置项都支持环境变量,使用 `NEX_` 前缀:
Server 入口下,所有配置项都支持环境变量,使用 `NEX_` 前缀:
```bash
export NEX_SERVER_PORT=9000
@@ -388,7 +395,7 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port``NEX_SERVER_PORT`)。
### 命令行参数
### 命令行参数(仅 Server 入口)
```bash
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
@@ -456,6 +463,8 @@ make mysql-test-quick
## 数据库迁移
应用启动时使用随二进制打包的迁移资源(`go:embed`自动执行迁移server 和 desktop 发布产物均自包含,不依赖源码目录。开发时可继续通过 Makefile goose CLI 操作文件系统中的 `migrations/<dialect>/` 目录,运行时嵌入资源与文件系统目录共享同一批 SQL 文件。
```bash
# 使用 Makefile
make migrate-up DB_DSN=~/.nex/config.db
@@ -577,6 +586,20 @@ GET /anthropic/v1/models
查询参数:`provider_id``model_name``start_date`YYYY-MM-DD`end_date``group_by`provider/model/date
#### 版本信息
- `GET /api/version` - 获取后端构建版本信息
响应字段来源于构建阶段注入的 `buildinfo` 元数据:
```json
{
"version": "0.1.0",
"commit": "abc1234",
"build_time": "2026-05-05T00:00:00Z"
}
```
#### 健康检查
- `GET /health` - 返回 `{"status": "ok"}`

View File

@@ -43,10 +43,23 @@ var (
)
func main() {
port := 9826
minimalLogger := pkgLogger.NewMinimal()
cfg, cfgMeta, err := config.LoadDesktopConfigWithMetadata()
if err != nil {
minimalLogger.Error("加载配置失败", zap.Error(err))
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
os.Exit(1)
}
port := cfg.Server.Port
if err := checkPortAvailable(port); err != nil {
minimalLogger.Error("端口不可用", zap.Error(err))
showError(appName, err.Error())
os.Exit(1)
}
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
if err := singleLock.Lock(); err != nil {
minimalLogger.Error("已有 Nex 实例运行")
@@ -59,17 +72,6 @@ func main() {
}
}()
if err := checkPortAvailable(port); err != nil {
minimalLogger.Error("端口不可用", zap.Error(err))
showError(appName, err.Error())
return
}
cfg, err := config.LoadConfig()
if err != nil {
minimalLogger.Fatal("加载配置失败", zap.Error(err))
}
zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
Level: cfg.Log.Level,
Path: cfg.Log.Path,
@@ -130,6 +132,8 @@ func main() {
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
versionHandler := handler.NewVersionHandler()
settingsHandler := handler.NewSettingsHandler(cfg, "desktop", true, cfgMeta.ConfigPath)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -139,11 +143,11 @@ func main() {
r.Use(middleware.Logging(zapLogger))
r.Use(middleware.CORS())
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
setupStaticFiles(r)
server = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Addr: desktopListenAddr(port),
Handler: r,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
@@ -164,7 +168,7 @@ func main() {
go func() {
time.Sleep(500 * time.Millisecond)
if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil {
if err := openBrowser(desktopURL(port)); err != nil {
zapLogger.Warn("无法打开浏览器", zap.Error(err))
}
}()
@@ -172,9 +176,10 @@ func main() {
setupSystray(port)
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
r.Any("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
r.GET("/api/version", versionHandler.GetVersion)
providers := r.Group("/api/providers")
{
@@ -200,6 +205,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
stats.GET("/aggregate", statsHandler.AggregateStats)
}
settings := r.Group("/api/settings")
{
settings.GET("/startup", settingsHandler.GetStartupSettings)
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
}
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
@@ -257,13 +268,13 @@ func setupStaticFilesWithFS(r *gin.Engine, distFS fs.FS) {
c.Data(200, getContentType(filepath), data)
})
r.GET("/favicon.svg", func(c *gin.Context) {
data, err := fs.ReadFile(distFS, "favicon.svg")
r.GET("/icon.png", func(c *gin.Context) {
data, err := fs.ReadFile(distFS, "icon.png")
if err != nil {
c.Status(404)
return
}
c.Data(200, "image/svg+xml", data)
c.Data(200, "image/png", data)
})
r.NoRoute(func(c *gin.Context) {
@@ -307,7 +318,7 @@ func setupSystray(port int) {
systray.AddSeparator()
mStatus := systray.AddMenuItem("状态: 运行中", "")
mStatus.Disable()
mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "")
mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "")
mPort.Disable()
systray.AddSeparator()
mQuit := systray.AddMenuItem("退出", "停止服务并退出")
@@ -316,7 +327,7 @@ func setupSystray(port int) {
for {
select {
case <-mOpen.ClickedCh:
if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil {
if err := openBrowser(desktopURL(port)); err != nil {
zapLogger.Warn("打开浏览器失败", zap.Error(err))
}
case <-mQuit.ClickedCh:
@@ -347,6 +358,30 @@ func doShutdown() {
}
}
func getDesktopConfigPath() string {
configDir, err := config.GetConfigDir()
if err != nil {
return "~/.nex/config.yaml"
}
return filepath.Join(configDir, "config.yaml")
}
func desktopConfigErrorMessage(configPath string, err error) string {
return fmt.Sprintf("加载配置失败\n\n配置文件: %s\n\n%v", configPath, err)
}
func desktopListenAddr(port int) string {
return fmt.Sprintf(":%d", port)
}
func desktopURL(port int) string {
return fmt.Sprintf("http://localhost:%d", port)
}
func desktopPortMenuTitle(port int) string {
return fmt.Sprintf("端口: %d", port)
}
func checkPortAvailable(port int) error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {

View File

@@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"testing"
"nex/backend/internal/config"
"nex/backend/internal/database"
"go.uber.org/zap"
)
func TestDesktop_InitMigrationsWithoutSourceTree(t *testing.T) {
tmpDir := t.TempDir()
origDir, err := os.Getwd()
if err == nil {
defer func() {
if chdirErr := os.Chdir(origDir); chdirErr != nil {
t.Logf("无法恢复工作目录: %v", chdirErr)
}
}()
}
if chdirErr := os.Chdir(tmpDir); chdirErr != nil {
t.Skipf("无法切换到临时目录: %v", chdirErr)
}
cfg := &config.DatabaseConfig{
Driver: "sqlite",
Path: filepath.Join(tmpDir, "nex-test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
db, err := database.Init(cfg, zapLogger)
if err != nil {
t.Fatalf("在无源码目录环境下数据库初始化应成功,但返回错误: %v", err)
}
database.Close(db)
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"net"
"net/http"
"strings"
"testing"
"time"
)
@@ -67,3 +68,62 @@ func TestCheckPortAvailableAfterClose(t *testing.T) {
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) {
path := getDesktopConfigPath()
if path == "" {
t.Fatal("getDesktopConfigPath 应返回非空路径")
}
if !strings.Contains(path, "config.yaml") {
t.Fatalf("路径应包含 config.yaml实际: %s", path)
}
t.Log("getDesktopConfigPath 测试通过")
}
func TestDesktopConfiguredPortHelpers(t *testing.T) {
port := 19830
if got := desktopListenAddr(port); got != ":19830" {
t.Fatalf("HTTP 监听地址应使用配置端口,实际: %s", got)
}
if got := desktopURL(port); got != "http://localhost:19830" {
t.Fatalf("浏览器 URL 应使用配置端口,实际: %s", got)
}
if got := desktopPortMenuTitle(port); got != "端口: 19830" {
t.Fatalf("托盘端口显示应使用配置端口,实际: %s", got)
}
}
func TestDesktopConfigErrorMessageContainsPathAndReason(t *testing.T) {
msg := desktopConfigErrorMessage("/tmp/nex/config.yaml", errors.New("yaml parse failed"))
if !strings.Contains(msg, "/tmp/nex/config.yaml") {
t.Fatalf("配置错误提示应包含配置路径,实际: %s", msg)
}
if !strings.Contains(msg, "yaml parse failed") {
t.Fatalf("配置错误提示应包含失败原因,实际: %s", msg)
}
}

View File

@@ -0,0 +1,44 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"nex/backend/internal/handler"
"github.com/gin-gonic/gin"
)
func TestSetupRoutes_VersionDoesNotFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "desktop", true, ""))
setupStaticFilesWithFS(r, fstest.MapFS{
"index.html": {Data: []byte("<html>fallback</html>")},
})
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
if contentType := w.Header().Get("Content-Type"); contentType == "text/html; charset=utf-8" {
t.Fatalf("版本接口不应返回 SPA fallback HTML")
}
var result map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
for _, key := range []string{"version", "commit", "build_time"} {
if result[key] == "" {
t.Fatalf("响应缺少 %s 字段: %#v", key, result)
}
}
}

View File

@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"github.com/gin-gonic/gin"
)
@@ -100,6 +101,30 @@ func TestSetupStaticFiles(t *testing.T) {
t.Log("静态文件服务测试通过")
}
func TestSetupStaticFilesWithFS_IconPNG(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
setupStaticFilesWithFS(r, fstest.MapFS{
"icon.png": {Data: []byte("png")},
"index.html": {Data: []byte("<html>fallback</html>")},
})
req := httptest.NewRequest("GET", "/icon.png", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
}
if w.Header().Get("Content-Type") != "image/png" {
t.Fatalf("期望 Content-Type image/png, 实际 %s", w.Header().Get("Content-Type"))
}
if w.Body.String() != "png" {
t.Fatalf("期望返回 PNG 内容,实际 %q", w.Body.String())
}
}
func TestWithProtocolAndStaticRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)

View File

@@ -29,7 +29,7 @@ import (
func main() {
minimalLogger := pkgLogger.NewMinimal()
cfg, err := config.LoadConfig()
cfg, cfgMeta, err := config.LoadServerConfigWithMetadata()
if err != nil {
minimalLogger.Fatal("加载配置失败", zap.Error(err))
}
@@ -93,6 +93,8 @@ func main() {
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
versionHandler := handler.NewVersionHandler()
settingsHandler := handler.NewSettingsHandler(cfg, "server", false, cfgMeta.ConfigPath)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -102,7 +104,7 @@ func main() {
r.Use(middleware.Logging(zapLogger))
r.Use(middleware.CORS())
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
@@ -140,8 +142,9 @@ func main() {
zapLogger.Info("服务器已关闭")
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
r.GET("/api/version", versionHandler.GetVersion)
providers := r.Group("/api/providers")
{
@@ -167,6 +170,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
stats.GET("/aggregate", statsHandler.AggregateStats)
}
settings := r.Group("/api/settings")
{
settings.GET("/startup", settingsHandler.GetStartupSettings)
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
}
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})

View File

@@ -0,0 +1,37 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"nex/backend/internal/handler"
"github.com/gin-gonic/gin"
)
func TestSetupRoutes_Version(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "server", false, ""))
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
var result map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
for _, key := range []string{"version", "commit", "build_time"} {
if result[key] == "" {
t.Fatalf("响应缺少 %s 字段: %#v", key, result)
}
}
}

View File

@@ -1,7 +1,6 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -37,7 +36,7 @@ type DatabaseConfig struct {
Driver string `yaml:"driver" mapstructure:"driver" validate:"required,oneof=sqlite mysql"`
Path string `yaml:"path" mapstructure:"path" validate:"required_if=Driver sqlite"`
Host string `yaml:"host" mapstructure:"host" validate:"required_if=Driver mysql"`
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,min=1,max=65535"`
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,omitempty,min=1,max=65535"`
User string `yaml:"user" mapstructure:"user" validate:"required_if=Driver mysql"`
Password string `yaml:"password" mapstructure:"password"`
DBName string `yaml:"dbname" mapstructure:"dbname" validate:"required_if=Driver mysql"`
@@ -225,82 +224,156 @@ func setupConfigFile(v *viper.Viper, configPath string) error {
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
// 尝试读取配置文件,如果不存在则忽略
if err := v.ReadInConfig(); err != nil {
if !os.IsNotExist(err) {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
// 配置文件不存在,创建默认配置文件
writeErr := v.SafeWriteConfigAs(configPath)
if writeErr == nil {
if os.IsNotExist(err) {
return nil
}
var alreadyExistsErr viper.ConfigFileAlreadyExistsError
if errors.As(writeErr, &alreadyExistsErr) {
return nil
}
return appErrors.Wrap(appErrors.ErrInternal, writeErr)
return appErrors.Wrap(appErrors.ErrInternal, err)
}
return nil
}
// LoadConfig loads config from YAML file, creates default if not exists
func LoadConfig() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
}
return LoadConfigFromPath(configPath)
type ConfigMetadata struct {
ConfigPath string
}
// LoadConfigFromPath 从指定路径加载配置
func LoadConfigFromPath(configPath string) (*Config, error) {
// 1. 创建 Viper 实例
type loadOptions struct {
configPathOverride string
useCLI bool
useEnv bool
useConfigFlag bool
}
// resolveConfigPath 根据 loadOptions 解析 CLI 参数并返回最终配置文件路径
func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
configPath := opts.configPathOverride
if !opts.useCLI && !opts.useConfigFlag {
return configPath, nil
}
flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError)
if opts.useConfigFlag {
flagSet.String("config", opts.configPathOverride, "配置文件路径")
}
if opts.useCLI {
setupFlags(v, flagSet)
}
if err := flagSet.Parse(os.Args[1:]); err != nil {
return "", appErrors.Wrap(appErrors.ErrInvalidRequest, err)
}
if opts.useConfigFlag {
if f, err := flagSet.GetString("config"); err == nil && f != "" {
configPath = f
}
}
return configPath, nil
}
func loadConfig(opts loadOptions) (*Config, error) {
cfg, _, err := loadConfigWithMetadata(opts)
return cfg, err
}
func loadConfigWithMetadata(opts loadOptions) (*Config, ConfigMetadata, error) {
v := viper.New()
// 2. 定义 CLI 参数
flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError)
flagSet.String("config", configPath, "配置文件路径")
setupFlags(v, flagSet)
// 3. 解析 CLI 参数(忽略错误,因为可能没有参数)
if err := flagSet.Parse(os.Args[1:]); err != nil {
return nil, appErrors.Wrap(appErrors.ErrInvalidRequest, err)
}
// 4. 获取配置文件路径(可能被 --config 参数覆盖)
if configPathFlag, err := flagSet.GetString("config"); err == nil && configPathFlag != "" {
configPath = configPathFlag
}
// 5. 设置默认值
setupDefaults(v)
// 6. 绑定环境变量
setupEnv(v)
// 7. 读取配置文件
if err := setupConfigFile(v, configPath); err != nil {
return nil, err
configPath, err := resolveConfigPath(v, opts)
if err != nil {
return nil, ConfigMetadata{}, err
}
if opts.useEnv {
setupEnv(v)
}
if err := setupConfigFile(v, configPath); err != nil {
return nil, ConfigMetadata{}, err
}
// 8. 反序列化到结构体
cfg := &Config{}
if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
))); err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
}
// 9. 验证配置
if err := cfg.Validate(); err != nil {
return nil, err
return nil, ConfigMetadata{}, err
}
return cfg, nil
return cfg, ConfigMetadata{ConfigPath: configPath}, nil
}
// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config
func LoadServerConfig() (*Config, error) {
cfg, _, err := LoadServerConfigWithMetadata()
return cfg, err
}
func LoadServerConfigWithMetadata() (*Config, ConfigMetadata, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
}
return loadConfigWithMetadata(loadOptions{
configPathOverride: configPath,
useCLI: true,
useEnv: true,
useConfigFlag: true,
})
}
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
func LoadDesktopConfig() (*Config, error) {
cfg, _, err := LoadDesktopConfigWithMetadata()
return cfg, err
}
func LoadDesktopConfigWithMetadata() (*Config, ConfigMetadata, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
}
return loadConfigWithMetadata(loadOptions{
configPathOverride: configPath,
useCLI: false,
useEnv: false,
useConfigFlag: false,
})
}
// LoadConfig loads config from YAML file.
// 向后兼容,等同于 LoadServerConfig。
func LoadConfig() (*Config, error) {
return LoadServerConfig()
}
// LoadConfigFromPath 从指定路径加载配置。
// 保留向后兼容,沿用 server 语义(支持 CLI、env 和 --config 覆盖)。
func LoadConfigFromPath(configPath string) (*Config, error) {
return loadConfig(loadOptions{
configPathOverride: configPath,
useCLI: true,
useEnv: true,
useConfigFlag: true,
})
}
// LoadDesktopConfigAtPath 从指定路径以 desktop 语义加载配置(仅配置文件和默认值),用于测试场景。
func LoadDesktopConfigAtPath(configPath string) (*Config, error) {
return loadConfig(loadOptions{
configPathOverride: configPath,
useCLI: false,
useEnv: false,
useConfigFlag: false,
})
}
// SaveConfig saves config to YAML file
@@ -309,13 +382,15 @@ func SaveConfig(cfg *Config) error {
if err != nil {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
return SaveConfigToPath(cfg, configPath)
}
func SaveConfigToPath(cfg *Config, configPath string) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
// Ensure directory exists
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return appErrors.Wrap(appErrors.ErrInternal, err)

View File

@@ -0,0 +1,94 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestLoadDesktopConfigAtPath_WithMetadata(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
cfg := DefaultConfig()
cfg.Server.Port = 8888
data, err := yaml.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(configPath, data, 0o600))
loaded, meta, err := loadConfigWithMetadata(loadOptions{
configPathOverride: configPath,
useCLI: false,
useEnv: false,
useConfigFlag: false,
})
require.NoError(t, err)
assert.Equal(t, 8888, loaded.Server.Port)
assert.Equal(t, configPath, meta.ConfigPath)
}
func TestSaveConfigToPath(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "sub", "config.yaml")
cfg := DefaultConfig()
cfg.Server.Port = 7777
err := SaveConfigToPath(cfg, configPath)
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Contains(t, string(data), "7777")
}
func TestSaveConfigToPath_InvalidDir(t *testing.T) {
cfg := DefaultConfig()
err := SaveConfigToPath(cfg, "/dev/null/impossible/config.yaml")
assert.Error(t, err)
}
func TestDurationConversion(t *testing.T) {
cfg := DefaultConfig()
dto := configToDTO(cfg)
parsed, err := time.ParseDuration(dto.Server.ReadTimeout)
require.NoError(t, err)
assert.Equal(t, cfg.Server.ReadTimeout, parsed)
parsed, err = time.ParseDuration(dto.Database.ConnMaxLifetime)
require.NoError(t, err)
assert.Equal(t, cfg.Database.ConnMaxLifetime, parsed)
}
func configToDTO(c *Config) struct {
Server struct {
Port int `json:"port"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
}
Database struct {
ConnMaxLifetime string `json:"conn_max_lifetime"`
}
} {
var result struct {
Server struct {
Port int `json:"port"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
}
Database struct {
ConnMaxLifetime string `json:"conn_max_lifetime"`
}
}
result.Server.Port = c.Server.Port
result.Server.ReadTimeout = c.Server.ReadTimeout.String()
result.Server.WriteTimeout = c.Server.WriteTimeout.String()
result.Database.ConnMaxLifetime = c.Database.ConnMaxLifetime.String()
return result
}

View File

@@ -1,10 +1,10 @@
package database
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/pressly/goose/v3"
"go.uber.org/zap"
@@ -13,6 +13,7 @@ import (
"gorm.io/gorm"
"nex/backend/internal/config"
"nex/backend/migrations"
pkglogger "nex/backend/pkg/logger"
)
@@ -77,29 +78,24 @@ func runMigrations(db *gorm.DB, driver string, zapLogger *zap.Logger) error {
return err
}
gooseDialect := "sqlite3"
migrationsSubDir := "sqlite"
if driver == "mysql" {
gooseDialect = "mysql"
migrationsSubDir = "mysql"
}
migrationsDir := getMigrationsDir(driver)
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
dialect, fsys, err := migrations.ForDriver(driver)
if err != nil {
return err
}
if zapLogger != nil {
zapLogger.Info("执行数据库迁移",
zap.String("dialect", gooseDialect),
zap.String("dir", migrationsSubDir))
zap.String("dialect", string(dialect)),
zap.String("driver", driver))
}
if err := goose.SetDialect(gooseDialect); err != nil {
return err
provider, err := goose.NewProvider(dialect, sqlDB, fsys)
if err != nil {
return fmt.Errorf("创建迁移提供者失败: %w", err)
}
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return err
if _, err := provider.Up(context.Background()); err != nil {
return fmt.Errorf("执行迁移失败: %w", err)
}
return nil
@@ -130,21 +126,6 @@ func configurePool(db *gorm.DB, cfg *config.DatabaseConfig, zapLogger *zap.Logge
}
}
func getMigrationsDir(driver string) string {
_, filename, _, ok := runtime.Caller(0)
if ok {
subDir := "sqlite"
if driver == "mysql" {
subDir = "mysql"
}
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", subDir)
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
}
return "./migrations"
}
func BuildDSN(cfg *config.DatabaseConfig) string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)

View File

@@ -1,10 +1,13 @@
package database
import (
"io/fs"
"os"
"path/filepath"
"testing"
"nex/backend/internal/config"
"nex/backend/migrations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -76,3 +79,87 @@ func TestBuildDSN_EmptyPassword(t *testing.T) {
dsn := BuildDSN(cfg)
assert.Equal(t, "root:@tcp(localhost:3306)/nex?charset=utf8mb4&parseTime=true&loc=Local", dsn)
}
func TestInit_SQLite_AnyCWD(t *testing.T) {
dir := t.TempDir()
origDir, err := os.Getwd()
if err == nil {
defer func() {
if chdirErr := os.Chdir(origDir); chdirErr != nil {
t.Logf("无法恢复工作目录: %v", chdirErr)
}
}()
}
if chdirErr := os.Chdir(dir); chdirErr != nil {
t.Skipf("无法切换到临时目录: %v", chdirErr)
}
cfg := &config.DatabaseConfig{
Driver: "sqlite",
Path: filepath.Join(dir, "test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
db, err := Init(cfg, zapLogger)
require.NoError(t, err)
require.NotNil(t, db)
defer Close(db)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NotNil(t, sqlDB)
}
func TestForDriverDialect_SQLite(t *testing.T) {
require.NoError(t, testMigrateWithDriver(t, "sqlite"))
}
func TestForDriverDialect_MySQL(t *testing.T) {
dialect, fsys, err := migrations.ForDriver("mysql")
require.NoError(t, err)
assert.Equal(t, "mysql", string(dialect))
entries, fsErr := fs.ReadDir(fsys, ".")
require.NoError(t, fsErr)
assert.NotEmpty(t, entries, "MySQL 迁移资源应至少包含一个文件")
}
func TestForDriverDialect_Invalid(t *testing.T) {
dir := t.TempDir()
cfg := &config.DatabaseConfig{
Driver: "postgres",
Path: filepath.Join(dir, "test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
_, err := Init(cfg, zapLogger)
assert.Error(t, err, "非法 driver 应返回错误")
assert.Contains(t, err.Error(), "不支持的数据库驱动")
}
func testMigrateWithDriver(t *testing.T, driver string) error {
t.Helper()
dir := t.TempDir()
cfg := &config.DatabaseConfig{
Driver: driver,
Path: filepath.Join(dir, "test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
db, err := Init(cfg, zapLogger)
if err != nil {
return err
}
Close(db)
return nil
}

View File

@@ -0,0 +1,71 @@
package database
import (
"io/fs"
"testing"
"nex/backend/migrations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEmbeddedMigrations_SQLiteResourcesPresent(t *testing.T) {
entries, err := fs.ReadDir(migrations.FS, "sqlite")
require.NoError(t, err)
var sqlFiles []string
for _, entry := range entries {
if !entry.IsDir() {
sqlFiles = append(sqlFiles, entry.Name())
}
}
assert.NotEmpty(t, sqlFiles, "SQLite 迁移资源应至少包含一个 .sql 文件")
}
func TestEmbeddedMigrations_MySQLResourcesPresent(t *testing.T) {
entries, err := fs.ReadDir(migrations.FS, "mysql")
require.NoError(t, err)
var sqlFiles []string
for _, entry := range entries {
if !entry.IsDir() {
sqlFiles = append(sqlFiles, entry.Name())
}
}
assert.NotEmpty(t, sqlFiles, "MySQL 迁移资源应至少包含一个 .sql 文件")
}
func TestEmbeddedMigrations_SQLiteSQLParsable(t *testing.T) {
subFS, err := fs.Sub(migrations.FS, "sqlite")
require.NoError(t, err)
entries, err := fs.ReadDir(subFS, ".")
require.NoError(t, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := fs.ReadFile(subFS, entry.Name())
require.NoError(t, err, "无法读取迁移文件: %s", entry.Name())
assert.NotEmpty(t, data, "迁移文件内容不应为空: %s", entry.Name())
}
}
func TestEmbeddedMigrations_MySQLSQLParsable(t *testing.T) {
subFS, err := fs.Sub(migrations.FS, "mysql")
require.NoError(t, err)
entries, err := fs.ReadDir(subFS, ".")
require.NoError(t, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := fs.ReadFile(subFS, entry.Name())
require.NoError(t, err, "无法读取迁移文件: %s", entry.Name())
assert.NotEmpty(t, data, "迁移文件内容不应为空: %s", entry.Name())
}
}

View File

@@ -20,7 +20,7 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
if id, ok := requestID.(string); ok {
requestIDStr = id
}
logger.Info("请求开始",
logger.Debug("请求开始",
pkglogger.Method(c.Request.Method),
pkglogger.Path(path),
pkglogger.Query(query),
@@ -33,7 +33,7 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
latency := time.Since(start)
statusCode := c.Writer.Status()
logger.Info("请求结束",
logger.Debug("请求结束",
pkglogger.StatusCode(statusCode),
pkglogger.Method(c.Request.Method),
pkglogger.Path(path),

View File

@@ -7,6 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
func init() {
@@ -65,6 +67,61 @@ func TestLogging(t *testing.T) {
assert.Equal(t, 200, w.Code)
}
func TestLogging_DoesNotLogLifecycleAtInfoLevel(t *testing.T) {
core, logs := observer.New(zapcore.InfoLevel)
logger := zap.New(core)
w := serveLoggingRequest(logger)
assert.Equal(t, 200, w.Code)
assert.Empty(t, logs.FilterMessage("请求开始").All())
assert.Empty(t, logs.FilterMessage("请求结束").All())
}
func TestLogging_LogsLifecycleAtDebugLevel(t *testing.T) {
core, logs := observer.New(zapcore.DebugLevel)
logger := zap.New(core)
w := serveLoggingRequest(logger)
assert.Equal(t, 200, w.Code)
startLogs := logs.FilterMessage("请求开始").All()
endLogs := logs.FilterMessage("请求结束").All()
if assert.Len(t, startLogs, 1) {
fields := startLogs[0].ContextMap()
assert.Equal(t, "GET", fields["method"])
assert.Equal(t, "/test", fields["path"])
assert.Equal(t, "key=value", fields["query"])
assert.Equal(t, "existing-id-123", fields["request_id"])
assert.NotEmpty(t, fields["client_ip"])
}
if assert.Len(t, endLogs, 1) {
fields := endLogs[0].ContextMap()
assert.Equal(t, int64(200), fields["status"])
assert.Equal(t, "GET", fields["method"])
assert.Equal(t, "/test", fields["path"])
assert.Equal(t, int64(2), fields["body_size"])
assert.Equal(t, "existing-id-123", fields["request_id"])
assert.Contains(t, fields, "latency")
}
}
func serveLoggingRequest(logger *zap.Logger) *httptest.ResponseRecorder {
r := gin.New()
r.Use(RequestID())
r.Use(Logging(logger))
r.GET("/test", func(c *gin.Context) {
c.String(200, "ok")
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test?key=value", nil)
req.Header.Set("X-Request-ID", "existing-id-123")
r.ServeHTTP(w, req)
return w
}
func TestRecovery_NoPanic(t *testing.T) {
logger := zap.NewNop()

View File

@@ -0,0 +1,223 @@
package handler
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"nex/backend/internal/config"
appErrors "nex/backend/pkg/errors"
)
type SettingsHandler struct {
runtimeCfg *config.Config
mode string
editable bool
configPath string
}
func NewSettingsHandler(runtimeCfg *config.Config, mode string, editable bool, configPath string) *SettingsHandler {
return &SettingsHandler{
runtimeCfg: runtimeCfg,
mode: mode,
editable: editable,
configPath: configPath,
}
}
type serverConfigDTO struct {
Port int `json:"port"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
}
type databaseConfigDTO struct {
Driver string `json:"driver"`
Path string `json:"path"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
DBName string `json:"dbname"`
MaxIdleConns int `json:"max_idle_conns"`
MaxOpenConns int `json:"max_open_conns"`
ConnMaxLifetime string `json:"conn_max_lifetime"`
}
type logConfigDTO struct {
Level string `json:"level"`
Path string `json:"path"`
MaxSize int `json:"max_size"`
MaxBackups int `json:"max_backups"`
MaxAge int `json:"max_age"`
Compress bool `json:"compress"`
}
type startupSettingsDTO struct {
Server serverConfigDTO `json:"server"`
Database databaseConfigDTO `json:"database"`
Log logConfigDTO `json:"log"`
}
type startupSettingsResponse struct {
Mode string `json:"mode"`
Editable bool `json:"editable"`
ConfigPath string `json:"config_path"`
RestartRequired bool `json:"restart_required"`
Config startupSettingsDTO `json:"config"`
}
func configToDTO(cfg *config.Config) startupSettingsDTO {
return startupSettingsDTO{
Server: serverConfigDTO{
Port: cfg.Server.Port,
ReadTimeout: cfg.Server.ReadTimeout.String(),
WriteTimeout: cfg.Server.WriteTimeout.String(),
},
Database: databaseConfigDTO{
Driver: cfg.Database.Driver,
Path: cfg.Database.Path,
Host: cfg.Database.Host,
Port: cfg.Database.Port,
User: cfg.Database.User,
Password: cfg.Database.Password,
DBName: cfg.Database.DBName,
MaxIdleConns: cfg.Database.MaxIdleConns,
MaxOpenConns: cfg.Database.MaxOpenConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime.String(),
},
Log: logConfigDTO{
Level: cfg.Log.Level,
Path: cfg.Log.Path,
MaxSize: cfg.Log.MaxSize,
MaxBackups: cfg.Log.MaxBackups,
MaxAge: cfg.Log.MaxAge,
Compress: cfg.Log.Compress,
},
}
}
func dtoToConfig(dto startupSettingsDTO) (*config.Config, error) {
readTimeout, err := time.ParseDuration(dto.Server.ReadTimeout)
if err != nil {
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "read_timeout 格式错误,例如 30s")
}
writeTimeout, err := time.ParseDuration(dto.Server.WriteTimeout)
if err != nil {
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "write_timeout 格式错误,例如 30s")
}
connMaxLifetime, err := time.ParseDuration(dto.Database.ConnMaxLifetime)
if err != nil {
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "conn_max_lifetime 格式错误,例如 1h")
}
return &config.Config{
Server: config.ServerConfig{
Port: dto.Server.Port,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
},
Database: config.DatabaseConfig{
Driver: dto.Database.Driver,
Path: dto.Database.Path,
Host: dto.Database.Host,
Port: dto.Database.Port,
User: dto.Database.User,
Password: dto.Database.Password,
DBName: dto.Database.DBName,
MaxIdleConns: dto.Database.MaxIdleConns,
MaxOpenConns: dto.Database.MaxOpenConns,
ConnMaxLifetime: connMaxLifetime,
},
Log: config.LogConfig{
Level: dto.Log.Level,
Path: dto.Log.Path,
MaxSize: dto.Log.MaxSize,
MaxBackups: dto.Log.MaxBackups,
MaxAge: dto.Log.MaxAge,
Compress: dto.Log.Compress,
},
}, nil
}
func (h *SettingsHandler) GetStartupSettings(c *gin.Context) {
var cfg *config.Config
var configPath string
if h.mode == "desktop" {
desktopCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
if err != nil {
writeError(c, err)
return
}
cfg = desktopCfg
configPath = h.configPath
} else {
cfg = h.runtimeCfg
configPath = h.configPath
}
c.JSON(http.StatusOK, startupSettingsResponse{
Mode: h.mode,
Editable: h.editable,
ConfigPath: configPath,
RestartRequired: h.editable,
Config: configToDTO(cfg),
})
}
func (h *SettingsHandler) SaveStartupSettings(c *gin.Context) {
if !h.editable {
c.JSON(http.StatusForbidden, gin.H{
"error": "server 模式下不允许保存启动参数",
"code": "forbidden",
})
return
}
var req struct {
Config startupSettingsDTO `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "无效的请求格式",
})
return
}
cfg, err := dtoToConfig(req.Config)
if err != nil {
writeError(c, err)
return
}
if err := cfg.Validate(); err != nil {
writeError(c, err)
return
}
if err := config.SaveConfigToPath(cfg, h.configPath); err != nil {
if errors.Is(err, appErrors.ErrInvalidRequest) {
writeError(c, err)
return
}
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
return
}
savedCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
if err != nil {
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
return
}
c.JSON(http.StatusOK, startupSettingsResponse{
Mode: h.mode,
Editable: h.editable,
ConfigPath: h.configPath,
RestartRequired: true,
Config: configToDTO(savedCfg),
})
}

View File

@@ -0,0 +1,418 @@
package handler
import (
"bytes"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"nex/backend/internal/config"
)
func init() {
gin.SetMode(gin.TestMode)
}
func createTestConfig(t *testing.T) (*config.Config, string) {
t.Helper()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
cfg := config.DefaultConfig()
cfg.Database.Path = filepath.Join(dir, "test.db")
cfg.Log.Path = filepath.Join(dir, "log")
data, err := yaml.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(configPath, data, 0o600))
return cfg, configPath
}
func TestSettingsHandler_GetStartupSettings_Desktop(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "desktop", resp.Mode)
assert.True(t, resp.Editable)
assert.True(t, resp.RestartRequired)
assert.Equal(t, configPath, resp.ConfigPath)
assert.Equal(t, cfg.Server.Port, resp.Config.Server.Port)
assert.Equal(t, "30s", resp.Config.Server.ReadTimeout)
assert.Equal(t, cfg.Database.Driver, resp.Config.Database.Driver)
}
func TestSettingsHandler_GetStartupSettings_Server(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "server", false, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "server", resp.Mode)
assert.False(t, resp.Editable)
assert.False(t, resp.RestartRequired)
assert.Equal(t, configPath, resp.ConfigPath)
}
func TestSettingsHandler_SaveStartupSettings_Desktop(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
newPort := 9999
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": newPort,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": filepath.Join(t.TempDir(), "new.db"),
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(t.TempDir(), "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, newPort, resp.Config.Server.Port)
assert.True(t, resp.Editable)
assert.True(t, resp.RestartRequired)
savedCfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, newPort, savedCfg.Server.Port)
}
func TestSettingsHandler_SaveStartupSettings_Server_Forbidden(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "server", false, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9999,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": "/tmp/test.db",
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": "/tmp/log",
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 403, w.Code)
assert.Contains(t, w.Body.String(), "不允许保存")
}
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidConfig(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
originalData, err := os.ReadFile(configPath)
require.NoError(t, err)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 0,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": "/tmp/test.db",
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": "/tmp/log",
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 400, w.Code)
currentData, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Equal(t, originalData, currentData)
}
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidDuration(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "not-a-duration",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": "/tmp/test.db",
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": "/tmp/log",
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 400, w.Code)
assert.Contains(t, w.Body.String(), "read_timeout")
}
func TestSettingsHandler_SaveStartupSettings_Desktop_CreatesConfigFile(t *testing.T) {
cfg := config.DefaultConfig()
dir := t.TempDir()
configPath := filepath.Join(dir, "nex", "config.yaml")
_, err := os.Stat(configPath)
assert.True(t, os.IsNotExist(err))
h := NewSettingsHandler(cfg, "desktop", true, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": filepath.Join(dir, "test.db"),
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(dir, "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
_, err = os.Stat(configPath)
assert.NoError(t, err)
}
func TestSettingsHandler_SaveStartupSettings_Desktop_PasswordIncluded(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "secret123",
"dbname": "nex",
"path": "",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(t.TempDir(), "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "secret123", resp.Config.Database.Password)
}
func TestSettingsHandler_GetStartupSettings_DesktopReadsConfigFile(t *testing.T) {
cfg, configPath := createTestConfig(t)
savedCfg := config.DefaultConfig()
savedCfg.Server.Port = 5555
data, err := yaml.Marshal(savedCfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(configPath, data, 0o600))
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, 5555, resp.Config.Server.Port)
}
func TestSettingsHandler_GetStartupSettings_ServerReturnsRuntime(t *testing.T) {
cfg, configPath := createTestConfig(t)
cfg.Server.Port = 7777
h := NewSettingsHandler(cfg, "server", false, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, 7777, resp.Config.Server.Port)
}
func TestSettingsHandler_SaveStartupSettings_InvalidJSON(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader([]byte("{invalid")))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 400, w.Code)
}

View File

@@ -0,0 +1,26 @@
package handler
import (
"net/http"
"nex/backend/pkg/buildinfo"
"github.com/gin-gonic/gin"
)
// VersionHandler 提供后端构建版本信息。
type VersionHandler struct{}
// NewVersionHandler 创建版本信息处理器。
func NewVersionHandler() *VersionHandler {
return &VersionHandler{}
}
// GetVersion 返回构建注入的版本元数据。
func (h *VersionHandler) GetVersion(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": buildinfo.Version(),
"commit": buildinfo.Commit(),
"build_time": buildinfo.BuildTime(),
})
}

View File

@@ -0,0 +1,31 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVersionHandler_GetVersion(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewVersionHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/version", nil)
h.GetVersion(c)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.Equal(t, "dev", result["version"])
assert.Equal(t, "unknown", result["commit"])
assert.Equal(t, "unknown", result["build_time"])
}

View File

@@ -0,0 +1,31 @@
package migrations
import (
"embed"
"fmt"
"io/fs"
"github.com/pressly/goose/v3"
)
//go:embed sqlite/*.sql mysql/*.sql
var FS embed.FS
func ForDriver(driver string) (goose.Dialect, fs.FS, error) {
switch driver {
case "sqlite":
subFS, err := fs.Sub(FS, "sqlite")
if err != nil {
return goose.DialectSQLite3, nil, fmt.Errorf("SQLite 迁移资源不可用: %w", err)
}
return goose.DialectSQLite3, subFS, nil
case "mysql":
subFS, err := fs.Sub(FS, "mysql")
if err != nil {
return goose.DialectMySQL, nil, fmt.Errorf("MySQL 迁移资源不可用: %w", err)
}
return goose.DialectMySQL, subFS, nil
default:
return goose.DialectSQLite3, nil, fmt.Errorf("不支持的数据库驱动: %s仅支持 sqlite 或 mysql", driver)
}
}

View File

@@ -1,113 +0,0 @@
package projectversion
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParse(t *testing.T) {
t.Run("valid", func(t *testing.T) {
version, err := Parse("1.2.3")
require.NoError(t, err)
assert.Equal(t, Version{Major: 1, Minor: 2, Patch: 3}, version)
assert.Equal(t, "1.2.3", version.String())
})
t.Run("invalid", func(t *testing.T) {
invalidValues := []string{"", "1.2", "1.2.3.4", "v1.2.3", "01.2.3", "1.02.3"}
for _, tc := range invalidValues {
_, err := Parse(tc)
assert.Error(t, err, "%q 应校验失败", tc)
}
})
}
func TestUpdatePackageJSONVersion(t *testing.T) {
content := "{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"
updated, err := UpdatePackageJSONVersion(content, "1.2.3")
require.NoError(t, err)
assert.Contains(t, updated, `"version": "1.2.3"`)
version, err := ReadPackageJSONVersion(updated)
require.NoError(t, err)
assert.Equal(t, "1.2.3", version)
}
func TestUpsertEnvVar(t *testing.T) {
updated := UpsertEnvVar("VITE_API_BASE=/api\n", "VITE_APP_VERSION", "1.2.3")
assert.Contains(t, updated, "VITE_API_BASE=/api\n")
assert.Contains(t, updated, "VITE_APP_VERSION=1.2.3\n")
updated = UpsertEnvVar(updated, "VITE_APP_VERSION", "2.0.0")
value, ok := ReadEnvVar(updated, "VITE_APP_VERSION")
assert.True(t, ok)
assert.Equal(t, "2.0.0", value)
assert.Equal(t, 1, strings.Count(updated, "VITE_APP_VERSION="))
}
func TestSyncAndCheck(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(root, "frontend"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", "package.json"), []byte("{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.production"), []byte("VITE_API_BASE=/api\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.development"), []byte("VITE_API_BASE=\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.desktop"), []byte("VITE_API_BASE=\n"), 0o644))
require.NoError(t, Sync(root))
require.NoError(t, Check(root))
packageJSONContent, err := os.ReadFile(filepath.Join(root, "frontend", "package.json"))
require.NoError(t, err)
assert.Contains(t, string(packageJSONContent), `"version": "1.2.3"`)
for _, relPath := range frontendVersionFiles {
content, readErr := os.ReadFile(filepath.Join(root, relPath))
require.NoError(t, readErr)
assert.Contains(t, string(content), "VITE_APP_VERSION=1.2.3\n")
}
}
func TestVerifyTag(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644))
require.NoError(t, VerifyTag(root, "v1.2.3"))
assert.Error(t, VerifyTag(root, "1.2.3"))
assert.Error(t, VerifyTag(root, "v1.2.4"))
}
func TestAssetNames(t *testing.T) {
linuxServer, err := ServerAssetName("1.2.3", "linux", "amd64")
require.NoError(t, err)
assert.Equal(t, "nex-server_1.2.3_linux_amd64.tar.gz", linuxServer)
macServer, err := ServerAssetName("1.2.3", "darwin", "arm64")
require.NoError(t, err)
assert.Equal(t, "nex-server_1.2.3_darwin_arm64.tar.gz", macServer)
macDesktop, err := DesktopAssetName("1.2.3", "macos")
require.NoError(t, err)
assert.Equal(t, "Nex_1.2.3_macOS_universal.zip", macDesktop)
_, err = DesktopAssetName("1.2.3", "ios")
assert.Error(t, err)
}
func TestDesktopInfoPlist(t *testing.T) {
plist, err := DesktopInfoPlist("1.2.3", "13.0")
require.NoError(t, err)
assert.Contains(t, plist, "<key>CFBundleShortVersionString</key>\n <string>1.2.3</string>")
assert.Contains(t, plist, "<key>CFBundleVersion</key>\n <string>1.2.3</string>")
assert.Contains(t, plist, "<key>LSMinimumSystemVersion</key>\n <string>13.0</string>")
_, err = DesktopInfoPlist("1.2", "13.0")
assert.Error(t, err)
_, err = DesktopInfoPlist("1.2.3", "")
assert.Error(t, err)
}

View File

@@ -120,7 +120,7 @@ log:
assert.Equal(t, "warn", cfg.Log.Level, "YAML value should be used when no CLI/ENV override")
}
func TestLoadConfig_AutoCreate(t *testing.T) {
func TestLoadConfig_NoAutoCreate(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
@@ -132,6 +132,9 @@ func TestLoadConfig_AutoCreate(t *testing.T) {
require.NotNil(t, cfg)
assert.Equal(t, 9826, cfg.Server.Port, "should load with default values")
_, err = os.Stat(configPath)
assert.True(t, os.IsNotExist(err), "config file should not be auto-created")
}
func TestSaveAndLoadConfig(t *testing.T) {
@@ -184,3 +187,124 @@ func TestSaveAndLoadConfig(t *testing.T) {
assert.Equal(t, cfg.Log.MaxAge, loaded.Log.MaxAge)
assert.Equal(t, cfg.Log.Compress, loaded.Log.Compress)
}
func TestLoadDesktopConfig_FileOnly(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
yamlContent := `
server:
port: 8080
log:
level: debug
`
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
require.NoError(t, err)
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port)
assert.Equal(t, "debug", cfg.Log.Level)
}
func TestLoadDesktopConfig_IgnoresCLI(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
require.NoError(t, err)
originalArgs := os.Args
defer func() { os.Args = originalArgs }()
os.Args = []string{"nex", "--server-port", "9999"}
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore CLI args and use config file")
}
func TestLoadDesktopConfig_IgnoresEnv(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
require.NoError(t, err)
t.Setenv("NEX_SERVER_PORT", "9000")
t.Setenv("NEX_LOG_LEVEL", "debug")
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore env vars and use config file")
assert.Equal(t, "info", cfg.Log.Level, "desktop should ignore env vars and use default")
}
func TestLoadDesktopConfig_IgnoresUnknownArgs(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
require.NoError(t, err)
originalArgs := os.Args
defer func() { os.Args = originalArgs }()
os.Args = []string{"nex", "--unknown-flag", "value", "--another-unknown"}
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err, "desktop should not fail on unknown CLI args")
assert.Equal(t, 8080, cfg.Server.Port)
}
func TestLoadDesktopConfig_Snapshot(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
require.NoError(t, err)
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port)
err = os.WriteFile(configPath, []byte("server:\n port: 9999\n"), 0o600)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port, "loaded config snapshot should not change when file changes")
cfg2, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 9999, cfg2.Server.Port, "reload should pick up new config values")
}
func TestLoadDesktopConfig_InvalidFileFails(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "invalid yaml",
content: "server:\n port: [\n",
},
{
name: "validation failure",
content: "server:\n port: 70000\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte(tt.content), 0o600)
require.NoError(t, err)
_, err = config.LoadDesktopConfigAtPath(configPath)
require.Error(t, err, "desktop should not silently fall back to defaults for invalid config files")
})
}
}

View File

@@ -1,2 +1,2 @@
VITE_API_BASE=
VITE_APP_VERSION=0.1.0
VITE_APP_VERSION=0.1.7

View File

@@ -1,2 +1,2 @@
VITE_API_BASE=
VITE_APP_VERSION=0.1.0
VITE_APP_VERSION=0.1.7

View File

@@ -1,2 +1,2 @@
VITE_API_BASE=/api
VITE_APP_VERSION=0.1.0
VITE_APP_VERSION=0.1.7

View File

@@ -1,4 +1,4 @@
# AI Gateway Frontend
# Nex Frontend
AI 网关管理前端,提供供应商配置和用量统计界面。
@@ -11,7 +11,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。
- **UI 组件库**: TDesign
- **路由**: React Router v7
- **数据获取**: TanStack Query v5
- **样式**: SCSS Modules禁止使用纯 CSS
- **样式**: TDesign 组件 props 优先TDesign tokens 次之SCSS 作为兜底补充
- **测试**: Vitest + React Testing Library + Playwright
- **代码格式化**: Prettier
@@ -86,17 +86,20 @@ frontend/
│ │ ├── client.ts # 统一 request<T>() + 字段转换
│ │ ├── providers.ts # Provider CRUD
│ │ ├── models.ts # Model CRUD
│ │ ── stats.ts # Stats 查询
│ │ ── stats.ts # Stats 查询
│ │ └── version.ts # 后端版本查询
│ ├── components/
│ │ └── AppLayout/ # 侧边栏导航布局
│ ├── hooks/ # TanStack Query hooks
│ │ ├── useProviders.ts
│ │ ├── useModels.ts
│ │ ── useStats.ts
│ │ ── useStats.ts
│ │ └── useVersion.ts
│ ├── pages/
│ │ ├── Providers/ # 供应商管理(含内嵌模型管理)
│ │ ├── Stats/ # 用量统计
│ │ ├── Settings/ # 设置(开发中)
│ │ ├── About/ # 关于页面(品牌与版本信息)
│ │ └── NotFound.tsx
│ ├── routes/
│ │ └── index.tsx # 路由配置
@@ -111,6 +114,7 @@ frontend/
│ ├── main.tsx
│ └── index.scss
├── e2e/ # Playwright E2E 测试
├── public/ # 静态资源icon.png 来源于 ../assets/icon.png
├── vitest.config.ts
├── playwright.config.ts
├── tsconfig.json
@@ -145,7 +149,8 @@ bun run build
```bash
bun run lint # ESLint 检查
bun run format:check # Prettier 格式检查
bun run check # 同时检查 lint 和格式
bun run typecheck # TypeScript 类型检查
bun run check # 同时检查类型、lint 和格式
```
### 代码格式化
@@ -184,13 +189,14 @@ bun run test:e2e
- API Key 脱敏显示
- 启用/禁用状态标签
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择
- **一键复制**Base URL 和 API Key 支持一键复制到剪贴板
### 模型管理
- 展开供应商行查看关联模型
- 添加/编辑/删除模型
- 按供应商筛选模型
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别,支持一键复制
- **UUID 自动生成**:创建模型时后端自动生成 UUID无需手动输入 ID
### 用量统计
@@ -200,6 +206,12 @@ bun run test:e2e
- 按模型筛选
- 按日期范围筛选DatePicker.RangePicker
### 关于页面
- 展示应用名称、产品描述和项目链接
- 展示前端版本、后端版本、后端 commit 和构建时间
- 根据 `VITE_APP_VERSION``GET /api/version` 返回值提示前后端版本是否一致
## 测试策略
### 目录结构
@@ -231,9 +243,10 @@ __tests__/
## 环境变量
| 变量 | 开发环境 | 生产环境 | 说明 |
| --------------- | -------- | -------- | ------------------------------- |
| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy |
| 变量 | 开发环境 | 生产环境 | 说明 |
| ------------------ | -------- | -------- | ----------------------------------------- |
| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy |
| `VITE_APP_VERSION` | `0.1.0` | `0.1.0` | 前端构建版本,由 `make version-sync` 同步 |
**E2E 测试特有**
@@ -242,9 +255,11 @@ __tests__/
## 开发规范
- 所有样式使用 SCSS禁止使用纯 CSS 文件
- 组件级样式使用 SCSS Modules\*.module.scss
- 样式优先使用 TDesign 组件 props`hoverShadow``headerBordered``variant``shape``gutter`
- 组件 props 无法表达时使用 TDesign tokens`var(--td-*)`
- 仅当 props 和 tokens 无法满足布局、响应式或品牌视觉需求时使用 SCSS禁止使用纯 CSS 文件
- 图标优先使用 TDesign 图标tdesign-icons-react
- 应用 favicon 使用 `frontend/public/icon.png`,该文件来源于仓库根目录 `assets/icon.png`
- TypeScript strict 模式,禁止 any 类型
- API 层自动处理 snake_case ↔ camelCase 字段转换
- 使用路径别名 `@/` 引用 src 目录

View File

@@ -12,7 +12,7 @@ test.describe('侧边栏', () => {
})
test('应显示应用名称', async ({ page }) => {
await expect(page.locator('aside').getByText('AI Gateway')).toBeVisible()
await expect(page.locator('aside').getByText('Nex')).toBeVisible()
})
test('应显示导航菜单项', async ({ page }) => {
@@ -41,6 +41,15 @@ test.describe('页面导航', () => {
await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible()
})
test('应能切换到关于页面并显示版本信息', async ({ page }) => {
await page.locator('aside').getByText('关于').click()
await expect(page.getByRole('heading', { name: '关于' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Nex' })).toBeVisible()
await expect(page.getByText('前端版本')).toBeVisible()
await expect(page.getByText('后端版本')).toBeVisible()
})
test('应在刷新后保持当前页面', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()

View File

@@ -2,9 +2,9 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Gateway</title>
<title>Nex</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,17 +1,18 @@
{
"name": "frontend",
"private": true,
"version": "0.1.0",
"version": "0.1.7",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && bun run check && vite build",
"build": "bun run check && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "bun run lint && bun run format:check",
"typecheck": "tsc -b",
"check": "bun run typecheck && bun run lint && bun run format:check",
"fix": "bun run lint:fix && bun run format",
"preview": "vite preview",
"test": "vitest run",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

BIN
frontend/public/icon.png LFS Normal file

Binary file not shown.

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,30 @@
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import { getBackendVersion } from '@/api/version'
describe('version API', () => {
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('fetches backend version and converts build_time to buildTime', async () => {
server.use(
http.get('http://localhost:3000/api/version', () => {
return HttpResponse.json({
version: '0.1.0',
commit: 'abc1234',
build_time: '2026-05-05T00:00:00Z',
})
})
)
await expect(getBackendVersion()).resolves.toEqual({
version: '0.1.0',
commit: 'abc1234',
buildTime: '2026-05-05T00:00:00Z',
})
})
})

View File

@@ -1,25 +1,37 @@
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router'
import { describe, it, expect } from 'vitest'
import { AppLayout } from '@/components/AppLayout'
const renderWithRouter = (component: React.ReactNode) => {
return render(<BrowserRouter>{component}</BrowserRouter>)
return render(<MemoryRouter initialEntries={['/providers']}>{component}</MemoryRouter>)
}
describe('AppLayout', () => {
it('renders sidebar with app name', () => {
renderWithRouter(<AppLayout />)
const appNames = screen.getAllByText('AI Gateway')
expect(appNames.length).toBeGreaterThan(0)
expect(screen.getByText('Nex')).toBeInTheDocument()
expect(screen.getByAltText('Nex logo')).toBeInTheDocument()
})
it('keeps logo visible when sidebar is collapsed', async () => {
const user = userEvent.setup()
renderWithRouter(<AppLayout />)
await user.click(screen.getByLabelText('收起侧边栏'))
expect(screen.getByAltText('Nex logo')).toBeInTheDocument()
expect(screen.queryByText('Nex')).not.toBeInTheDocument()
expect(screen.getByLabelText('展开侧边栏')).toBeInTheDocument()
})
it('renders navigation menu items', () => {
renderWithRouter(<AppLayout />)
expect(screen.getByText('供应商管理')).toBeInTheDocument()
expect(screen.getByText('用量统计')).toBeInTheDocument()
expect(screen.getAllByText('供应商管理').length).toBeGreaterThan(0)
expect(screen.getAllByText('用量统计').length).toBeGreaterThan(0)
})
it('renders settings menu item', () => {
@@ -28,6 +40,12 @@ describe('AppLayout', () => {
expect(screen.getByText('设置')).toBeInTheDocument()
})
it('renders about menu item', () => {
renderWithRouter(<AppLayout />)
expect(screen.getByText('关于')).toBeInTheDocument()
})
it('renders content outlet', () => {
const { container } = renderWithRouter(<AppLayout />)

View File

@@ -4,6 +4,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ModelTable } from '@/pages/Providers/ModelTable'
import type { Model } from '@/types'
const { mockMessagePluginSuccess } = vi.hoisted(() => ({
mockMessagePluginSuccess: vi.fn(),
}))
vi.mock('tdesign-react', async () => {
const actual = await vi.importActual('tdesign-react')
return {
...actual,
MessagePlugin: {
success: mockMessagePluginSuccess,
error: vi.fn(),
},
}
})
const mockModels: Model[] = [
{
id: 'model-1',
@@ -44,6 +59,7 @@ const defaultProps = {
describe('ModelTable', () => {
beforeEach(() => {
mockMutate.mockClear()
mockMessagePluginSuccess.mockClear()
})
it('renders model list with unified ID and model name', () => {
@@ -120,4 +136,19 @@ describe('ModelTable', () => {
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument()
})
it('renders unified model ID with copy button and copies on click', async () => {
const user = userEvent.setup()
const { container } = render(<ModelTable {...defaultProps} />)
const allCells = container.querySelectorAll('td')
const modelIdCell = Array.from(allCells).find((td) => td.textContent?.includes('openai/gpt-4o'))
expect(modelIdCell).toBeTruthy()
const buttons = modelIdCell!.querySelectorAll('button')
expect(buttons.length).toBeGreaterThan(0)
await user.click(buttons[0]!)
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制统一模型 ID')
})
})

View File

@@ -1,9 +1,24 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ProviderTable } from '@/pages/Providers/ProviderTable'
import type { Provider } from '@/types'
const { mockMessagePluginSuccess } = vi.hoisted(() => ({
mockMessagePluginSuccess: vi.fn(),
}))
vi.mock('tdesign-react', async () => {
const actual = await vi.importActual('tdesign-react')
return {
...actual,
MessagePlugin: {
success: mockMessagePluginSuccess,
error: vi.fn(),
},
}
})
const mockModelsData = [
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
{
@@ -54,6 +69,9 @@ const defaultProps = {
}
describe('ProviderTable', () => {
beforeEach(() => {
mockMessagePluginSuccess.mockClear()
})
it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
render(<ProviderTable {...defaultProps} />)
@@ -203,4 +221,66 @@ describe('ProviderTable', () => {
const protocolCell = container.querySelector('[data-colkey="protocol"]')
expect(protocolCell).toBeInTheDocument()
})
it('renders Base URL with copy button and copies on click', async () => {
const user = userEvent.setup()
const { container } = render(<ProviderTable {...defaultProps} />)
const baseUrlCells = container.querySelectorAll('td')
const baseUrlCellWithContent = Array.from(baseUrlCells).find((td) =>
td.textContent?.includes('https://api.openai.com/v1')
)
expect(baseUrlCellWithContent).toBeTruthy()
const buttons = baseUrlCellWithContent!.querySelectorAll('button')
expect(buttons.length).toBeGreaterThan(0)
await user.click(buttons[0]!)
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 Base URL')
})
it('renders API Key with copy button and copies on click', async () => {
const user = userEvent.setup()
const { container } = render(<ProviderTable {...defaultProps} />)
const allCells = container.querySelectorAll('td')
const apiKeyCell = Array.from(allCells).find((td) => td.textContent?.includes('sk-abcdefgh12345678'))
expect(apiKeyCell).toBeTruthy()
const buttons = apiKeyCell!.querySelectorAll('button')
expect(buttons.length).toBeGreaterThan(0)
await user.click(buttons[0]!)
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 API Key')
})
it('does not render copy button when Base URL is empty', () => {
const emptyUrlProvider: Provider[] = [
{
...mockProviders[0],
id: 'empty-url',
baseUrl: '',
},
]
const { container } = render(<ProviderTable {...defaultProps} providers={emptyUrlProvider} />)
const allCells = container.querySelectorAll('td')
const baseUrlCells = Array.from(allCells).filter((td) => td.textContent === '')
expect(baseUrlCells.length).toBeGreaterThanOrEqual(0)
})
it('does not render copy button when API Key is empty', () => {
const emptyKeyProvider: Provider[] = [
{
...mockProviders[0],
id: 'empty-key',
apiKey: '',
},
]
const { container } = render(<ProviderTable {...defaultProps} providers={emptyKeyProvider} />)
const allCells = container.querySelectorAll('td')
const apiKeyCells = Array.from(allCells).filter((td) => td.textContent === '')
expect(apiKeyCells.length).toBeGreaterThanOrEqual(0)
})
})

View File

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

View File

@@ -0,0 +1,33 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import React from 'react'
import { useBackendVersion } from '@/hooks/useVersion'
const server = setupServer(
http.get('/api/version', () => {
return HttpResponse.json({ version: '0.1.0', commit: 'abc1234', build_time: '2026-05-05T00:00:00Z' })
})
)
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
}
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('useBackendVersion', () => {
it('fetches backend version', async () => {
const { result } = renderHook(() => useBackendVersion(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({ version: '0.1.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' })
})
})

View File

@@ -0,0 +1,88 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBackendVersion } from '@/hooks/useVersion'
import AboutPage from '@/pages/About'
vi.mock('@/hooks/useVersion', () => ({
useBackendVersion: vi.fn(),
}))
vi.mock('@/constants/app', () => ({
APP_NAME: 'Nex',
APP_DESCRIPTION: 'AI Gateway - 统一的大模型 API 网关',
APP_WEBSITE: 'https://github.com/nex/gateway',
APP_VERSION: '0.1.0',
}))
const mockUseBackendVersion = useBackendVersion as ReturnType<typeof vi.fn>
describe('AboutPage', () => {
beforeEach(() => {
mockUseBackendVersion.mockReturnValue({
data: { version: '0.1.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' },
isError: false,
isLoading: false,
} as ReturnType<typeof useBackendVersion>)
})
it('renders brand, description and links', () => {
render(<AboutPage />)
expect(screen.getByRole('heading', { name: 'Nex' })).toBeInTheDocument()
expect(screen.getByText('AI Gateway - 统一的大模型 API 网关')).toBeInTheDocument()
expect(screen.getByText('GitHub')).toHaveAttribute('href', 'https://github.com/nex/gateway')
})
it('shows frontend and backend versions', () => {
render(<AboutPage />)
expect(screen.getByText('前端版本')).toBeInTheDocument()
expect(screen.getAllByText('0.1.0').length).toBeGreaterThan(0)
expect(screen.getByText('abc1234')).toBeInTheDocument()
expect(screen.getByText('2026-05-05T00:00:00Z')).toBeInTheDocument()
})
it('shows matched status', () => {
render(<AboutPage />)
expect(screen.getByText('版本一致')).toBeInTheDocument()
})
it('shows mismatched status', () => {
mockUseBackendVersion.mockReturnValue({
data: { version: '0.2.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' },
isError: false,
isLoading: false,
} as ReturnType<typeof useBackendVersion>)
render(<AboutPage />)
expect(screen.getByText('版本不一致')).toBeInTheDocument()
expect(screen.getByText(/用于部署诊断/)).toBeInTheDocument()
})
it('shows unknown status for dev backend version', () => {
mockUseBackendVersion.mockReturnValue({
data: { version: 'dev', commit: 'unknown', buildTime: 'unknown' },
isError: false,
isLoading: false,
} as ReturnType<typeof useBackendVersion>)
render(<AboutPage />)
expect(screen.getByText('无法判断版本')).toBeInTheDocument()
})
it('shows unavailable status on backend request failure', () => {
mockUseBackendVersion.mockReturnValue({
data: undefined,
isError: true,
isLoading: false,
} as ReturnType<typeof useBackendVersion>)
render(<AboutPage />)
expect(screen.getByText('无法获取后端版本')).toBeInTheDocument()
expect(screen.getByText(/后端版本接口暂时不可用/)).toBeInTheDocument()
})
})

View File

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

View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { getVersionStatus } from '@/utils/version'
describe('getVersionStatus', () => {
it('returns matched when versions are equal', () => {
expect(getVersionStatus('1.2.3', { version: '1.2.3', commit: 'abc', buildTime: 'now' }).kind).toBe('matched')
})
it('returns mismatched when release versions differ', () => {
expect(getVersionStatus('1.2.3', { version: '1.2.4', commit: 'abc', buildTime: 'now' }).kind).toBe('mismatched')
})
it('returns unknown for dev or unknown versions', () => {
expect(getVersionStatus('dev', { version: '1.2.3', commit: 'abc', buildTime: 'now' }).kind).toBe('unknown')
expect(getVersionStatus('1.2.3', { version: 'unknown', commit: 'abc', buildTime: 'now' }).kind).toBe('unknown')
})
it('returns unavailable on request failure', () => {
expect(getVersionStatus('1.2.3', undefined, true).kind).toBe('unavailable')
})
})

View File

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

View File

@@ -0,0 +1,6 @@
import type { BackendVersion } from '@/types'
import { request } from './client'
export async function getBackendVersion(): Promise<BackendVersion> {
return request<BackendVersion>('GET', '/api/version')
}

View File

@@ -9,6 +9,7 @@ import {
ChevronRightIcon,
} from 'tdesign-icons-react'
import { Layout, Menu, Button } from 'tdesign-react'
import { APP_NAME } from '@/constants/app'
const { MenuItem } = Menu
@@ -22,7 +23,7 @@ export function AppLayout() {
if (location.pathname === '/stats') return '用量统计'
if (location.pathname === '/settings') return '设置'
if (location.pathname === '/about') return '关于'
return 'AI Gateway'
return APP_NAME
}
const asideWidth = collapsed ? '64px' : '232px'
@@ -52,15 +53,18 @@ export function AppLayout() {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
fontSize: '1.25rem',
fontWeight: 600,
}}
>
{!collapsed && 'AI Gateway'}
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 28, height: 28 }} />
{!collapsed && APP_NAME}
</div>
}
operations={
<Button
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
variant='text'
shape='square'
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}

View File

@@ -0,0 +1,4 @@
export const APP_NAME = 'Nex'
export const APP_DESCRIPTION = 'AI Gateway - 统一的大模型 API 网关'
export const APP_WEBSITE = 'https://github.com/nex/gateway'
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || 'dev'

View File

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

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import * as api from '@/api/version'
export const versionKeys = {
backend: ['version', 'backend'] as const,
}
export function useBackendVersion() {
return useQuery({
queryKey: versionKeys.backend,
queryFn: api.getBackendVersion,
})
}

View File

@@ -1,30 +1,77 @@
import { Card } from 'tdesign-react'
import { Alert, Card, Descriptions, Link, Space, Tag } from 'tdesign-react'
import { APP_DESCRIPTION, APP_NAME, APP_VERSION, APP_WEBSITE } from '@/constants/app'
import { useBackendVersion } from '@/hooks/useVersion'
import type { VersionStatusKind } from '@/types'
import { getVersionStatus } from '@/utils/version'
const statusTheme: Record<VersionStatusKind, 'success' | 'warning' | 'default'> = {
matched: 'success',
mismatched: 'warning',
unknown: 'default',
unavailable: 'warning',
}
export default function AboutPage() {
const { data: backendVersion, isError, isLoading } = useBackendVersion()
const versionStatus = getVersionStatus(APP_VERSION, backendVersion, isError)
return (
<Card bordered={false}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4rem 0',
}}
>
<h1 style={{ margin: 0, fontSize: '2rem' }}>Nex</h1>
<p style={{ margin: '0.5rem 0 0', color: 'var(--td-text-color-secondary)', fontSize: '1rem' }}>
AI Gateway - API
</p>
<a
href='https://github.com/nex/gateway'
target='_blank'
rel='noopener noreferrer'
style={{ marginTop: '1rem', color: 'var(--td-brand-color)' }}
<div style={{ display: 'grid', gap: 'var(--td-comp-margin-l)' }}>
<Card bordered={false} hoverShadow>
<Space align='center' size='large'>
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 56, height: 56 }} />
<div>
<h1 style={{ margin: 0, fontSize: '2rem' }}>{APP_NAME}</h1>
<p style={{ margin: '0.5rem 0 0', color: 'var(--td-text-color-secondary)', fontSize: '1rem' }}>
{APP_DESCRIPTION}
</p>
</div>
</Space>
</Card>
<div style={{ position: 'relative' }}>
<Card bordered={false} hoverShadow>
{(versionStatus.kind === 'mismatched' || versionStatus.kind === 'unavailable') && (
<Alert
theme='warning'
message={versionStatus.description}
style={{ marginBottom: 'var(--td-comp-margin-l)' }}
/>
)}
<Descriptions
column={2}
itemLayout='vertical'
items={[
{ label: '前端版本', content: APP_VERSION },
{ label: '后端版本', content: isLoading ? '加载中' : backendVersion?.version || 'unknown' },
{ label: '后端提交', content: isLoading ? '加载中' : backendVersion?.commit || 'unknown' },
{ label: '后端构建时间', content: isLoading ? '加载中' : backendVersion?.buildTime || 'unknown' },
]}
/>
</Card>
<Tag
theme={statusTheme[versionStatus.kind]}
variant='light'
shape='round'
style={{ position: 'absolute', top: 'var(--td-comp-paddingLR-l)', right: 'var(--td-comp-paddingLR-l)' }}
>
https://github.com/nex/gateway
</a>
{versionStatus.label}
</Tag>
</div>
</Card>
<Card bordered={false} hoverShadow>
<Space breakLine size='large'>
<Link href={APP_WEBSITE} target='_blank' theme='primary'>
GitHub
</Link>
<Link href={APP_WEBSITE} target='_blank' theme='primary'>
</Link>
<Link href={`${APP_WEBSITE}/blob/main/LICENSE`} target='_blank' theme='primary'>
License
</Link>
</Space>
</Card>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'
import { Button, Table, Tag, Popconfirm, Space, Typography, MessagePlugin } from 'tdesign-react'
import { useModels, useDeleteModel } from '@/hooks/useModels'
import type { Model } from '@/types'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
@@ -18,8 +18,32 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
title: '统一模型 ID',
colKey: 'unifiedId',
width: 250,
ellipsis: true,
cell: ({ row }) => row.unifiedId || `${row.providerId}/${row.modelName}`,
cell: ({ row }) => {
const id = row.unifiedId || `${row.providerId}/${row.modelName}`
return id ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{id}
</span>
<Typography.Text
style={{ flexShrink: 0 }}
copyable={{
text: id,
onCopy: () => MessagePlugin.success('已复制统一模型 ID'),
}}
>
{''}
</Typography.Text>
</span>
) : null
},
},
{
title: '模型名称',

View File

@@ -1,4 +1,4 @@
import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'
import { Button, Table, Tag, Popconfirm, Space, Card, Typography, MessagePlugin } from 'tdesign-react'
import type { Provider, Model } from '@/types'
import { ModelTable } from './ModelTable'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
@@ -32,7 +32,30 @@ export function ProviderTable({
{
title: 'Base URL',
colKey: 'baseUrl',
ellipsis: true,
cell: ({ row }) =>
row.baseUrl ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{row.baseUrl}
</span>
<Typography.Text
style={{ flexShrink: 0 }}
copyable={{
text: row.baseUrl,
onCopy: () => MessagePlugin.success('已复制 Base URL'),
}}
>
{''}
</Typography.Text>
</span>
) : null,
},
{
title: '协议',
@@ -47,7 +70,30 @@ export function ProviderTable({
{
title: 'API Key',
colKey: 'apiKey',
ellipsis: true,
cell: ({ row }) =>
row.apiKey ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{row.apiKey}
</span>
<Typography.Text
style={{ flexShrink: 0 }}
copyable={{
text: row.apiKey,
onCopy: () => MessagePlugin.success('已复制 API Key'),
}}
>
{''}
</Typography.Text>
</span>
) : null,
},
{
title: '状态',

View File

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

View File

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

View File

@@ -62,6 +62,20 @@ export interface StatsQueryParams {
endDate?: string
}
export interface BackendVersion {
version: string
commit: string
buildTime: string
}
export type VersionStatusKind = 'matched' | 'mismatched' | 'unknown' | 'unavailable'
export interface VersionStatus {
kind: VersionStatusKind
label: string
description: string
}
export class ApiError extends Error {
status: number
code?: string
@@ -78,3 +92,49 @@ export interface ApiErrorResponse {
error: string
code?: string
}
export interface StartupServerConfig {
port: number
readTimeout: string
writeTimeout: string
}
export interface StartupDatabaseConfig {
driver: string
path: string
host: string
port: number
user: string
password: string
dbname: string
maxIdleConns: number
maxOpenConns: number
connMaxLifetime: string
}
export interface StartupLogConfig {
level: string
path: string
maxSize: number
maxBackups: number
maxAge: number
compress: boolean
}
export interface StartupConfig {
server: StartupServerConfig
database: StartupDatabaseConfig
log: StartupLogConfig
}
export interface StartupSettings {
mode: 'server' | 'desktop'
editable: boolean
configPath: string
restartRequired: boolean
config: StartupConfig
}
export type SaveStartupSettingsInput = {
config: StartupConfig
}

View File

@@ -0,0 +1,42 @@
import type { BackendVersion, VersionStatus } from '@/types'
function isUnknownVersion(version: string | undefined): boolean {
const normalized = version?.trim().toLowerCase()
return !normalized || normalized === 'dev' || normalized === 'unknown'
}
export function getVersionStatus(
frontendVersion: string,
backendVersion?: BackendVersion,
hasError = false
): VersionStatus {
if (hasError) {
return {
kind: 'unavailable',
label: '无法获取后端版本',
description: '后端版本接口暂时不可用,当前仅展示前端版本。',
}
}
if (!backendVersion || isUnknownVersion(frontendVersion) || isUnknownVersion(backendVersion.version)) {
return {
kind: 'unknown',
label: '无法判断版本',
description: '当前处于开发构建或版本信息不完整,不判定为版本错误。',
}
}
if (frontendVersion === backendVersion.version) {
return {
kind: 'matched',
label: '版本一致',
description: '前端和后端来自同一版本构建。',
}
}
return {
kind: 'mismatched',
label: '版本不一致',
description: '前后端版本不同,该状态用于部署诊断,不影响当前功能使用。',
}
}

View File

@@ -3,4 +3,5 @@ go 1.26.2
use (
backend
embedfs
versionctl
)

View File

@@ -4,8 +4,6 @@ cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQ
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -14,6 +12,7 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cristalhq/acmd v0.12.0/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
@@ -26,8 +25,6 @@ github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6v
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
@@ -48,6 +45,7 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
@@ -66,8 +64,10 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=

View File

@@ -1,5 +0,0 @@
pre-commit:
commands:
backend-lint:
glob: "backend/**/*.go"
run: cd backend && go tool golangci-lint run --new-from-rev HEAD ./...

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-04

View File

@@ -0,0 +1,105 @@
## Context
Nex 已经具备统一版本源:仓库根目录 `VERSION` 是权威版本,`versionctl sync` 会同步 `frontend/package.json` 和前端 `.env.*` 中的 `VITE_APP_VERSION`Go 二进制构建通过 `-ldflags` 注入 `backend/pkg/buildinfo`。当前缺口在运行时可见性:前端只能知道自身构建版本,无法从后端获取 server/desktop 二进制版本,也无法判断前后端是否来自同一版本。
前端侧边栏当前在 `AppLayout` 中以 `AI Gateway` 作为品牌文字,折叠后 logo 区域为空HTML title 也仍为 `AI Gateway`。About 页面只展示名称、描述和链接,缺少版本、构建信息、状态反馈和现代化信息层次。图标资源方面,前端 public 目录维护独立 SVG favicon 和未使用的 `icons.svg`,而桌面托盘和打包资源已经使用仓库 `assets/icon.png``assets/icon.ico``assets/icon.icns`。desktop 代码中的 `appName/appTooltip` 已经是 `Nex`,但现有 `desktop-app` spec 仍保留旧 tooltip 文案,需要借本次 change 同步。
本变更跨越前端页面、前端 API、后端管理接口、desktop 静态资源服务、README 和 OpenSpec因此需要先固定实现边界避免实现阶段出现多个版本来源或重复图标资源。
## Goals / Non-Goals
**Goals:**
- 统一用户可见应用名称为 `Nex`覆盖前端侧边栏、HTML title 和相关测试断言,`AI Gateway` 作为产品描述保留。
- 侧边栏展开态显示统一图标和 `Nex`,折叠态仍显示统一图标。
- 前端 favicon/public 图标复用仓库 `assets/icon.png`,清理不再使用的 SVG public 图标资源。
- About 页面展示前端版本、后端版本、后端 commit、后端 build_time 和版本匹配状态。
- 后端在 server 和 desktop 模式下都提供 `GET /api/version`
- 明确并落地前端样式优先级TDesign 组件 props、TDesign tokens、SCSS。
- 补充单元测试、组件测试、后端路由测试和必要的 E2E 覆盖。
- 同步更新根 README、frontend README、backend README。
**Non-Goals:**
- 不改变版本权威来源,仍以根目录 `VERSION` 为唯一版本源。
- 不新增前端或后端依赖。
- 不引入用户认证、权限控制或配置项开关。
- 不让 About 页面阻断业务功能;版本不一致仅提示,不禁止使用。
- 不重构所有已有内联样式,只处理本次触达的品牌和 About 页面相关样式。
## Decisions
### Decision: 后端版本信息复用 buildinfo 并通过 `/api/version` 暴露
后端 SHALL 新增 `GET /api/version` 管理接口,响应字段为 `version``commit``build_time`,字段值来自 `buildinfo.Version()``buildinfo.Commit()``buildinfo.BuildTime()`
选择 `/api/version` 的原因:管理接口已经统一使用 `/api/*`,前端 API 客户端也以该路径族访问后端;`/health` 应保持存活检查语义,不混入构建元数据。选择 `buildinfo` 的原因Makefile 已在 server 和 desktop 构建中注入版本、commit 和 buildTime运行时读取 `VERSION` 会破坏发布产物独立性。
备选方案:扩展 `/health`。放弃原因是健康检查会被监控系统频繁调用,混入构建信息容易模糊职责,并且前端语义上需要的是管理信息而不是存活探针。
### Decision: server 和 desktop 分别注册同一个版本接口
server 和 desktop 当前拥有独立 `setupRoutes`,因此两个入口都 MUST 注册 `GET /api/version`。desktop 还需要确保该路径不会落入 SPA fallback 或协议代理路由。
备选方案:只在 server 注册。放弃原因是桌面应用使用独立路由装配和静态资源服务,用户在 desktop 模式下同样需要 About 页面展示后端版本。
### Decision: 前端版本使用 `VITE_APP_VERSION`,不运行时读取 package.json
前端 SHALL 使用 `import.meta.env.VITE_APP_VERSION` 作为自身版本,缺失时降级为 `dev``unknown` 显示。该值由版本同步工具写入 `.env.*`,符合现有构建版本注入规则。
备选方案:在前端运行时请求或打包 `package.json`。放弃原因是会产生第二个版本读取路径,并可能在 desktop 嵌入构建、生产构建和测试环境中产生不一致。
### Decision: About 页面只提示版本一致性,不阻断功能
About 页面 SHALL 根据前端版本和后端版本显示状态:一致、不一致、开发构建无法判断、后端版本获取失败。状态通过 TDesign `Tag` 和必要时的 `Alert` 展示。
判断规则:前端版本等于后端版本时为一致;任一版本为 `dev``unknown`、空值时为无法判断;请求失败时为后端版本获取失败;其余不相等时为不一致。
备选方案:版本不一致时阻断或弹窗提示。放弃原因是版本检查用于部署诊断,不能影响现有配置和代理能力。
### Decision: 图标资源统一到 `assets/icon.png` 并固定运行时路径
前端 public 图标 SHALL 由仓库 `assets/icon.png` 派生,实施时将根目录 `assets/icon.png` 复制为 `frontend/public/icon.png`,前端入口 SHALL 使用 `/icon.png` 作为 PNG favicon 路径。`frontend/public/favicon.svg` 不再作为 favicon 来源,`frontend/public/icons.svg` 经确认未被引用后 SHALL 删除。
Vite 会将 `frontend/public/icon.png` 输出到 dist 根目录,因此 desktop 静态服务 SHALL 显式服务 `/icon.png` 并读取嵌入 dist 中的 `icon.png`。README SHALL 标注 `frontend/public/icon.png` 来源于根目录 `assets/icon.png`,后续更新应用图标时应以根目录资源为准并同步 public 镜像。
备选方案:继续保留 SVG favicon。放弃原因是会继续存在两套应用图标来源与用户要求的统一资源方向不一致。
### Decision: 前端样式优先级写入 README 并指导实现
实现视觉效果时,前端 SHALL 优先使用 TDesign 组件 props例如 `Card``bordered``hoverShadow``headerBordered``Tag``theme``variant``shape``Row/Col` 的响应式 props。组件 props 不足时使用 TDesign tokens例如 `var(--td-text-color-secondary)``var(--td-brand-color)`。只有 props 和 tokens 无法表达布局、响应式或品牌细节时才使用 SCSS。
备选方案:直接新增 SCSS Modules 完成全部视觉。放弃原因是项目已经在 OpenSpec 中要求优先使用 TDesign 样式体系,过多 SCSS 会增加覆盖组件内部样式的风险。
### Decision: About 页面采用信息面板布局
About 页面 SHALL 以三个独立 `Card` 垂直排列呈现:顶部品牌卡片展示图标、`Nex`、产品描述中部版本信息卡片展示前端版本、后端版本、commit、build_time版本状态 Tag 以绝对定位浮动在卡片右上角;下部链接卡片展示外部链接入口。
布局示意:
```text
┌─────────────────────────────────────────────┐
│ Icon Nex │
│ AI Gateway - 统一的大模型 API 网关 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 前端版本 后端版本 构建信息 [Tag] │
│ v0.1.0 v0.1.0 commit/time │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ GitHub / 文档 / License │
└─────────────────────────────────────────────┘
```
### Decision: desktop-app spec 同步现有 Nex tooltip
desktop 代码已经通过 `appName = "Nex"``appTooltip = appName` 使用统一应用名称,现有 `desktop-app` spec 中 tooltip 仍写 `AI Gateway`。本次 change 已触达 `desktop-app` capability因此 delta spec SHALL 同步修正托盘 tooltip 要求为 `Nex`,避免归档后主 spec 与当前代码继续漂移。
## Risks / Trade-offs
- `/api/version` 在未通过 Makefile 运行的本地 Go 进程中可能返回 `dev/unknown/unknown` → About 页面将其视为开发构建,显示“无法判断”而不是错误。
- desktop 与 server 路由重复维护,可能漏注册 → 任务中明确分别添加路由测试,覆盖两个入口。
- 前端 public PNG 与根 `assets/icon.png` 可能漂移 → README 明确来源;实现阶段复制 `assets/icon.png``frontend/public/icon.png`,并通过构建验证确认 `/icon.png` 可用。
- 删除 `frontend/public/icons.svg` 可能影响隐藏引用 → 已通过全文搜索未发现引用;实现阶段删除前再次全文确认,删除后运行前端测试和构建验证。
- 样式优先级与现有 README “SCSS Modules” 表述可能冲突 → 本变更同步更新 README以 TDesign props/tokens 优先作为新的明确规则。
- 版本不一致提示可能被误解为严重故障 → 文案应说明该状态用于部署诊断,不影响当前功能使用。

View File

@@ -0,0 +1,34 @@
## Why
当前前端侧边栏仍显示旧的 `AI Gateway` 文案折叠后品牌区域为空About 页面信息展示较简陋,无法展示和判断前后端构建版本是否匹配。同时前端 public 图标资源与仓库统一的 `assets/icon.png` 不一致,并存在未使用的 SVG 图标资源,增加了品牌和资源维护成本。
## What Changes
- 将前端侧边栏品牌统一为 `Nex`,展开时显示统一图标和应用名称,折叠时仍显示图标。
- 将前端 favicon/public 图标统一复用仓库 `assets/icon.png`,运行时统一使用 `/icon.png`;替换当前 `/favicon.svg` 引用并清理未使用的 `frontend/public/icons.svg` 资源。
- 将前端 HTML 标题等用户可见应用名称同步为 `Nex`,保留 `AI Gateway` 作为产品描述。
- 重新设计 About 页面信息结构,使用三个独立卡片分别展示品牌、版本信息和外部链接,版本状态 Tag 浮动在版本信息卡片右上角。
- 新增后端管理接口 `GET /api/version`,暴露构建注入的 `version``commit``build_time`,供前端判断前后端版本一致性。
- 在 server 和 desktop 两种启动模式下都注册版本接口,并确保 desktop 静态资源路由支持新的 PNG 图标路径。
- 明确前端样式优先级TDesign 组件 props 优先,其次使用 TDesign tokens最后才在无法通过前两者表达时使用 SCSS并同步更新 README。
- 为菜单品牌、About 页面、版本接口、版本匹配状态和资源清理补充测试。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `frontend`: 侧边栏品牌标识、折叠态 logo、HTML 标题、前端样式优先级和 public 图标资源使用规则发生变化。
- `about-page`: About 页面从简单品牌信息扩展为现代化信息面板,并展示前后端版本与匹配状态。
- `repository-versioning`: 后端需要通过管理接口暴露构建版本信息,前端需要使用构建注入版本与后端版本进行一致性判断。
- `desktop-app`: desktop 模式需要支持新的 PNG 图标静态资源路径,保证版本接口作为 API 路由处理,并同步现有 `Nex` 托盘 tooltip 规范。
## Impact
- 前端:`frontend/src/components/AppLayout``frontend/src/pages/About``frontend/src/api``frontend/src/hooks``frontend/src/types``frontend/index.html``frontend/public`、前端测试和 E2E 测试。
- 后端:`backend/internal/handler``backend/cmd/server``backend/cmd/desktop`、后端测试。
- 文档与规范:根 `README.md``frontend/README.md``backend/README.md`、相关 OpenSpec specs。
- API新增 `GET /api/version` 管理接口;不引入新依赖。

View File

@@ -0,0 +1,78 @@
## MODIFIED Requirements
### Requirement: 关于页面
前端 SHALL 提供现代化关于页面,使用 TDesign 组件展示项目品牌信息、项目链接、前端版本、后端版本和版本匹配状态。
#### Scenario: 显示关于页面
- **WHEN** 用户访问 `/about` 路径
- **THEN** 前端 SHALL 显示关于页面
- **THEN** 页面 SHALL 展示应用名称 `Nex`
- **THEN** 页面 SHALL 展示应用描述 `AI Gateway - 统一的大模型 API 网关`
- **THEN** 页面 SHALL 展示项目链接 `https://github.com/nex/gateway`
#### Scenario: 页面布局
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 使用三个独立 TDesign Card 组件分别承载品牌区、版本信息区和链接区
- **THEN** 三个 Card SHALL 使用 grid 布局垂直排列
- **THEN** 每个 Card SHALL 设置 `bordered={false}``hoverShadow`
- **THEN** Card SHALL 使用 TDesign 组件 props 和 tokens 完成主要视觉效果
#### Scenario: 品牌卡片
- **WHEN** 渲染品牌卡片
- **THEN** 卡片 SHALL 展示应用图标、应用名称 `Nex` 和产品描述
#### Scenario: 版本信息卡片
- **WHEN** 渲染版本信息卡片
- **THEN** 版本状态 Tag SHALL 以绝对定位浮动在卡片右上角,不占据内容布局空间
- **THEN** 版本状态 Tag SHALL 使用 TDesign Tag 的 `theme``variant``shape` props
- **THEN** 卡片 SHALL 展示前端版本、后端版本、后端提交和后端构建时间
#### Scenario: 链接卡片
- **WHEN** 渲染链接卡片
- **THEN** 卡片 SHALL 展示项目外部链接
#### Scenario: 展示前端版本
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 显示前端版本号
- **THEN** 前端版本号 SHALL 来源于构建注入的 `VITE_APP_VERSION`
- **THEN** 当前端版本号缺失时页面 SHALL 显示开发或未知版本状态
#### Scenario: 展示后端版本
- **WHEN** 渲染关于页面且后端版本接口请求成功
- **THEN** 页面 SHALL 显示后端 `version`
- **THEN** 页面 SHALL 显示后端 `commit`
- **THEN** 页面 SHALL 显示后端 `build_time`
#### Scenario: 判断版本一致
- **WHEN** 前端版本号与后端版本号相同
- **THEN** 页面 SHALL 显示版本一致状态
- **THEN** 版本状态 SHALL 使用 TDesign Tag 展示成功语义
#### Scenario: 判断版本不一致
- **WHEN** 前端版本号与后端版本号不同且两者均为可判断的发布版本
- **THEN** 页面 SHALL 显示版本不一致状态
- **THEN** 页面 SHALL 展示提示信息说明该状态用于部署诊断
- **THEN** 页面 SHALL NOT 阻断用户使用其他功能
#### Scenario: 后端版本无法判断
- **WHEN** 后端版本号为 `dev``unknown` 或空值
- **THEN** 页面 SHALL 显示开发构建或无法判断状态
- **THEN** 页面 SHALL NOT 将该状态显示为版本错误
#### Scenario: 后端版本获取失败
- **WHEN** 请求后端版本接口失败
- **THEN** 页面 SHALL 显示无法获取后端版本的状态
- **THEN** 页面 SHALL 保留前端版本信息
- **THEN** 页面 SHALL NOT 因版本接口失败而崩溃

View File

@@ -0,0 +1,91 @@
## MODIFIED Requirements
### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
#### Scenario: 托盘图标显示
- **WHEN** 桌面应用启动成功
- **THEN** 系统根据平台加载正确的图标格式
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`
- **AND** 托盘图标 tooltip 显示 `Nex`
#### Scenario: 托盘菜单显示
- **WHEN** 用户点击托盘图标(左键或右键)
- **THEN** 显示托盘菜单
- **AND** 菜单包含"打开管理界面"选项
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
- **AND** 菜单包含"退出"选项
#### Scenario: 打开管理界面
- **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
#### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:9826`
#### Scenario: 退出应用
- **WHEN** 用户点击托盘菜单"退出"
- **THEN** 系统优雅关闭后端服务
- **AND** 托盘图标消失
- **AND** 应用进程退出
### Requirement: 静态文件服务
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
#### Scenario: API 请求路由
- **WHEN** 请求路径以 `/api/``/health` 开头
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
#### Scenario: 版本接口路由
- **WHEN** desktop 模式收到 `GET /api/version` 请求
- **THEN** 请求 SHALL 由版本信息 handler 处理
- **THEN** 响应 SHALL 为 API JSON 响应
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: 协议代理请求路由
- **WHEN** 请求路径以 `/openai/``/anthropic/` 开头
- **THEN** 请求 SHALL 被视为协议代理请求或返回 API 风格 404
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: OpenAI 代理路由
- **WHEN** desktop 模式收到 `/openai/v1/chat/completions` 请求
- **THEN** 请求 SHALL 进入 ProxyHandler
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `openai`
#### Scenario: Anthropic 代理路由
- **WHEN** desktop 模式收到 `/anthropic/v1/messages` 请求
- **THEN** 请求 SHALL 进入 ProxyHandler
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `anthropic`
#### Scenario: 静态资源路由
- **WHEN** 请求路径为 `/assets/*`
- **THEN** 返回嵌入的前端静态资源文件
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: PNG Favicon 路由
- **WHEN** 请求路径为 `/icon.png`
- **THEN** 返回来源于统一应用图标的 PNG favicon 资源
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: SPA 路由回退
- **WHEN** 请求路径不匹配任何 API、协议代理或静态资源路由
- **THEN** 返回 `index.html`(支持前端 SPA 路由)

View File

@@ -0,0 +1,121 @@
## MODIFIED Requirements
### Requirement: 样式体系
前端样式 SHALL 优先使用 TDesign 组件 props其次使用 TDesign tokens最后在前两者无法表达所需效果时使用 SCSS 作为补充工具。
#### Scenario: TDesign 组件 Props 优先
- **WHEN** 实现组件视觉效果
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props如 color、trend、hoverShadow、stripe、variant、shape、headerBordered、gutter 等)
- **THEN** 前端 SHALL NOT 通过 CSS 类名覆盖组件内部样式
#### Scenario: TDesign Tokens 作为第二优先级
- **WHEN** 组件 props 无法完整表达颜色、边框、背景、间距等视觉细节
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(`var(--td-*)`)表达样式
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 `#fff``#e7e7e7``#999` 等颜色值
#### Scenario: CSS Variables 主题微调
- **WHEN** 需要调整全局视觉风格
- **THEN** 前端 SHALL 通过 `:root` 中声明 TDesign CSS Variables`--td-*`)进行覆盖
- **THEN** 前端 SHALL NOT 使用 `!important` 或高优先级选择器覆盖组件样式
#### Scenario: SCSS 兜底使用
- **WHEN** TDesign 组件 props 和 TDesign tokens 均无法满足布局、响应式或品牌视觉需求
- **THEN** 前端 MAY 使用 SCSS 作为补充
- **THEN** SCSS SHALL 只承载必要的补充样式
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css
### Requirement: 提供响应式布局
前端 SHALL 使用 TDesign Layout 提供侧边栏导航布局。
#### Scenario: 桌面布局
- **WHEN** 在桌面屏幕上查看前端
- **THEN** 布局 SHALL 使用 TDesign `Layout.Aside` + `Menu`
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
- **THEN** Menu 组件 SHALL 使用 `logo` prop 显示品牌标识
- **THEN** Menu 组件 SHALL 使用 `operations` prop 在底部显示操作区域
- **THEN** Menu 组件 SHALL 支持 `collapsed` 折叠功能
#### Scenario: 侧边栏折叠布局
- **WHEN** 用户折叠侧边栏
- **THEN** 侧边栏 SHALL 使用折叠宽度 64px
- **THEN** Menu logo 区域 SHALL 保留应用图标
- **THEN** Menu logo 区域 SHALL 隐藏应用名称文字
- **THEN** Menu logo 区域 SHALL NOT 显示为空白
#### Scenario: 页面内容区域
- **WHEN** 显示页面内容
- **THEN** 内容区域 SHALL 在 `Layout.Content` 中渲染
- **THEN** 页面之间 SHALL 通过 React Router Outlet 渲染
#### Scenario: Header 区域
- **WHEN** 渲染页面 Header
- **THEN** Header SHALL 仅显示当前页面标题
- **THEN** Header SHALL 不包含导航菜单
- **THEN** Header 背景色 SHALL 使用 `var(--td-bg-color-container)` Token
- **THEN** Header 底部分割线 SHALL 使用 `var(--td-component-stroke)` Token
### Requirement: 提供侧边栏导航
前端 SHALL 使用 TDesign `Layout.Aside` 提供侧边栏导航。
#### Scenario: 侧边栏内容
- **WHEN** 渲染侧边栏
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
- **THEN** 侧边栏 SHALL 包含导航菜单
- **THEN** 导航菜单项 SHALL 包含供应商管理ServerIcon 图标、用量统计ChartLineIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
#### Scenario: 侧边栏折叠品牌显示
- **WHEN** 侧边栏处于折叠状态
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标
- **THEN** 侧边栏顶部 SHALL 隐藏 `Nex` 文案
- **THEN** 侧边栏顶部 SHALL NOT 为空白
#### Scenario: 导航菜单交互
- **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计"
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"设置"
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"关于"
- **THEN** 前端 SHALL 导航到 `/about` 并高亮当前菜单项
## ADDED Requirements
### Requirement: 统一 public 图标资源
前端 SHALL 使用仓库统一应用图标作为 public favicon 和品牌图标来源。
#### Scenario: 使用 PNG favicon
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面 SHALL 使用 `/icon.png` 作为 PNG favicon 路径
- **THEN** `frontend/public/icon.png` SHALL 来源于仓库根目录 `assets/icon.png`
- **THEN** 页面 SHALL NOT 引用独立维护的 SVG favicon
#### Scenario: HTML 标题使用统一应用名称
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面标题 SHALL 使用 `Nex` 作为应用名称
- **THEN** 页面标题 SHALL NOT 使用旧应用名称 `AI Gateway`
#### Scenario: 清理未使用 public SVG 图标
- **WHEN** public 目录中的 SVG 图标资源没有被前端代码、HTML 或 desktop 静态服务引用
- **THEN** 前端 SHALL 删除该未使用 SVG 图标资源
- **THEN** 前端 SHALL NOT 保留未使用的 `frontend/public/icons.svg`

View File

@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: 后端运行时版本查询
系统 SHALL 通过管理接口暴露后端运行时构建版本信息,供前端和用户诊断前后端版本一致性。
#### Scenario: 查询后端版本信息
- **WHEN** 客户端请求 `GET /api/version`
- **THEN** 后端 SHALL 返回 HTTP 200
- **THEN** 响应 JSON SHALL 包含 `version` 字段
- **THEN** 响应 JSON SHALL 包含 `commit` 字段
- **THEN** 响应 JSON SHALL 包含 `build_time` 字段
#### Scenario: 版本信息来源于构建注入
- **WHEN** 后端返回版本信息
- **THEN** `version` SHALL 来源于 `buildinfo.Version()`
- **THEN** `commit` SHALL 来源于 `buildinfo.Commit()`
- **THEN** `build_time` SHALL 来源于 `buildinfo.BuildTime()`
- **THEN** 后端 SHALL NOT 在运行时读取仓库 `VERSION` 文件作为接口响应来源
#### Scenario: 本地开发构建降级值
- **WHEN** 后端未通过构建参数注入版本元数据
- **THEN** 后端版本接口 SHALL 返回 buildinfo 的默认降级值
- **THEN** 前端 SHALL 能够展示该降级值而不崩溃
### Requirement: 前后端版本一致性诊断
系统 SHALL 支持前端使用自身构建版本和后端运行时版本进行一致性诊断。
#### Scenario: 前端读取构建版本
- **WHEN** 前端渲染版本信息
- **THEN** 前端 SHALL 使用 `VITE_APP_VERSION` 作为前端版本号
- **THEN** `VITE_APP_VERSION` SHALL 继续由版本同步流程保持与 `VERSION` 一致
#### Scenario: 诊断版本匹配
- **WHEN** 前端版本号和后端版本号均可判断且完全相同
- **THEN** 前端 SHALL 将版本状态判定为一致
#### Scenario: 诊断版本不匹配
- **WHEN** 前端版本号和后端版本号均可判断且不相同
- **THEN** 前端 SHALL 将版本状态判定为不一致
- **THEN** 前端 SHALL 将该状态作为诊断提示展示
#### Scenario: 诊断版本不可判断
- **WHEN** 任一版本号为空、`dev``unknown`
- **THEN** 前端 SHALL 将版本状态判定为无法判断
- **THEN** 前端 SHALL NOT 将该状态判定为版本不一致

View File

@@ -0,0 +1,57 @@
## 1. 后端版本接口
- [x] 1.1 新增后端版本信息 handler响应 `version``commit``build_time` 并复用 `buildinfo`
- [x] 1.2 在 server 入口注册 `GET /api/version`
- [x] 1.3 在 desktop 入口注册 `GET /api/version`
- [x] 1.4 为版本 handler 增加单元测试覆盖响应字段和默认降级值
- [x] 1.5 为 server 路由增加测试覆盖 `GET /api/version`
- [x] 1.6 为 desktop 路由增加测试覆盖 `GET /api/version` 不落入 SPA fallback
## 2. 前端版本 API 与状态判断
- [x] 2.1 新增前端版本信息类型定义,包含后端 `version``commit``buildTime`
- [x] 2.2 新增前端版本 API 调用 `GET /api/version`
- [x] 2.3 新增 TanStack Query hook 获取后端版本信息
- [x] 2.4 集中定义前端 `APP_NAME`、产品描述和 `VITE_APP_VERSION` 降级值
- [x] 2.5 实现前后端版本状态判断逻辑,覆盖一致、不一致、无法判断和请求失败
- [x] 2.6 为版本 API 和版本状态判断增加前端单元测试
## 3. 品牌与图标资源
- [x] 3.1 将侧边栏品牌名称从 `AI Gateway` 替换为 `Nex`
- [x] 3.2 使用统一应用图标渲染侧边栏 logo折叠态保留图标并隐藏文字
- [x] 3.3 为侧边栏折叠按钮补充清晰的无障碍标签
- [x] 3.4 将根目录 `assets/icon.png` 复制为 `frontend/public/icon.png`
- [x] 3.5 将前端 HTML favicon 路径改为 `/icon.png` 并将 HTML title 改为 `Nex`
- [x] 3.6 将 desktop 静态文件服务的 `/favicon.svg` 路由替换为 `/icon.png` 路由
- [x] 3.7 删除未使用的 `frontend/public/icons.svg` 和旧 SVG favicon 资源
- [x] 3.8 全文确认不存在未处理的 `icons.svg``favicon.svg` 引用
- [x] 3.9 更新 AppLayout 组件测试覆盖展开态 `Nex`、折叠态图标和 About 菜单项
- [x] 3.10 更新导航 E2E 中旧 `AI Gateway` 品牌断言为 `Nex`
## 4. About 页面改造
- [x] 4.1 重构 About 页面为品牌卡片、版本信息卡片和链接卡片三卡布局
- [x] 4.2 使用 TDesign Card、Tag、Descriptions、Alert、Row/Col 等组件 props 完成主要视觉结构
- [x] 4.3 在 About 页面展示前端版本、后端版本、commit 和 build_time
- [x] 4.4 在 About 页面展示版本一致、不一致、无法判断和请求失败状态
- [x] 4.5 确保版本不一致或接口失败时 About 页面不崩溃且不阻断其他功能
- [x] 4.6 为 About 页面增加组件测试覆盖布局、版本展示和所有版本状态
- [x] 4.7 为 About 页面补充必要的 E2E 导航与版本区域可见性测试
## 5. 文档同步
- [x] 5.1 更新根 README 的管理接口列表,加入 `GET /api/version`
- [x] 5.2 更新根 README 的前端样式技术栈说明,体现 TDesign props、TDesign tokens、SCSS 优先级
- [x] 5.3 更新 backend README 的管理接口文档,说明版本响应字段来源
- [x] 5.4 更新 frontend README 的环境变量说明,加入 `VITE_APP_VERSION`
- [x] 5.5 更新 frontend README 的样式优先级为 TDesign props、TDesign tokens、SCSS
- [x] 5.6 更新 frontend README 的项目结构、About 页面说明和 public 图标来源说明
- [x] 5.7 更新 desktop 相关文档说明 PNG favicon、`/icon.png` 静态路由和 API 版本接口路由
## 6. 验证
- [x] 6.1 运行后端测试,确保 handler、server 和 desktop 路由测试通过
- [x] 6.2 运行前端测试,确保 API、hook、AppLayout 和 About 页面测试通过
- [x] 6.3 运行前端检查和构建,确保 Bun/Vite 构建能加载 PNG favicon
- [x] 6.4 运行 OpenSpec 校验,确保 proposal、design、specs 和 tasks 状态可实施

View File

@@ -8,7 +8,7 @@ context: |
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- backend是使用go开发的后端阅读backend/README.md了解项目架构优先使用公共组件实现功能逻辑优先级官方库>主流三方库>项目公共工具>自行实现)
- frontend是基于bun+vite+typescript开发的前端使用bun作为唯一包管理器严禁使用pnpm、npm
- frontend是基于bun+vite+typescript开发的前端使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行

View File

@@ -2,25 +2,83 @@
## Purpose
TBD - 提供关于页面展示项目品牌信息
TBD - 提供关于页面展示项目品牌信息、版本信息和外部链接
## Requirements
### Requirement: 关于页面
前端 SHALL 提供关于页面,使用 TDesign Card 组件居中展示项目品牌信息(应用名称、描述、项目链接)
前端 SHALL 提供现代化关于页面,使用 TDesign 组件展示项目品牌信息、项目链接、前端版本、后端版本和版本匹配状态
#### Scenario: 显示关于页面
- **WHEN** 用户访问 `/about` 路径
- **THEN** 前端 SHALL 显示关于页面
- **THEN** 页面 SHALL 展示应用名称"Nex"
- **THEN** 页面 SHALL 展示应用描述"AI Gateway - 统一的大模型 API 网关"
- **THEN** 页面 SHALL 展示项目链接"https://github.com/nex/gateway"
- **THEN** 页面 SHALL 展示应用名称 `Nex`
- **THEN** 页面 SHALL 展示应用描述 `AI Gateway - 统一的大模型 API 网关`
- **THEN** 页面 SHALL 展示项目链接 `https://github.com/nex/gateway`
#### Scenario: 页面布局
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 使用 TDesign Card 组件作为容器
- **THEN** Card SHALL 设置 `bordered={false}`
- **THEN** 内容 SHALL 居中展示
- **THEN** 页面 SHALL 使用三个独立 TDesign Card 组件分别承载品牌区、版本信息区和链接区
- **THEN** 三个 Card SHALL 使用 grid 布局垂直排列
- **THEN** 每个 Card SHALL 设置 `bordered={false}``hoverShadow`
- **THEN** Card SHALL 使用 TDesign 组件 props 和 tokens 完成主要视觉效果
#### Scenario: 品牌卡片
- **WHEN** 渲染品牌卡片
- **THEN** 卡片 SHALL 展示应用图标、应用名称 `Nex` 和产品描述
#### Scenario: 版本信息卡片
- **WHEN** 渲染版本信息卡片
- **THEN** 版本状态 Tag SHALL 以绝对定位浮动在卡片右上角,不占据内容布局空间
- **THEN** 版本状态 Tag SHALL 使用 TDesign Tag 的 `theme``variant``shape` props
- **THEN** 卡片 SHALL 展示前端版本、后端版本、后端提交和后端构建时间
#### Scenario: 链接卡片
- **WHEN** 渲染链接卡片
- **THEN** 卡片 SHALL 展示项目外部链接
#### Scenario: 展示前端版本
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 显示前端版本号
- **THEN** 前端版本号 SHALL 来源于构建注入的 `VITE_APP_VERSION`
- **THEN** 当前端版本号缺失时页面 SHALL 显示开发或未知版本状态
#### Scenario: 展示后端版本
- **WHEN** 渲染关于页面且后端版本接口请求成功
- **THEN** 页面 SHALL 显示后端 `version`
- **THEN** 页面 SHALL 显示后端 `commit`
- **THEN** 页面 SHALL 显示后端 `build_time`
#### Scenario: 判断版本一致
- **WHEN** 前端版本号与后端版本号相同
- **THEN** 页面 SHALL 显示版本一致状态
- **THEN** 版本状态 SHALL 使用 TDesign Tag 展示成功语义
#### Scenario: 判断版本不一致
- **WHEN** 前端版本号与后端版本号不同且两者均为可判断的发布版本
- **THEN** 页面 SHALL 显示版本不一致状态
- **THEN** 页面 SHALL 展示提示信息说明该状态用于部署诊断
- **THEN** 页面 SHALL NOT 阻断用户使用其他功能
#### Scenario: 后端版本无法判断
- **WHEN** 后端版本号为 `dev``unknown` 或空值
- **THEN** 页面 SHALL 显示开发构建或无法判断状态
- **THEN** 页面 SHALL NOT 将该状态显示为版本错误
#### Scenario: 后端版本获取失败
- **WHEN** 请求后端版本接口失败
- **THEN** 页面 SHALL 显示无法获取后端版本的状态
- **THEN** 页面 SHALL 保留前端版本信息
- **THEN** 页面 SHALL NOT 因版本接口失败而崩溃

View File

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

View File

@@ -8,20 +8,27 @@
### Requirement: 使用 YAML 配置文件
系统 SHALL 使用 YAML 格式的配置文件。
系统 SHALL 使用 YAML 格式的配置文件,并按入口区分配置文件路径选择能力
#### Scenario: 配置文件路径
#### Scenario: Server 默认配置文件路径
- **WHEN** 应用启动且未指定 `--config` 参数
- **WHEN** server 应用启动且未指定 `--config` 参数
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
- **THEN** SHALL 解析 YAML 格式
#### Scenario: 自定义配置文件路径
#### Scenario: Server 自定义配置文件路径
- **WHEN** 应用启动且指定 `--config /path/to/custom.yaml`
- **WHEN** server 应用启动且指定 `--config /path/to/custom.yaml`
- **THEN** SHALL 从指定路径加载配置文件
- **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml`
#### Scenario: Desktop 固定配置文件路径
- **WHEN** desktop 应用启动
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
- **THEN** SHALL 解析 YAML 格式
- **THEN** SHALL NOT 支持通过 `--config` 指定其他配置文件路径
#### Scenario: 配置文件结构
- **WHEN** 加载配置文件
@@ -30,14 +37,14 @@
### Requirement: 自动生成默认配置
系统 SHALL 在首次使用时自动生成默认配置
系统 SHALL 在配置文件不存在时使用默认配置值,不自动创建配置文件
#### Scenario: 配置文件不存在
- **WHEN** 应用启动且配置文件不存在
- **THEN** SHALL 自动创建配置文件
- **THEN** SHALL 写入默认配置值
- **THEN** SHALL 记录日志提示已创建
- **THEN** SHALL 使用默认配置值
- **THEN** SHALL NOT 自动创建配置文件
- **THEN** SHALL NOT 写入默认配置值到磁盘
#### Scenario: 配置文件已存在
@@ -163,22 +170,36 @@
### Requirement: 配置加载流程
系统 SHALL 实现标准化的配置加载流程。
系统 SHALL 为 server 和 desktop 实现标准化且入口隔离的配置加载流程。
#### Scenario: 加载步骤
#### Scenario: Server 加载步骤
- **WHEN** 应用启动
- **WHEN** server 应用启动
- **THEN** SHALL 按以下顺序加载配置:
1. 解析 CLI 参数(获取 --config 路径)
2. 初始化配置管理器
3. 设置默认值
4. 绑定 CLI 参数
5. 绑定环境变量
6. 读取配置文件(不存在时自动创建
6. 读取配置文件(不存在时使用默认值
7. 反序列化到结构体
8. 验证配置
9. 打印配置摘要
#### Scenario: Desktop 加载步骤
- **WHEN** desktop 应用启动
- **THEN** SHALL 按以下顺序加载配置:
1. 初始化配置管理器
2. 设置默认值
3. 读取默认配置文件 `~/.nex/config.yaml`(不存在时使用默认值)
4. 反序列化到结构体
5. 验证配置
6. 打印配置摘要
- **THEN** SHALL NOT 解析 CLI 参数
- **THEN** SHALL NOT 绑定环境变量
- **THEN** SHALL NOT 允许 CLI 参数覆盖配置文件路径
#### Scenario: 加载失败处理
- **WHEN** 配置加载过程中发生错误
@@ -188,25 +209,25 @@
### Requirement: 配置优先级管理
系统 SHALL 实现明确的配置优先级机制。
系统 SHALL 为不同入口实现明确的配置优先级机制。
#### Scenario: 优先级顺序
#### Scenario: Server 优先级顺序
- **WHEN** 同一配置项在多个配置源中设置
- **WHEN** 同一配置项在多个 server 配置源中设置
- **THEN** SHALL 按以下优先级顺序(从高到低):
1. CLI 参数
2. 环境变量
3. 配置文件
4. 默认值
#### Scenario: CLI 参数最高优先级
#### Scenario: Server CLI 参数最高优先级
- **WHEN** 配置文件设置 `server.port: 9826`
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
- **AND** CLI 参数设置 `--server-port 8080`
- **AND** server CLI 参数设置 `--server-port 8080`
- **THEN** SHALL 使用 CLI 参数值 8080
#### Scenario: 环境变量次高优先级
#### Scenario: Server 环境变量次高优先级
- **WHEN** 配置文件设置 `server.port: 9826`
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
@@ -227,21 +248,35 @@
- **AND** 未设置 CLI 参数
- **THEN** SHALL 使用默认值
#### Scenario: 部分配置覆盖
#### Scenario: Server 部分配置覆盖
- **WHEN** 配置文件设置完整配置
- **AND** CLI 参数仅覆盖部分配置项
- **AND** server CLI 参数仅覆盖部分配置项
- **THEN** SHALL 合并所有配置源
- **THEN** SHALL 使用高优先级源覆盖指定项
- **THEN** SHALL 保留其他配置源中的未覆盖项
#### Scenario: 配置项独立覆盖
#### Scenario: Server 配置项独立覆盖
- **WHEN** 仅通过 CLI 参数设置 `--server-port 9000`
- **WHEN** 仅通过 server CLI 参数设置 `--server-port 9000`
- **THEN** SHALL 仅覆盖 server.port 配置项
- **THEN** SHALL NOT 影响其他配置项
- **THEN** SHALL 其他配置项使用配置文件或默认值
#### Scenario: Desktop 优先级顺序
- **WHEN** 同一配置项存在于 desktop 默认配置文件和默认值中
- **THEN** SHALL 使用 `~/.nex/config.yaml` 中的配置文件值
- **THEN** SHALL 仅在配置文件未设置该配置项时使用默认值
#### Scenario: Desktop 忽略外部覆盖源
- **WHEN** desktop 启动时存在 `--server-port 9000` 参数
- **AND** 存在 `NEX_SERVER_PORT=9001` 环境变量
- **AND** `~/.nex/config.yaml` 设置 `server.port: 9826`
- **THEN** SHALL 使用配置文件值 9826
- **THEN** SHALL NOT 使用 CLI 参数或环境变量覆盖配置
#### Scenario: 启动后配置锁定
- **WHEN** 应用启动完成
@@ -314,67 +349,79 @@
### Requirement: CLI 参数配置支持
系统 SHALL 支持通过命令行参数设置所有配置项。
server 入口 SHALL 支持通过命令行参数设置所有配置项desktop 入口 SHALL NOT 将命令行参数作为配置源
#### Scenario: 基本参数解析
#### Scenario: Server 基本参数解析
- **WHEN** 应用启动时传入命令行参数
- **WHEN** server 应用启动时传入命令行参数
- **THEN** SHALL 解析所有 CLI 参数
- **THEN** SHALL 将参数值应用到对应配置项
#### Scenario: 参数命名规范
- **WHEN** 使用命令行参数
- **WHEN** server 使用命令行参数
- **THEN** SHALL 使用 kebab-case 命名(如 `--server-port`
- **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns`
- **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns``--database-max-idle-conns`
#### Scenario: 参数类型支持
- **WHEN** 解析不同类型的参数
- **WHEN** server 解析不同类型的参数
- **THEN** SHALL 支持 int 类型(如 `--server-port 9000`
- **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db`
- **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s`
- **THEN** SHALL 支持 bool 类型(如 `--log-compress`
#### Scenario: 完整配置覆盖
#### Scenario: Server 完整配置覆盖
- **WHEN** 使用服务器相关参数
- **WHEN** server 使用服务器相关参数
- **THEN** SHALL 支持 `--server-port``--server-read-timeout``--server-write-timeout`
- **WHEN** 使用数据库相关参数
- **WHEN** server 使用数据库相关参数
- **THEN** SHALL 支持 `--database-driver``--database-path``--database-host``--database-port``--database-user``--database-password``--database-dbname``--database-max-idle-conns``--database-max-open-conns``--database-conn-max-lifetime`
- **WHEN** 使用日志相关参数
- **WHEN** server 使用日志相关参数
- **THEN** SHALL 支持 `--log-level``--log-path``--log-max-size``--log-max-backups``--log-max-age``--log-compress`
#### Scenario: 参数帮助信息
#### Scenario: Server 参数帮助信息
- **WHEN** 使用 `--help` 参数
- **WHEN** server 使用 `--help` 参数
- **THEN** SHALL 显示所有支持的参数
- **THEN** SHALL 按功能分组展示参数(服务器、数据库、日志)
- **THEN** SHALL 显示每个参数的默认值和说明
#### Scenario: 参数错误处理
#### Scenario: Server 参数错误处理
- **WHEN** 传入无效的参数值(如 `--server-port abc`
- **WHEN** server 传入无效的参数值(如 `--server-port abc`
- **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型
- **THEN** SHALL NOT 启动应用
- **WHEN** 传入未定义的参数(如 `--unknown-param value`
- **WHEN** server 传入未定义的参数(如 `--unknown-param value`
- **THEN** SHALL 返回错误信息,指示未知参数名称
- **THEN** SHALL NOT 启动应用
#### Scenario: Desktop 忽略配置参数
- **WHEN** desktop 启动时传入 `--server-port 9000``--database-path /tmp/test.db``--config /tmp/custom.yaml`
- **THEN** SHALL 忽略这些参数
- **THEN** SHALL 从 `~/.nex/config.yaml` 和默认值加载配置
#### Scenario: Desktop 忽略未知参数
- **WHEN** desktop 启动时传入未知命令行参数
- **THEN** SHALL NOT 因未知参数导致配置加载失败
- **THEN** SHALL NOT 将未知参数应用为配置
### Requirement: 环境变量配置支持
系统 SHALL 支持通过环境变量设置所有配置项,符合 12-Factor App 原则。
server 入口 SHALL 支持通过环境变量设置所有配置项,符合 server 部署场景的 12-Factor App 原则desktop 入口 SHALL NOT 将 `NEX_*` 环境变量作为配置源
#### Scenario: 环境变量读取
#### Scenario: Server 环境变量读取
- **WHEN** 应用启动时存在环境变量
- **WHEN** server 应用启动时存在环境变量
- **THEN** SHALL 自动读取所有 `NEX_` 前缀的环境变量
- **THEN** SHALL 将环境变量值应用到对应配置项
#### Scenario: 环境变量命名规范
- **WHEN** 使用环境变量配置
- **WHEN** server 使用环境变量配置
- **THEN** SHALL 使用 `NEX_` 前缀
- **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT`
- **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS`
@@ -382,35 +429,143 @@
#### Scenario: 环境变量类型转换
- **WHEN** 解析不同类型的环境变量
- **WHEN** server 解析不同类型的环境变量
- **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000`
- **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db`
- **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s`
- **THEN** SHALL 支持 bool 类型(如 `NEX_LOG_COMPRESS=true`
#### Scenario: 完整环境变量覆盖
#### Scenario: Server 完整环境变量覆盖
- **WHEN** 设置服务器相关环境变量
- **WHEN** server 设置服务器相关环境变量
- **THEN** SHALL 支持 `NEX_SERVER_PORT``NEX_SERVER_READ_TIMEOUT``NEX_SERVER_WRITE_TIMEOUT`
- **WHEN** 设置数据库相关环境变量
- **WHEN** server 设置数据库相关环境变量
- **THEN** SHALL 支持 `NEX_DATABASE_DRIVER``NEX_DATABASE_PATH``NEX_DATABASE_HOST``NEX_DATABASE_PORT``NEX_DATABASE_USER``NEX_DATABASE_PASSWORD``NEX_DATABASE_DBNAME``NEX_DATABASE_MAX_IDLE_CONNS``NEX_DATABASE_MAX_OPEN_CONNS``NEX_DATABASE_CONN_MAX_LIFETIME`
- **WHEN** 设置日志相关环境变量
- **WHEN** server 设置日志相关环境变量
- **THEN** SHALL 支持 `NEX_LOG_LEVEL``NEX_LOG_PATH``NEX_LOG_MAX_SIZE``NEX_LOG_MAX_BACKUPS``NEX_LOG_MAX_AGE``NEX_LOG_COMPRESS`
#### Scenario: 12-Factor App 合规
#### Scenario: Server 12-Factor App 合规
- **WHEN** 应用部署到不同环境
- **WHEN** server 部署到不同环境
- **THEN** SHALL 通过环境变量区分环境配置
- **THEN** SHALL NOT 修改代码或配置文件
- **WHEN** 配置包含敏感信息(如密钥、密码)
- **WHEN** server 配置包含敏感信息(如密钥、密码)
- **THEN** SHALL 通过环境变量传递
- **THEN** SHALL NOT 存储在配置文件中
#### Scenario: 环境变量错误处理
#### Scenario: Server 环境变量错误处理
- **WHEN** 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`
- **WHEN** server 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`
- **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型
- **THEN** SHALL NOT 启动应用
- **WHEN** 必需配置项既无配置文件也无环境变量
- **WHEN** server 必需配置项既无配置文件也无环境变量
- **THEN** SHALL 使用默认值
- **THEN** SHALL 正常启动应用
#### Scenario: Desktop 忽略环境变量
- **WHEN** desktop 启动时存在 `NEX_SERVER_PORT``NEX_DATABASE_PATH``NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
- **THEN** SHALL NOT 读取这些环境变量作为配置源
- **THEN** SHALL 使用 `~/.nex/config.yaml` 和默认值加载配置
### Requirement: 启动参数设置查询
系统 SHALL 提供面向前端设置页的启动参数查询能力,按入口返回用于展示的启动参数设置视图和当前入口的可编辑状态。
#### Scenario: Desktop 查询配置文件编辑视图
- **WHEN** desktop 入口收到启动参数查询请求
- **THEN** 后端 SHALL 使用 desktop 配置语义读取 `~/.nex/config.yaml` 和默认值
- **THEN** 后端 SHALL 返回用于编辑配置文件的启动参数设置视图
- **THEN** 后端 SHALL NOT 将查询结果应用到当前运行配置快照
- **THEN** 返回配置 SHALL 包含 `server``database``log` 配置分组
- **THEN** 返回配置 SHALL 覆盖现有配置结构中的全部启动参数字段
- **THEN** 返回配置 SHALL 直接包含 `database.password` 字段值
#### Scenario: Server 查询当前有效启动参数
- **WHEN** server 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回当前运行进程启动后使用的有效配置
- **THEN** 返回配置 SHALL 包含 `server``database``log` 配置分组
- **THEN** 返回配置 SHALL 覆盖现有配置结构中的全部启动参数字段
- **THEN** 返回配置 SHALL 直接包含 `database.password` 字段值
#### Scenario: 查询返回入口模式元数据
- **WHEN** 前端请求启动参数设置
- **THEN** 后端 SHALL 返回当前入口模式,取值为 `server``desktop`
- **THEN** 后端 SHALL 返回 `editable` 表示当前入口是否允许前端保存启动参数
- **THEN** 后端 SHALL 返回配置文件路径
- **THEN** 后端 SHALL 返回 `restart_required` 表示保存后是否需要重启生效
#### Scenario: Desktop 查询返回可编辑元数据
- **WHEN** desktop 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回 `mode``desktop`
- **THEN** 后端 SHALL 返回 `editable` 为 true
- **THEN** 后端 SHALL 返回配置文件路径为默认配置文件 `~/.nex/config.yaml`
- **THEN** 后端 SHALL 返回 `restart_required` 为 true
#### Scenario: 查询不返回来源追踪信息
- **WHEN** 前端请求启动参数设置
- **THEN** 后端 SHALL NOT 要求返回每个字段的配置来源标签
- **THEN** 后端 SHALL NOT 要求区分当前运行值和配置文件值
### Requirement: Desktop 启动参数保存
desktop 入口 SHALL 允许前端通过设置页保存启动参数到默认配置文件,并保持当前运行时配置快照不变。
#### Scenario: Desktop 保存有效启动参数
- **WHEN** desktop 入口收到有效的启动参数保存请求
- **THEN** 后端 SHALL 验证请求配置符合现有配置验证规则
- **THEN** 后端 SHALL 将配置保存到 `~/.nex/config.yaml`
- **THEN** 保存的配置文件权限 SHALL 符合现有配置文件安全要求
- **THEN** 后端 SHALL 返回保存后的启动参数设置
#### Scenario: Desktop 保存时创建配置文件
- **WHEN** desktop 入口收到有效的启动参数保存请求
- **AND** `~/.nex/config.yaml` 不存在
- **THEN** 后端 SHALL 创建配置文件并写入提交的配置
- **THEN** 后端 SHALL NOT 在查询启动参数时自动创建配置文件
#### Scenario: Desktop 保存不动态应用配置
- **WHEN** desktop 入口成功保存启动参数
- **THEN** 当前运行中的配置快照 SHALL 保持不变
- **THEN** 当前运行中的 HTTP server、数据库连接、日志器和请求处理 SHALL NOT 因保存操作而重建或中断
- **THEN** 保存后的配置 SHALL 在下一次 desktop 启动时生效
- **THEN** 后端 SHALL NOT 自动重启 desktop
#### Scenario: Desktop 拒绝无效启动参数
- **WHEN** desktop 入口收到无效的启动参数保存请求
- **THEN** 后端 SHALL 返回验证错误
- **THEN** 后端 SHALL NOT 写入无效配置到 `~/.nex/config.yaml`
### Requirement: Server 启动参数只读
server 入口 SHALL 允许前端查看当前有效启动参数,但 SHALL NOT 允许前端保存或修改启动参数。
#### Scenario: Server 查询只读元数据
- **WHEN** server 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回 `mode``server`
- **THEN** 后端 SHALL 返回 `editable` 为 false
- **THEN** 后端 SHALL 返回 `restart_required` 为 false
- **THEN** 后端 SHALL 返回 server 启动时实际解析到的配置文件路径
#### Scenario: Server 查询返回自定义配置文件路径
- **WHEN** server 入口使用 `--config /path/to/custom.yaml` 启动
- **AND** server 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回配置文件路径 `/path/to/custom.yaml`
#### Scenario: Server 拒绝保存启动参数
- **WHEN** server 入口收到启动参数保存请求
- **THEN** 后端 SHALL 返回禁止修改错误
- **THEN** 后端 SHALL NOT 写入配置文件
- **THEN** 后端 SHALL NOT 修改当前运行配置

View File

@@ -104,14 +104,14 @@
### Requirement: 应用启动时迁移
应用 SHALL 在启动时执行迁移。
应用 SHALL 在启动时执行迁移,并 SHALL 使用随应用构建产物可用的打包迁移资源
#### Scenario: 自动迁移
- **WHEN** 应用启动
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移目录和 goose dialect
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录goose dialect 为 `mysql`
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移资源和 goose dialect
- **THEN** SHALL 在 `driver=sqlite` 时使用 SQLite 方言迁移资源goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql` 时使用 MySQL 方言迁移资源goose dialect 为 `mysql`
- **THEN** SHALL 自动执行待执行的迁移
- **THEN** SHALL 在迁移失败时拒绝启动
- **THEN** SHALL 记录迁移日志
@@ -122,6 +122,15 @@
- **THEN** SHALL 检查数据库迁移版本
- **THEN** SHALL 在版本不匹配时执行迁移
#### Scenario: 发布产物无源码目录时自动迁移
- **WHEN** 应用以发布产物形式运行,且安装环境不存在仓库源码目录
- **THEN** 应用启动 SHALL 能找到迁移资源
- **THEN** SHALL 自动执行待执行迁移
- **THEN** SHALL NOT 依赖源码工作区中的 `backend/migrations/...` 文件系统路径
- **THEN** SHALL NOT 通过 `runtime.Caller` 推导构建机或源码目录作为运行时迁移目录
- **THEN** 日志中的迁移资源位置 SHALL NOT 指向构建机路径,如 `/Users/runner/work/...`
### Requirement: 连接池配置
系统 SHALL 配置数据库连接池。
@@ -157,7 +166,7 @@
### Requirement: 迁移文件管理
迁移文件 SHALL 版本化管理。
迁移文件 SHALL 版本化管理,并 SHALL 在构建发布产物时作为运行时迁移资源打包
#### Scenario: 迁移文件命名
@@ -171,3 +180,10 @@
- **WHEN** 创建迁移文件
- **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/``migrations/mysql/`
- **THEN** SHALL 提交到版本控制系统
#### Scenario: 迁移文件打包
- **WHEN** 构建 server 或 desktop 二进制
- **THEN** SQLite 和 MySQL 迁移文件 SHALL 被作为运行时迁移资源打包进二进制或等效发布资源
- **THEN** 应用启动迁移 SHALL 使用该打包资源
- **THEN** backend Makefile 的 goose CLI 迁移命令 MAY 继续使用文件系统中的 `migrations/<dialect>/` 目录

View File

@@ -8,16 +8,18 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 桌面应用启动
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口
#### Scenario: 双击启动
- **WHEN** 用户双击桌面应用可执行文件
- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁
- **THEN** 系统 `~/.nex/config.yaml` 和默认值加载启动配置快照
- **AND** 系统使用 `gofrs/flock` 尝试获取排他文件锁
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
- **AND** 系统启动后端服务
- **AND** 系统使用启动配置中的 `server.port` 启动后端服务
- **AND** 未配置 `server.port` 时默认端口为 9826
- **AND** 系统托盘图标出现
- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面
- **AND** 浏览器自动打开 `http://localhost:<server.port>` 显示管理界面
#### Scenario: 单实例检查
@@ -34,7 +36,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`
#### Scenario: 托盘图标显示
@@ -42,7 +44,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **THEN** 系统根据平台加载正确的图标格式
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`
- **AND** 托盘图标 tooltip 显示"AI Gateway"
- **AND** 托盘图标 tooltip 显示 `Nex`
#### Scenario: 托盘菜单显示
@@ -50,19 +52,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **THEN** 显示托盘菜单
- **AND** 菜单包含"打开管理界面"选项
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
- **AND** 菜单包含"端口: <server.port>"选项(禁用状态)
- **AND** 菜单包含"退出"选项
#### Scenario: 打开管理界面
- **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
- **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
#### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:9826`
- **AND** 用户可手动访问 `http://localhost:<server.port>`
#### Scenario: 退出应用
@@ -80,6 +82,13 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **WHEN** 请求路径以 `/api/``/health` 开头
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
#### Scenario: 版本接口路由
- **WHEN** desktop 模式收到 `GET /api/version` 请求
- **THEN** 请求 SHALL 由版本信息 handler 处理
- **THEN** 响应 SHALL 为 API JSON 响应
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: 协议代理请求路由
- **WHEN** 请求路径以 `/openai/``/anthropic/` 开头
@@ -104,10 +113,10 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **THEN** 返回嵌入的前端静态资源文件
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: Favicon 路由
#### Scenario: PNG Favicon 路由
- **WHEN** 请求路径为 `/favicon.svg`
- **THEN** 返回嵌入的前端 favicon 资源
- **WHEN** 请求路径为 `/icon.png`
- **THEN** 返回来源于统一应用图标的 PNG favicon 资源
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: SPA 路由回退
@@ -117,44 +126,169 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 端口冲突检测
系统 SHALL 在启动前检测端口是否可用。
系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。
#### Scenario: 端口可用
#### Scenario: 配置端口可用
- **WHEN** 端口 9826 未被占用
- **WHEN** 启动配置中的 `server.port` 未被占用
- **THEN** 服务正常启动
#### Scenario: 端口被占用
#### Scenario: 配置端口被占用
- **WHEN** 端口 9826 已被其他程序占用
- **THEN** 显示错误提示"端口 9826 已被占用"
- **WHEN** 启动配置中的 `server.port` 已被其他程序占用
- **THEN** 显示错误提示"端口 <server.port> 已被占用"
- **AND** 应用退出
### Requirement: 桌面配置源隔离和启动快照
desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。
#### Scenario: Desktop 仅使用默认配置文件
- **WHEN** desktop 启动
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
- **THEN** SHALL 在配置文件不存在时使用默认值
- **THEN** SHALL 使用默认值补齐配置文件未设置的配置项
#### Scenario: Desktop 不支持 CLI 配置源
- **WHEN** desktop 启动时传入 `--server-port 9000``--database-path /tmp/test.db``--config /tmp/custom.yaml`
- **THEN** SHALL 忽略这些参数
- **THEN** SHALL NOT 将这些参数应用到运行时配置
- **THEN** SHALL NOT 使用 `--config` 指定的配置文件路径
#### Scenario: Desktop 不支持环境变量配置源
- **WHEN** desktop 启动环境中存在 `NEX_SERVER_PORT``NEX_DATABASE_PATH``NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
- **THEN** SHALL NOT 将这些环境变量应用到运行时配置
- **THEN** SHALL 使用默认配置文件和默认值确定运行时配置
#### Scenario: Desktop 忽略未知启动参数
- **WHEN** desktop 启动时传入未知命令行参数
- **THEN** SHALL NOT 因未知参数导致配置加载失败
- **THEN** SHALL 继续使用默认配置文件和默认值加载配置
#### Scenario: 配置文件修改仅下次启动生效
- **WHEN** desktop 已启动并正在处理请求
- **AND** 用户修改 `~/.nex/config.yaml` 中的 `server.port``database.*``log.*` 或 timeout 配置
- **THEN** 当前运行中的 desktop SHALL NOT 重新加载配置文件
- **THEN** 当前运行中的 HTTP server、数据库连接、日志器和请求处理 SHALL NOT 因配置文件修改而重建或中断
- **THEN** 修改后的配置 SHALL 在下一次 desktop 启动时生效
#### Scenario: 配置文件无效
- **WHEN** desktop 启动时 `~/.nex/config.yaml` 存在但内容无法解析或验证失败
- **THEN** SHALL 显示包含配置文件路径和失败原因的错误提示
- **THEN** SHALL 退出应用
- **THEN** SHALL NOT 静默回退默认配置继续启动
### Requirement: Desktop 前端同源 API 访问
desktop 内嵌前端 SHALL 使用同源相对路径访问后端 API不主动发现、缓存或覆盖 desktop 端口。
#### Scenario: 同源 API 请求
- **WHEN** desktop 浏览器页面打开在 `http://localhost:<server.port>`
- **THEN** 前端 SHALL 使用 `/api/*``/openai/*``/anthropic/*` 等相对路径访问同一 origin
- **THEN** 前端 SHALL NOT 硬编码 desktop 端口
#### Scenario: 重启后新端口访问
- **WHEN** 用户修改配置文件中的 `server.port` 并重启 desktop
- **THEN** desktop SHALL 按新端口启动并打开 `http://localhost:<new-port>`
- **THEN** 前端 SHALL 继续使用当前 origin 的相对路径访问 API
- **THEN** 前端 SHALL NOT 扫描端口、读取本地缓存端口或请求端口发现接口
### Requirement: 跨平台构建
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,中间构建产物文件名 SHALL 保持 `nex-{os}-{arch}[.exe]` 格式,最终桌面发布资产文件名 SHALL 包含统一版本号和平台标识
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息
#### Scenario: macOS 构建
- **WHEN** 执行 `desktop-build-mac` 构建命令且当前版本为 `1.2.3`
- **THEN** 生成 `nex-mac-arm64``nex-mac-amd64` 可执行文件
- **AND** 系统生成可打包为 `.app` bundle 的 macOS 桌面产物
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3``macOS` 平台标识
- **WHEN** 执行 macOS desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 macOS arm64 和 amd64 桌面可执行文件
- **AND** 系统 SHALL 使用 `lipo` 生成 macOS universal 桌面可执行文件
- **AND** 系统 SHALL 生成可打包为 `.app` bundle 的 macOS desktop 产物
- **AND** 最终 macOS desktop 发布资产文件名 SHALL 包含 `1.2.3``macos``universal`
#### Scenario: Windows 构建
- **WHEN** 执行 `desktop-build-win` 构建命令且当前版本为 `1.2.3`
- **THEN** 系统生成 Windows 桌面可执行文件
- **AND** 生成 `nex-win-amd64.exe` 可执行文件
- **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3``windows` 平台标识
- **WHEN** 执行 Windows desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 Windows amd64 desktop 可执行文件
- **AND** Windows desktop 构建 SHALL 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
- **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3``windows``amd64`
#### Scenario: Linux 构建
- **WHEN** 执行 `desktop-build-linux` 构建命令且当前版本为 `1.2.3`
- **THEN** 系统生成 Linux 桌面可执行文件
- **AND** 生成 `nex-linux-amd64` 可执行文件
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3``linux` 平台标识
- **WHEN** 执行 Linux desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 Linux amd64 和 arm64 desktop 可执行文件
- **AND** Linux desktop 构建 SHALL 使用 CGO 和 GTK/AppIndicator 构建依赖
- **AND** 最终 Linux desktop 发布资产文件名 SHALL 包含 `1.2.3``linux` 和对应架构标识
### Requirement: Linux 桌面发布安装包
系统 SHALL 为 Linux desktop amd64 和 arm64 生成 tar.gz、AppImage、deb 和 rpm 发布安装包,并 SHALL 在安装包中包含标准桌面集成元数据。
#### Scenario: Linux desktop tar.gz 裸包
- **WHEN** 构建 Linux desktop 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.tar.gz`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.tar.gz`
#### Scenario: Linux desktop AppImage 包
- **WHEN** 构建 Linux desktop AppImage 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.AppImage`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.AppImage`
- **AND** AppImage SHALL 包含 desktop entry、应用图标和 desktop 可执行文件
- **AND** AppImage SHALL 依赖目标系统提供 GTK3、Ayatana AppIndicator 和运行 AppImage 所需的 runtime/FUSE 能力
#### Scenario: Linux desktop deb 包
- **WHEN** 构建 Linux desktop deb 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.deb`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.deb`
- **AND** deb 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
- **AND** deb 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
- **AND** deb 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
- **AND** deb 包 SHALL 声明 `libgtk-3-0``libayatana-appindicator3-1``xdg-utils` 运行时依赖
- **AND** deb 包 metadata 的架构字段 SHALL 使用 `amd64``arm64`
#### Scenario: Linux desktop rpm 包
- **WHEN** 构建 Linux desktop rpm 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.rpm`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.rpm`
- **AND** rpm 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
- **AND** rpm 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
- **AND** rpm 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
- **AND** rpm 包 SHALL 声明 `gtk3``libayatana-appindicator-gtk3``xdg-utils` 运行时依赖
- **AND** rpm 包 metadata 的架构字段 SHALL 使用 `x86_64``aarch64`
### Requirement: macOS DMG 打包
系统 SHALL 为 macOS desktop universal `.app` 生成 unsigned DMG 安装包,并 SHALL 保留 universal zip 发布资产。
#### Scenario: macOS universal zip 包
- **WHEN** 构建 macOS desktop 发布资产且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.zip`
- **AND** zip 包 SHALL 包含 `Nex.app`
#### Scenario: macOS universal DMG 包
- **WHEN** 构建 macOS desktop DMG 发布资产且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.dmg`
- **AND** DMG SHALL 包含 `Nex.app`
- **AND** DMG SHALL 包含指向 `/Applications` 的快捷方式
- **AND** DMG SHALL NOT 要求 macOS 签名或 notarization 才能完成构建
#### Scenario: macOS universal 架构校验
- **WHEN** macOS desktop universal 可执行文件生成完成
- **THEN** 系统 SHALL 验证该可执行文件包含 amd64 和 arm64 架构
### Requirement: macOS .app 打包
@@ -246,3 +380,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
#### Scenario: 多行文本处理
- **WHEN** 对话框消息包含换行符 `\n`
- **THEN** AppleScript 正确显示多行文本
### Requirement: 桌面应用打包迁移资源
桌面应用 SHALL 在打包安装后仍能访问数据库迁移资源,并 SHALL 在首次启动时完成数据库初始化和迁移。
#### Scenario: 打包安装后首次启动执行迁移
- **WHEN** 用户从 macOS DMG 安装并首次启动 `Nex.app`
- **THEN** 系统 SHALL 初始化默认配置和数据库
- **THEN** 系统 SHALL 使用打包在应用内的迁移资源执行 SQLite 迁移
- **THEN** 系统 SHALL NOT 尝试访问构建机源码路径或仓库源码路径
- **THEN** 系统 SHALL 成功启动后端服务、托盘和管理界面
#### Scenario: .app 包含运行时必需迁移资源
- **WHEN** 执行 macOS 桌面打包脚本
- **THEN** `Nex.app` SHALL 包含启动后端服务所需的数据库迁移资源
- **THEN** 迁移资源 SHALL 随应用移动到任意安装位置后仍可用
- **THEN** `.app` SHALL NOT 依赖构建目录、源码目录或 GitHub Actions runner 路径
#### Scenario: DMG 安装后运行时资源完整
- **WHEN** 用户从 DMG 将 `Nex.app` 拖入 `/Applications` 并启动
- **THEN** 应用 SHALL 能访问数据库迁移资源
- **THEN** 应用 SHALL NOT 因 `migrations/sqlite``migrations/mysql` 文件系统目录不存在而启动失败

View File

@@ -49,13 +49,14 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
### Requirement: 构建集成 lint 检查
前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。
前端 SHALL 在 `build` 命令中集成 TypeScript 类型检查、ESLint 检查和 Prettier 格式检查。
#### Scenario: 构建时执行 lint 和格式检查
#### Scenario: 构建时执行类型检查、lint 和格式检查
- **WHEN** 执行 `bun run build`
- **THEN** 构建 SHALL 依次执行 `tsc -b``bun run check``vite build`
- **THEN** `bun run check` SHALL 执行 `bun run lint && bun run format:check`
- **THEN** 构建 SHALL 依次执行 `bun run check``vite build`
- **THEN** `bun run check` SHALL 依次执行 `bun run typecheck``bun run lint``bun run format:check`
- **THEN** 若 `tsc -b` 报告类型错误,构建 SHALL 中断
- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断
- **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断
@@ -77,8 +78,13 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
#### Scenario: 统一检查命令
- **WHEN** 执行 `bun run check`
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
- **THEN** lint 错误和格式问题 SHALL 都被检查
- **THEN** SHALL 依次运行 `bun run typecheck``bun run lint``bun run format:check`
- **THEN** 类型错误、lint 错误和格式问题 SHALL 都被检查
#### Scenario: 单独执行类型检查
- **WHEN** 执行 `bun run typecheck`
- **THEN** SHALL 运行 `tsc -b`
#### Scenario: 统一修复命令

View File

@@ -8,31 +8,31 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 样式体系
前端样式 SHALL 优先使用 TDesign 样式体系,SCSS 作为补充工具。
前端样式 SHALL 优先使用 TDesign 组件 props其次使用 TDesign tokens最后在前两者无法表达所需效果时使用 SCSS 作为补充工具。
#### Scenario: TDesign 组件 Props 优先
- **WHEN** 实现组件视觉效果
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props如 color、trend、hoverShadow、stripe、variant、shape 等)
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props如 color、trend、hoverShadow、stripe、variant、shape、headerBordered、gutter 等)
- **THEN** 前端 SHALL NOT 通过 CSS 类名覆盖组件内部样式
#### Scenario: TDesign Tokens 作为第二优先级
- **WHEN** 组件 props 无法完整表达颜色、边框、背景、间距等视觉细节
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(`var(--td-*)`)表达样式
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 `#fff``#e7e7e7``#999` 等颜色值
#### Scenario: CSS Variables 主题微调
- **WHEN** 需要调整全局视觉风格
- **THEN** 前端 SHALL 通过 \`:root\` 中声明 TDesign CSS Variables\`--td-*\`)进行覆盖
- **THEN** 前端 SHALL NOT 使用 \`!important\` 或高优先级选择器覆盖组件样式
- **THEN** 前端 SHALL 通过 `:root` 中声明 TDesign CSS Variables`--td-*`)进行覆盖
- **THEN** 前端 SHALL NOT 使用 `!important` 或高优先级选择器覆盖组件样式
#### Scenario: 布局样式 Token 化
#### Scenario: SCSS 兜底使用
- **WHEN** 编写布局级 inline style
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(\`var(--td-*)\`)替代硬编码颜色值
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 \`#fff\`、\`#e7e7e7\`、\`#999\` 等颜色值
#### Scenario: SCSS 补充使用
- **WHEN** TDesign 样式体系无法满足需求
- **WHEN** TDesign 组件 props 和 TDesign tokens 均无法满足布局、响应式或品牌视觉需求
- **THEN** 前端 MAY 使用 SCSS 作为补充
- **THEN** SCSS 文件 SHALL 仅用于 \`:root\` 级别的 CSS Variables 声明和全局 reset
- **THEN** SCSS SHALL 只承载必要的补充样式
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css
@@ -125,6 +125,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 供应商列表为空
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加"
#### Scenario: Base URL 一键复制
- **WHEN** 供应商表格渲染 Base URL 列
- **THEN** Base URL 文本右侧 SHALL 显示复制图标按钮
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text``copyable` 属性
- **WHEN** 用户点击 Base URL 的复制按钮
- **THEN** 系统 SHALL 将完整 Base URL 写入剪贴板
- **THEN** 系统 SHALL 显示 `已复制 Base URL` 成功提示
- **THEN** 当 Base URL 为空时,复制按钮 SHALL 禁用
#### Scenario: API Key 一键复制
- **WHEN** 供应商表格渲染 API Key 列
- **THEN** API Key 文本右侧 SHALL 显示复制图标按钮
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text``copyable` 属性
- **WHEN** 用户点击 API Key 的复制按钮
- **THEN** 系统 SHALL 将完整 API Key 写入剪贴板
- **THEN** 系统 SHALL 显示 `已复制 API Key` 成功提示
- **THEN** 当 API Key 为空时,复制按钮 SHALL 禁用
#### Scenario: 添加新供应商
- **WHEN** 用户点击"添加供应商"按钮
@@ -184,6 +204,16 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 模型列表为空
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加"
#### Scenario: 统一模型 ID 一键复制
- **WHEN** 模型表格渲染统一模型 ID 列
- **THEN** 统一模型 ID 文本右侧 SHALL 显示复制图标按钮
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text``copyable` 属性
- **WHEN** 用户点击统一模型 ID 的复制按钮
- **THEN** 系统 SHALL 将完整统一模型 ID 写入剪贴板
- **THEN** 系统 SHALL 显示 `已复制统一模型 ID` 成功提示
- **THEN** 当统一模型 ID 为空时,复制按钮 SHALL 禁用
#### Scenario: 为供应商添加模型
- **WHEN** 用户在展开行中点击"添加模型"
@@ -231,12 +261,20 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
#### Scenario: 桌面布局
- **WHEN** 在桌面屏幕上查看前端
- **THEN** 布局 SHALL 使用 TDesign \`Layout.Aside\` + \`Menu\`
- **THEN** 布局 SHALL 使用 TDesign `Layout.Aside` + `Menu`
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
- **THEN** Menu 组件 SHALL 使用 \`logo\` prop 显示品牌标识
- **THEN** Menu 组件 SHALL 使用 \`operations\` prop 在底部显示操作区域
- **THEN** Menu 组件 SHALL 支持 \`collapsed\` 折叠功能
- **THEN** Menu 组件 SHALL 使用 `logo` prop 显示品牌标识
- **THEN** Menu 组件 SHALL 使用 `operations` prop 在底部显示操作区域
- **THEN** Menu 组件 SHALL 支持 `collapsed` 折叠功能
#### Scenario: 侧边栏折叠布局
- **WHEN** 用户折叠侧边栏
- **THEN** 侧边栏 SHALL 使用折叠宽度 64px
- **THEN** Menu logo 区域 SHALL 保留应用图标
- **THEN** Menu logo 区域 SHALL 隐藏应用名称文字
- **THEN** Menu logo 区域 SHALL NOT 显示为空白
#### Scenario: 页面内容区域
@@ -336,13 +374,54 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 提供设置页面
前端 SHALL 提供设置页面。
前端 SHALL 提供设置页面,并在设置页面中以独立 Card 展示启动参数设置
#### Scenario: 显示设置页面
- **WHEN** 用户访问设置页面
- **THEN** 前端 SHALL 显示设置页面
- **THEN** 开发中提示文字颜色 SHALL 使用 \`var(--td-text-color-placeholder)\` Token
- **THEN** 前端 SHALL 显示标题为“启动参数设置”的 Card
- **THEN** 启动参数设置 Card SHALL 与未来其他设置 Card 在视觉结构上保持独立
#### Scenario: Desktop 模式显示可编辑启动参数
- **WHEN** 后端返回启动参数设置 `editable` 为 true
- **THEN** 前端 SHALL 在“启动参数设置” Card 中显示可编辑表单
- **THEN** 表单 SHALL 覆盖 `server``database``log` 配置分组
- **THEN** 前端 SHALL 提示“Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效”
- **THEN** 前端 SHALL 显示保存按钮
- **THEN** 前端 SHALL 在保存成功后提示“配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效”
#### Scenario: Server 模式显示只读启动参数
- **WHEN** 后端返回启动参数设置 `editable` 为 false
- **THEN** 前端 SHALL 在“启动参数设置” Card 中显示只读表单
- **THEN** 所有启动参数字段 SHALL 不可编辑
- **THEN** 前端 SHALL 隐藏或禁用保存按钮
- **THEN** 前端 SHALL 提示“Server 模式下启动参数仅支持查看,不支持从前端编辑”
#### Scenario: 启动参数展示内容
- **WHEN** 前端渲染启动参数设置表单
- **THEN** 前端 SHALL 直接展示后端返回的启动参数设置值
- **THEN** 前端 SHALL NOT 区分当前运行值和配置文件值
- **THEN** 前端 SHALL NOT 展示配置来源标签
- **THEN** 前端 SHALL 直接展示 `database.password` 字段值
#### Scenario: 数据库驱动表单切换
- **WHEN** 启动参数设置中的 `database.driver``sqlite`
- **THEN** 前端 SHALL 允许配置 SQLite 数据库路径
- **THEN** 前端 SHALL 弱化或禁用 MySQL 专属字段
- **WHEN** 启动参数设置中的 `database.driver``mysql`
- **THEN** 前端 SHALL 允许配置 MySQL host、port、user、password、dbname 字段
- **THEN** 前端 SHALL 弱化或禁用 SQLite 专属路径字段
#### Scenario: 启动参数保存失败
- **WHEN** 用户保存启动参数且后端返回验证错误或保存错误
- **THEN** 前端 SHALL 显示用户可理解的错误提示
- **THEN** 前端 SHALL 保持用户当前填写内容,便于修正后重新保存
### Requirement: 显示统一模型 ID
@@ -402,21 +481,29 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 提供侧边栏导航
前端 SHALL 使用 TDesign \`Layout.Aside\` 提供侧边栏导航。
前端 SHALL 使用 TDesign `Layout.Aside` 提供侧边栏导航。
#### Scenario: 侧边栏内容
- **WHEN** 渲染侧边栏
- **THEN** 侧边栏顶部 SHALL 显示应用名称/Logo
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
- **THEN** 侧边栏 SHALL 包含导航菜单
- **THEN** 导航菜单项 SHALL 包含供应商管理ServerIcon 图标、用量统计ChartLineIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
#### Scenario: 侧边栏折叠品牌显示
- **WHEN** 侧边栏处于折叠状态
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标
- **THEN** 侧边栏顶部 SHALL 隐藏 `Nex` 文案
- **THEN** 侧边栏顶部 SHALL NOT 为空白
#### Scenario: 导航菜单交互
- **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 \`/providers\` 并高亮当前菜单项
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计"
- **THEN** 前端 SHALL 导航到 \`/stats\` 并高亮当前菜单项
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"设置"
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"关于"
@@ -583,3 +670,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** 前端 SHALL 显示全局错误提示
- **THEN** 错误消息 SHALL 具有描述性
### Requirement: 统一 public 图标资源
前端 SHALL 使用仓库统一应用图标作为 public favicon 和品牌图标来源。
#### Scenario: 使用 PNG favicon
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面 SHALL 使用 `/icon.png` 作为 PNG favicon 路径
- **THEN** `frontend/public/icon.png` SHALL 来源于仓库根目录 `assets/icon.png`
- **THEN** 页面 SHALL NOT 引用独立维护的 SVG favicon
#### Scenario: HTML 标题使用统一应用名称
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面标题 SHALL 使用 `Nex` 作为应用名称
- **THEN** 页面标题 SHALL NOT 使用旧应用名称 `AI Gateway`
#### Scenario: 清理未使用 public SVG 图标
- **WHEN** public 目录中的 SVG 图标资源没有被前端代码、HTML 或 desktop 静态服务引用
- **THEN** 前端 SHALL 删除该未使用 SVG 图标资源
- **THEN** 前端 SHALL NOT 保留未使用的 `frontend/public/icons.svg`

View File

@@ -0,0 +1,196 @@
# git-hooks
## Purpose
定义仓库原生 Git hooks 的安装、校验、测试与跨平台执行规则,确保提交前快速检查和提交信息格式校验符合项目规范。
## Requirements
### Requirement: pre-commit hook 快速检查
pre-commit hook SHALL 在 `git commit` 执行前对 staged files 进行快速检查。非代码检查冲突标记、大文件告警、LFS 指针SHALL 在 `_hooks-pre-commit` 中直接实现代码检查Go 后端、Go versionctl、前端SHALL 根据 staged 文件类型有条件地委托给已有 Makefile target`_backend-lint``_versionctl-lint``_frontend-check`),不再内联独立的 lint 命令。
#### Scenario: 无 Go 和前端文件变更时跳过代码检查
- **WHEN** staged files 中既无 `.go` 文件也无 `.ts`/`.tsx`/`.scss` 文件
- **THEN** pre-commit hook SHALL 跳过代码检查委托,仅执行非代码检查
#### Scenario: Go 文件变更时委托后端 lint
- **WHEN** staged files 中包含 `backend/*.go` 文件
- **THEN** pre-commit hook SHALL 委托 `_backend-lint` target 进行 Go 代码检查
- **THEN** `_backend-lint` SHALL 复用 `backend/.golangci.yml` 配置
- **THEN** 若 lint 报告任何错误commit SHALL 被阻止
#### Scenario: versionctl Go 文件变更时委托 versionctl lint
- **WHEN** staged files 中包含 `versionctl/*.go` 文件
- **THEN** pre-commit hook SHALL 委托 `_versionctl-lint` target 进行 Go 代码检查
- **THEN** `_versionctl-lint` SHALL 复用 `versionctl/.golangci.yml` 配置
- **THEN** 若 lint 报告任何错误commit SHALL 被阻止
#### Scenario: 前端文件变更时委托前端检查
- **WHEN** staged files 中包含 `.ts``.tsx``.scss` 文件
- **THEN** pre-commit hook SHALL 委托 `_frontend-check` target 进行前端代码检查
- **THEN** `_frontend-check` SHALL 运行 `bun run check`(包含 `tsc -b` TypeScript 类型检查、ESLint 和 Prettier 格式检查)
- **THEN** 若检查报告任何错误commit SHALL 被阻止
#### Scenario: 冲突标记检测
- **WHEN** staged files 中包含 `<<<<<<<``=======``>>>>>>>` 冲突标记
- **THEN** pre-commit hook SHALL 报告错误并列出包含冲突的文件名
- **THEN** commit SHALL 被阻止
#### Scenario: 大文件告警
- **WHEN** staged files 中存在超过 500KB 的文本文件
- **THEN** pre-commit hook SHALL 输出警告信息(不阻止提交),提示检查是否误提交
#### Scenario: LFS 指针校验
- **WHEN** staged files 匹配 `.gitattributes``filter=lfs` 的路径模式
- **THEN** pre-commit hook SHALL 检查 staged 内容是否为 LFS 指针格式(`version https://git-lfs.github.com/spec/v1`
- **THEN** 若内容不是 LFS 指针格式commit SHALL 被阻止,并提示安装 git-lfs
- **THEN** 若 staged files 不匹配任何 `filter=lfs` 路径模式SHALL 跳过此检查
#### Scenario: commit 被阻止时显示修复提示
- **WHEN** pre-commit hook 检查失败
- **THEN** hook SHALL 输出明确的修复提示(如 `make lint` 修复代码问题、手动解决冲突标记等)
### Requirement: commit-msg hook 校验提交信息格式
commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确保首行符合项目规范。提交描述按项目规范应使用中文,但 hook SHALL NOT 通过 Python/CJK 字符集检测强制判断描述语言,以避免引入新的运行时依赖。
#### Scenario: 合法格式通过
- **WHEN** 提交信息首行格式为 `<类型>: <描述>`,类型为 `feat``fix``refactor``docs``style``test``chore` 之一
- **THEN** commit-msg hook SHALL 通过commit 正常执行
#### Scenario: 非法类型被拒绝
- **WHEN** 提交信息首行使用的类型不在允许列表中(如 `update: xxx`
- **THEN** commit-msg hook SHALL 报告错误显示允许的类型列表commit SHALL 被阻止
#### Scenario: 缺少冒号空格被拒绝
- **WHEN** 提交信息首行为 `feat:xxx`(冒号后无空格)或 `feat xxx`
- **THEN** commit-msg hook SHALL 报告格式错误commit SHALL 被阻止
#### Scenario: 首行过长告警
- **WHEN** 提交信息首行超过 72 个字符
- **THEN** commit-msg hook SHALL 输出警告(不阻止提交),提示首行应简短
#### Scenario: Merge commit 自动放行
- **WHEN** 提交信息首行以 `Merge` 开头
- **THEN** commit-msg hook SHALL 直接通过,不进行格式校验
#### Scenario: 格式错误时显示示例
- **WHEN** commit-msg hook 检查失败
- **THEN** hook SHALL 输出包含正确格式示例的错误信息(如 `feat: 添加供应商批量管理功能`
#### Scenario: 不执行字符集检测
- **WHEN** 提交信息首行格式合法且类型合法,但描述部分不包含 CJK 字符(如 `feat: add hook tests`
- **THEN** commit-msg hook SHALL 通过
- **THEN** hook SHALL NOT 调用 `python3` 或其他额外运行时做 Unicode/CJK 检测
#### Scenario: 多行格式校验
- **WHEN** 提交信息忽略 `#` 注释行后,第三行及之后存在任一非空详细说明行
- **THEN** commit-msg hook SHALL 检查第二行是否为空行
- **THEN** 若第二行非空行commit SHALL 被阻止,提示首行后应空行再写详细描述
#### Scenario: 模板注释不参与校验
- **WHEN** 提交信息文件中包含 prepare-commit-msg 写入的 `#` 注释模板
- **THEN** commit-msg hook SHALL 忽略这些注释行
- **THEN** 注释行 SHALL NOT 导致首行格式、多行空行分隔校验失败
### Requirement: hooks-install 安装命令
`make hooks-install` SHALL 将 `scripts/git-hooks/` 下的 hook 脚本安装到 `.git/hooks/`,不覆盖 Git LFS 管理的 hook。
#### Scenario: 安装所有 hook 脚本
- **WHEN** 执行 `make hooks-install`
- **THEN** `scripts/git-hooks/pre-commit` SHALL 被复制到 `.git/hooks/pre-commit`
- **THEN** `scripts/git-hooks/commit-msg` SHALL 被复制到 `.git/hooks/commit-msg`
- **THEN** `scripts/git-hooks/prepare-commit-msg` SHALL 被复制到 `.git/hooks/prepare-commit-msg`
- **THEN** 所有复制文件 SHALL 被设置为可执行(`chmod +x`
#### Scenario: 不覆盖 LFS 管理的 hook
- **WHEN** `.git/hooks/post-checkout``.git/hooks/post-commit``.git/hooks/post-merge``.git/hooks/pre-push` 已由 Git LFS 管理
- **THEN** `make hooks-install` SHALL NOT 覆盖或修改这些文件
#### Scenario: 重复安装幂等
- **WHEN** `make hooks-install` 被执行多次
- **THEN** hook 文件 SHALL 被正确覆盖更新,不会产生重复或损坏
#### Scenario: hooks-check 验证安装状态
- **WHEN** 执行 `make hooks-check`
- **THEN** 命令 SHALL 检查 `.git/hooks/pre-commit``.git/hooks/commit-msg``.git/hooks/prepare-commit-msg` 是否存在且可执行
- **THEN** SHALL 输出每个 hook 的安装状态
#### Scenario: 安装前验证 source 文件存在
- **WHEN** 执行 `make hooks-install`
- **THEN** 命令 SHALL 在复制前验证每个 source 文件(`scripts/git-hooks/<hook-name>`)是否存在
- **THEN** 若 source 文件不存在,命令 SHALL 报告错误并返回非零退出码
### Requirement: hooks-test 回归测试命令
`make hooks-test` SHALL 运行仓库内 hook 回归测试,覆盖 commit-msg 格式校验和 pre-commit staged-file 检查,不污染真实 git index。
#### Scenario: 运行 hook 回归测试
- **WHEN** 执行 `make hooks-test`
- **THEN** SHALL 运行 `scripts/git-hooks/test-hooks.sh`
- **THEN** 测试 SHALL 使用临时 `GIT_INDEX_FILE` 构造 staged fixture
- **THEN** 若任一 hook 行为不符合预期,命令 SHALL 返回非零退出码
### Requirement: 跨平台可用
pre-commit 和 commit-msg hook 脚本 SHALL 可在 macOS 和 WindowsGit Bash上正常执行。
#### Scenario: macOS 上正常执行
- **WHEN** hook 脚本在 macOS 上被 git 调用
- **THEN** `#!/bin/sh` shebang SHALL 被系统正确解析
- **THEN** `exec make` SHALL 正确调用 Makefile target
#### Scenario: Windows Git Bash 上正常执行
- **WHEN** hook 脚本在 Windows 的 Git Bash 环境中被 git 调用
- **THEN** Git for Windows 自带的 sh.exe SHALL 正确解析 `#!/bin/sh`
- **THEN** `exec make` SHALL 正确调用 Makefile target依赖 Git Bash/MINGW64 环境中 `make` 可用)
- **THEN** Go 和 Bun 工具链 SHALL 通过 PATH 可被 Makefile 调用
### Requirement: pre-commit 核心逻辑在 Makefile 中复用
pre-commit hook 的检查逻辑 SHALL 通过 Makefile target 调用项目已有工具链不重复实现。非代码检查冲突标记、大文件、LFS 指针SHALL 在 `_hooks-pre-commit` 中直接实现;代码检查 SHALL 委托 `_backend-lint``_versionctl-lint``_frontend-check` target。
#### Scenario: Go lint 委托后端 lint target
- **WHEN** pre-commit 需要检查 Go 文件
- **THEN** SHALL 委托 `_backend-lint``_versionctl-lint` target根据文件路径 `backend/` vs `versionctl/`
- **THEN** SHALL NOT 在 `_hooks-pre-commit` 中内联 `golangci-lint` 命令
#### Scenario: 前端检查委托前端 check target
- **WHEN** pre-commit 需要检查前端文件
- **THEN** SHALL 委托 `_frontend-check` target
- **THEN** SHALL NOT 在 `_hooks-pre-commit` 中内联 `eslint``prettier` 命令
#### Scenario: 终端直接调试
- **WHEN** 开发者执行 `make _hooks-pre-commit`
- **THEN** SHALL 执行与 pre-commit hook 完全相同的检查逻辑
- **THEN** 输出 SHALL 与 hook 触发时一致

View File

@@ -29,13 +29,13 @@
#### Scenario: 记录请求开始
- **WHEN** 收到 HTTP 请求
- **THEN** SHALL 记录请求开始日志
- **THEN** SHALL 以 debug 级别记录请求开始日志
- **THEN** SHALL 包含请求方法、路径、客户端 IP、请求 ID
#### Scenario: 记录请求结束
- **WHEN** HTTP 请求处理完成
- **THEN** SHALL 记录请求结束日志
- **THEN** SHALL 以 debug 级别记录请求结束日志
- **THEN** SHALL 包含响应状态码、响应大小、请求耗时、请求 ID
#### Scenario: 记录错误
@@ -44,6 +44,12 @@
- **THEN** SHALL 记录错误日志
- **THEN** SHALL 包含错误详情和请求 ID
#### Scenario: Info 级别过滤请求生命周期日志
- **WHEN** 日志中间件使用 info 级别 logger
- **THEN** SHALL NOT 输出“请求开始”日志
- **THEN** SHALL NOT 输出“请求结束”日志
### Requirement: 实现错误恢复中间件
系统 SHALL 实现错误恢复中间件。

View File

@@ -54,13 +54,13 @@
### Requirement: 数据库初始化公共包
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server``cmd/desktop` 共同调用。
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server``cmd/desktop` 共同调用,并 SHALL 使用随应用构建产物打包的迁移资源执行运行时迁移
#### Scenario: 公共包 Init 函数
- **WHEN** 调用 `database.Init(cfg, logger)`
- **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接
- **THEN** SHALL 执行对应方言的 goose 迁移
- **THEN** SHALL 使用随应用构建产物打包的迁移资源执行对应方言的 goose 迁移
- **THEN** SHALL 配置连接池参数
- **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL`
- **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA
@@ -71,11 +71,20 @@
- **WHEN** 调用 `database.Close(db)`
- **THEN** SHALL 获取底层 `sql.DB` 并关闭连接
#### Scenario: 迁移目录选择
#### Scenario: 迁移方言资源选择
- **WHEN** 执行迁移
- **THEN** SHALL 在 `driver=sqlite`使用 `migrations/sqlite/` 目录goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql`使用 `migrations/mysql/` 目录goose dialect 为 `mysql`
- **WHEN** 执行运行时迁移
- **THEN** SHALL 在 `driver=sqlite`选择 SQLite 方言迁移资源goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql`选择 MySQL 方言迁移资源goose dialect 为 `mysql`
- **THEN** 运行时迁移资源 SHALL 来源于打包资源而非源码目录
- **THEN** SHALL 在方言子资源解析失败时返回明确错误并拒绝启动
#### Scenario: 公共包迁移资源来源
- **WHEN** 调用 `database.Init(cfg, logger)` 且当前工作目录不是仓库根目录或 `backend/` 目录
- **THEN** SHALL 仍能解析并执行对应方言的迁移资源
- **THEN** SHALL NOT 要求当前进程工作目录位于仓库根目录或 `backend/` 目录
- **THEN** SHALL NOT 依赖 `runtime.Caller` 推导源码路径
### Requirement: MySQL 方言迁移文件

View File

@@ -0,0 +1,57 @@
# prepare-commit-msg-hook
## Purpose
定义 prepare-commit-msg Git hook`git commit` 编辑器打开时为开发者提供提交信息模板。
## Requirements
### Requirement: prepare-commit-msg hook 提供提交信息模板
prepare-commit-msg hook SHALL 在 `git commit` 打开编辑器时,将规范格式的提交信息模板预填充到提交信息文件中,辅助开发者编写符合项目规范的多行提交信息。
#### Scenario: 模板预填充到提交信息文件
- **WHEN** `git commit` 被执行且编辑器打开提交信息文件
- **THEN** prepare-commit-msg hook SHALL 在提交信息文件中写入模板内容
- **THEN** 模板 SHALL 包含注释行(以 `#` 开头)引导开发者填写规范格式
#### Scenario: 模板包含格式引导
- **WHEN** 模板被写入提交信息文件
- **THEN** 模板 SHALL 包含首行格式提示:`# <类型>: <简短中文描述>`
- **THEN** 模板 SHALL 包含空行占位符
- **THEN** 模板 SHALL 包含详细描述区:`# <详细说明>`
- **THEN** 模板 SHALL 列出可用类型:`feat / fix / refactor / docs / style / test / chore`
- **THEN** 模板 SHALL 包含示例:`feat: 添加供应商批量管理功能`
#### Scenario: 注释行不被提交
- **WHEN** 用户在编辑器中基于模板填写提交信息并保存
- **THEN** 以 `#` 开头的模板注释行 SHALL 被 Git 作为注释过滤,不会成为提交信息的一部分
#### Scenario: 已有提交信息时跳过
- **WHEN** 提交信息文件已包含非注释内容(如 `-m` 参数指定、`git commit --amend`、merge commit、cherry-pick
- **THEN** prepare-commit-msg hook SHALL NOT 覆盖已有内容,直接退出
#### Scenario: Git 默认注释不阻止模板写入
- **WHEN** 提交信息文件只包含空行或 Git 默认生成的 `#` 注释行
- **THEN** prepare-commit-msg hook SHALL 将其视为没有已有提交信息
- **THEN** hook SHALL 在文件顶部写入模板,并保留 Git 原有注释内容
### Requirement: 通过 hooks-install 安装
prepare-commit-msg hook SHALL 随 `make hooks-install` 一起安装到 `.git/hooks/`
#### Scenario: 安装 prepare-commit-msg
- **WHEN** 执行 `make hooks-install`
- **THEN** `scripts/git-hooks/prepare-commit-msg` SHALL 被复制到 `.git/hooks/prepare-commit-msg`
- **THEN** 该文件 SHALL 被设置为可执行(`chmod +x`
#### Scenario: hooks-check 验证安装状态
- **WHEN** 执行 `make hooks-check`
- **THEN** 命令 SHALL 检查 `.git/hooks/prepare-commit-msg` 是否存在且可执行

View File

@@ -189,7 +189,8 @@
- `format = "prettier --write ."` — 格式化所有文件
- `format:check = "prettier --check ."` — 检查文件格式
- `check = "bun run lint && bun run format:check"` — 检查 lint 和格式
- `typecheck = "tsc -b"` — TypeScript 类型检查
- `check = "bun run typecheck && bun run lint && bun run format:check"` — 检查类型、lint 和格式
- `fix = "bun run lint:fix && bun run format"` — 修复 lint 问题并格式化
#### Scenario: 运行格式化命令
@@ -207,8 +208,14 @@
#### Scenario: 运行统一检查命令
- **WHEN** 执行 `bun run check`
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
- **THEN** lint 错误和格式问题 SHALL 都被检查
- **THEN** SHALL 依次运行 `bun run typecheck``bun run lint``bun run format:check`
- **THEN** TypeScript 类型错误、lint 错误和格式问题 SHALL 都被检查
#### Scenario: 运行类型检查命令
- **WHEN** 执行 `bun run typecheck`
- **THEN** SHALL 运行 `tsc -b`
- **THEN** TypeScript 类型错误 SHALL 报告错误
#### Scenario: 运行统一修复命令

View File

@@ -8,71 +8,257 @@
### Requirement: Tag 驱动发布流水线
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。发布流水线 SHALL 使用 `./versionctl` 而非 `./backend/cmd/versionctl` 调用版本管理工具。发布流水线 SHALL 在进入构建阶段前完成全流程测试验证,测试未通过 SHALL NOT 执行任何构建。
#### Scenario: 有效发布 tag
- **WHEN** 仓库收到 `v1.2.3` tag push
- **THEN** 发布流水线 SHALL 启动版本校验、构建和 Release 组装步骤
- **THEN** 发布流水线 SHALL 启动版本校验、全流程测试、构建和 Release 组装步骤
- **AND** 版本校验步骤 SHALL 使用 `go run ./versionctl print``go run ./versionctl verify-tag` 获取并验证版本
#### Scenario: 普通分支推送
- **WHEN** 仓库收到非 tag 的分支 push
- **THEN** 系统 SHALL NOT 创建 GitHub Release
#### Scenario: 测试门禁阻止构建
- **WHEN** 发布流水线中全流程测试步骤lint、默认测试、MySQL 测试、E2E 测试)任一失败
- **THEN** 发布流水线 SHALL NOT 执行任何平台构建
- **THEN** 发布流水线 SHALL NOT 创建 Draft Release
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
#### Scenario: 测试通过后并行构建
- **WHEN** 全流程测试全部通过
- **THEN** web、Linux、Windows、macOS 构建 SHALL 并行执行
- **AND** 所有构建 job SHALL 依赖 `prepare``test-gate`
### Requirement: 发布流水线 Go 模块缓存覆盖
发布流水线 SHALL 在所有 Go module 的 go.sum 文件存在时正确设置 Go 模块缓存路径,确保新增的 `versionctl` module 依赖也被缓存。
#### Scenario: CI 缓存覆盖所有 module
- **WHEN** 发布流水线设置 Go 模块缓存
- **THEN** `cache-dependency-path` SHALL 覆盖 `backend/go.sum``versionctl/go.sum`
### Requirement: 三平台发布构建
系统 SHALL 在发布流水线中构建 server 与 desktop 的 Linux、Windows、macOS 三个平台产物
系统 SHALL 在发布流水线中构建 server、web 与 desktop 的发布产物,并覆盖 Linux、Windows、macOS 的目标架构和格式矩阵
#### Scenario: Linux 发布构建
#### Scenario: server 发布构建
- **WHEN** 发布流水线执行 Linux 构建 job
- **THEN** 系统 SHALL 生成 Linux server 发布资产
- **AND** 系统 SHALL 生成 Linux desktop 发布资产
- **WHEN** 发布流水线执行 server 发布构建
- **THEN** 系统 SHALL 生成 `nex-server_<version>_linux_amd64.tar.gz`
- **AND** 系统 SHALL 生成 `nex-server_<version>_linux_arm64.tar.gz`
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_amd64.tar.gz`
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_arm64.tar.gz`
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_universal.tar.gz`
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_amd64.zip`
#### Scenario: Windows 发布构建
#### Scenario: web 发布构建
- **WHEN** 发布流水线执行 Windows 构建 job
- **THEN** 系统 SHALL 生成 Windows server 发布资产
- **AND** 系统 SHALL 生成 Windows desktop 发布资产
- **WHEN** 发布流水线执行 web 发布构建
- **THEN** 系统 SHALL 使用 Bun 构建 `frontend/dist`
- **AND** 系统 SHALL 将前端静态资源打包为 `nex-web_<version>.tar.gz`
- **AND** server 发布资产 SHALL NOT 内置 Web 管理界面静态资源
#### Scenario: macOS 发布构建
#### Scenario: Linux desktop 发布构建
- **WHEN** 发布流水线执行 macOS 构建 job
- **THEN** 系统 SHALL 生成 darwin-amd64 server 发布资产
- **AND** 系统 SHALL 生成 darwin-arm64 server 发布资产
- **AND** 系统 SHALL 生成 macOS desktop universal 发布资产
- **WHEN** 发布流水线执行 Linux desktop 发布构建
- **THEN** 系统 SHALL 在可访问 Go、Bun、CGO、GTK3、Ayatana AppIndicator 和 Linux 打包工具链的环境中构建
- **AND** 系统 SHALL `amd64``arm64` 分别生成 tar.gz、AppImage、deb 和 rpm desktop 发布资产
- **AND** Linux amd64 desktop 发布构建 SHALL 在 `ubuntu-latest` runner 上执行
- **AND** Linux arm64 desktop 发布构建 SHALL 在 `ubuntu-24.04-arm` runner 上执行
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
#### Scenario: Windows desktop 发布构建
- **WHEN** 发布流水线执行 Windows desktop 发布构建
- **THEN** 系统 SHALL 在包含对应架构 MSYS2/MinGW 或等价 CGO 工具链的环境中构建
- **AND** Windows amd64 desktop 发布构建 SHALL 在 `windows-latest` runner 上的 MSYS2 MINGW64 环境中执行
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_amd64.zip`
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
#### Scenario: macOS desktop 发布构建
- **WHEN** 发布流水线执行 macOS desktop 发布构建
- **THEN** 系统 SHALL 在可访问 Go、Bun、Xcode 命令行工具、`lipo``hdiutil` 和 zip 打包工具的 macOS 环境中构建
- **AND** 系统 SHALL 在 ARM64 macOS runner 上编译 amd64 和 arm64 双架构二进制并使用 `lipo` 合并为 universal binary
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.zip`
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.dmg`
#### Scenario: 原生架构构建
- **WHEN** 发布流水线执行 Linux 或 Windows 的 server/desktop 构建步骤
- **THEN** 系统 SHALL NOT 显式传递 TARGET_ARCH 参数
- **AND** Makefile SHALL 通过 `go env GOARCH` 自动检测目标架构
- **AND** 原生 runner 的实际架构 SHALL 与 `go env GOARCH` 返回值一致
### Requirement: 三平台发布构建预检
系统 SHALL 在正式执行各平台 release 构建前验证对应 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。
#### Scenario: Linux 预检通过后开始构建
- **WHEN** Linux 发布 job 中的 `go``bun``gcc``pkg-config`、GTK3、Ayatana AppIndicator 和 Linux 打包工具均可用
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
- **AND** 系统 SHALL 继续执行对应 Linux release 构建
#### Scenario: Windows 预检通过后开始构建
- **WHEN** Windows 发布 job 中的 `go``bun``make`、对应架构 CGO 编译器和 resource 生成工具均可用
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
- **AND** 系统 SHALL 继续执行对应 Windows release 构建
#### Scenario: macOS 预检通过后开始构建
- **WHEN** macOS 发布 job 中的 `go``bun``ditto``lipo``vtool``hdiutil` 均可用
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
- **AND** 系统 SHALL 继续执行对应 macOS release 构建
#### Scenario: web 预检通过后开始构建
- **WHEN** web 发布 job 中的 `bun` 和前端构建依赖均可用
- **THEN** 系统 SHALL 输出 Bun 版本信息
- **AND** 系统 SHALL 继续执行 web release 构建
#### Scenario: 任一预检发现工具缺失
- **WHEN** 任一发布 job 中存在关键工具不可用
- **THEN** 发布流水线 SHALL 在正式构建前失败
- **AND** 系统 SHALL 在日志中标识缺失的工具链名称
### Requirement: 发布流水线 LFS 资产拉取
发布流水线 SHALL 在所有会 checkout 仓库并参与版本校验、web 构建、server 构建或 desktop 构建的 job 中拉取 Git LFS 真实文件,确保发布构建读取到真实二进制资产而非 LFS pointer 文本。
#### Scenario: 发布 job 获取真实 LFS 图标资产
- **WHEN** 发布流水线执行任一参与版本校验、web 构建、server 构建或 desktop 构建的 job 的 checkout 步骤
- **THEN** checkout 步骤 SHALL 拉取 Git LFS 文件
- **AND** `assets/icon.ico``assets/icon.icns``assets/icon.png``frontend/public/icon.png` SHALL 在后续步骤中表现为真实图标文件而非 LFS pointer 文本
#### Scenario: 新增矩阵 job 获取真实 LFS 资产
- **WHEN** 发布流水线新增 server、web、desktop、platform 或 arch 矩阵 job
- **THEN** 该 job 的 checkout 步骤 SHALL 使用与现有发布 job 一致的 Git LFS 拉取配置
### Requirement: 发布资产图标预检
发布流水线 SHALL 在正式执行任何需要图标资产、前端 public 图标或 desktop 打包资源的发布构建前校验关键图标资产可用,并在检测到 LFS pointer 或错误格式时快速失败且输出明确诊断。
#### Scenario: 图标资产为 LFS pointer
- **WHEN** 发布资产预检发现关键图标文件内容为 Git LFS pointer 文本
- **THEN** 发布流水线 SHALL 在执行对应 release 构建前失败
- **AND** 系统 SHALL 在日志中标识对应图标文件需要拉取 Git LFS 真实内容
#### Scenario: 图标资产格式无效
- **WHEN** 发布资产预检发现关键图标文件不是对应格式的有效资源
- **THEN** 发布流水线 SHALL 在执行对应 release 构建前失败
- **AND** 系统 SHALL 在日志中标识格式无效的图标文件路径
#### Scenario: 图标资产预检通过
- **WHEN** `assets/icon.ico``assets/icon.icns``assets/icon.png``frontend/public/icon.png` 均为真实且格式可用的图标资产
- **THEN** 发布流水线 SHALL 继续执行依赖这些资产的 release 构建
### Requirement: 发布流水线运行时兼容性
系统 SHALL 保持与 GitHub-hosted runner 当前受支持的 workflow runtime 约束兼容,避免发布流程依赖已声明弃用的 runtime 或执行约束。
#### Scenario: runner runtime 升级前完成兼容更新
- **WHEN** GitHub-hosted runner 宣布 workflow runtime 或关键执行约束将从旧版本迁移到新版本
- **THEN** 发布流水线 SHALL 在旧约束移除前完成兼容性更新
#### Scenario: 发布流水线执行时不依赖已弃用 runtime
- **WHEN** 发布流水线运行 release workflow
- **THEN** 关键发布步骤 SHALL NOT 依赖已被 GitHub-hosted runner 标记为待移除的 runtime 或执行约束
### Requirement: 版本化发布资产命名
系统 SHALL 为 server 与 desktop 发布资产使用包含统一版本号和目标平台信息的文件名,确保 Release 页面可直接区分产物用途平台。
系统 SHALL 为 server、web 与 desktop 发布资产使用包含统一版本号、组件、目标平台和目标架构信息的文件名,确保 Release 页面可直接区分产物用途平台、架构和格式
#### Scenario: server 资产命名
- **WHEN** 当前发布版本为 `1.2.3`
- **THEN** Linux server 发布资产文件名 SHALL 包含 `1.2.3``linux``amd64`
- **AND** Windows server 发布资产文件名 SHALL 包含 `1.2.3``windows``amd64`
- **AND** macOS server 发布资产文件名 SHALL 分别包含 `1.2.3``darwin``amd64``1.2.3``darwin``arm64`
- **THEN** Linux server 发布资产文件名 SHALL `nex-server_1.2.3_linux_amd64.tar.gz``nex-server_1.2.3_linux_arm64.tar.gz`
- **AND** macOS server 发布资产文件名 SHALL `nex-server_1.2.3_macos_amd64.tar.gz``nex-server_1.2.3_macos_arm64.tar.gz``nex-server_1.2.3_macos_universal.tar.gz`
- **AND** Windows server 发布资产文件名 SHALL `nex-server_1.2.3_windows_amd64.zip`
#### Scenario: web 资产命名
- **WHEN** 当前发布版本为 `1.2.3`
- **THEN** web 发布资产文件名 SHALL 为 `nex-web_1.2.3.tar.gz`
- **AND** web 发布资产文件名 SHALL NOT 包含平台或架构字段
#### Scenario: desktop 资产命名
- **WHEN** 当前发布版本为 `1.2.3`
- **THEN** Linux desktop 发布资产文件名 SHALL 包含 `1.2.3``linux`
- **AND** Windows desktop 发布资产文件名 SHALL 包含 `1.2.3``windows`
- **AND** macOS desktop universal 发布资产文件名 SHALL 包含 `1.2.3``macOS`
- **THEN** Linux desktop 发布资产文件名 SHALL 使用 `nex-desktop_1.2.3_linux_<arch>.<format>` 格式
- **AND** Windows desktop 发布资产文件名 SHALL `nex-desktop_1.2.3_windows_amd64.zip`
- **AND** macOS desktop 发布资产文件名 SHALL `nex-desktop_1.2.3_macos_universal.zip``nex-desktop_1.2.3_macos_universal.dmg`
- **AND** 发布资产文件名中的 macOS 平台字段 SHALL 使用 `macos` 而非 `darwin`
### Requirement: Draft Release 组装
系统 SHALL 将发布流水线产物上传到 GitHub Draft Release由人工确认后再公开发布。
系统 SHALL 将发布流水线产物上传到 GitHub Draft Release由人工确认后再公开发布,并 SHALL 生成覆盖全部发布资产的校验和清单
#### Scenario: 发布成功时创建 Draft Release
- **WHEN** 版本校验通过且三平台发布资产构建完成
- **WHEN** 版本校验通过、全流程测试通过且 server、web、desktop 的全部目标发布资产构建完成
- **THEN** 系统 SHALL 创建或更新与该 tag 对应的 GitHub Draft Release
- **AND** 系统 SHALL 上传 server 与 desktop 的全部发布资产
- **AND** 系统 SHALL 上传 server、web 与 desktop 的全部发布资产
- **AND** 系统 SHALL 上传 `SHA256SUMS`
#### Scenario: 校验和覆盖全部资产
- **WHEN** Draft Release 组装步骤生成 `SHA256SUMS`
- **THEN** `SHA256SUMS` SHALL 包含除自身以外的全部发布资产文件
- **AND** `SHA256SUMS` 中的文件名 SHALL 与实际上传的 release asset 文件名一致
#### Scenario: 构建失败时阻止完成发布
- **WHEN** 任一平台发布资产构建失败或版本校验失败
- **WHEN** 任一目标发布资产构建失败、打包失败、校验失败、artifact 上传为空、版本校验失败或全流程测试失败
- **THEN** 发布流水线 SHALL 失败
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
#### Scenario: artifact 缺失时快速失败
- **WHEN** 任一构建 job 尝试上传 release artifact 但匹配不到目标文件
- **THEN** 该 job SHALL 失败
- **AND** Draft Release 组装 SHALL NOT 继续发布不完整资产集合
### Requirement: 发布产物运行时资源完整性
发布流水线 SHALL 确保 server 和 desktop 发布产物包含运行时启动所需的数据库迁移资源,且 SHALL NOT 依赖 CI runner 的源码路径。
#### Scenario: desktop 发布产物包含迁移资源
- **WHEN** 发布流水线构建 desktop 发布资产
- **THEN** 生成的 desktop 二进制或应用包 SHALL 包含 SQLite 和 MySQL 迁移资源
- **THEN** macOS `.app``.zip``.dmg` 安装后 SHALL 不需要仓库源码目录即可执行启动迁移
#### Scenario: server 发布产物包含迁移资源
- **WHEN** 发布流水线构建 server 发布资产
- **THEN** 生成的 server 二进制 SHALL 包含 SQLite 和 MySQL 迁移资源
- **THEN** server 发布资产 SHALL 不需要仓库源码目录即可执行启动迁移
#### Scenario: 发布产物不泄漏构建机迁移路径
- **WHEN** 发布流水线完成 server 或 desktop 构建
- **THEN** 构建产物 SHALL NOT 在运行时使用 `/Users/runner/work/.../backend/migrations/...` 作为迁移目录
- **THEN** 若检测到运行时迁移路径依赖 CI runner 源码路径,发布构建 SHALL 失败
#### Scenario: 发布构建迁移资源验证
- **WHEN** 发布流水线执行 release 构建验证
- **THEN** 验证 SHALL 覆盖迁移资源可用性
- **THEN** 验证 SHALL 覆盖安装包内应用在无源码目录环境下可解析迁移资源
- **THEN** 验证 MAY 通过 Go 测试或轻量资源自检完成,不要求启动图形托盘界面

View File

@@ -66,3 +66,56 @@
- **WHEN** 执行前端生产构建
- **THEN** 构建流程 SHALL 注入 `VITE_APP_VERSION`
- **AND** 该值 SHALL 等于 `VERSION` 中的版本号
### Requirement: 后端运行时版本查询
系统 SHALL 通过管理接口暴露后端运行时构建版本信息,供前端和用户诊断前后端版本一致性。
#### Scenario: 查询后端版本信息
- **WHEN** 客户端请求 `GET /api/version`
- **THEN** 后端 SHALL 返回 HTTP 200
- **THEN** 响应 JSON SHALL 包含 `version` 字段
- **THEN** 响应 JSON SHALL 包含 `commit` 字段
- **THEN** 响应 JSON SHALL 包含 `build_time` 字段
#### Scenario: 版本信息来源于构建注入
- **WHEN** 后端返回版本信息
- **THEN** `version` SHALL 来源于 `buildinfo.Version()`
- **THEN** `commit` SHALL 来源于 `buildinfo.Commit()`
- **THEN** `build_time` SHALL 来源于 `buildinfo.BuildTime()`
- **THEN** 后端 SHALL NOT 在运行时读取仓库 `VERSION` 文件作为接口响应来源
#### Scenario: 本地开发构建降级值
- **WHEN** 后端未通过构建参数注入版本元数据
- **THEN** 后端版本接口 SHALL 返回 buildinfo 的默认降级值
- **THEN** 前端 SHALL 能够展示该降级值而不崩溃
### Requirement: 前后端版本一致性诊断
系统 SHALL 支持前端使用自身构建版本和后端运行时版本进行一致性诊断。
#### Scenario: 前端读取构建版本
- **WHEN** 前端渲染版本信息
- **THEN** 前端 SHALL 使用 `VITE_APP_VERSION` 作为前端版本号
- **THEN** `VITE_APP_VERSION` SHALL 继续由版本同步流程保持与 `VERSION` 一致
#### Scenario: 诊断版本匹配
- **WHEN** 前端版本号和后端版本号均可判断且完全相同
- **THEN** 前端 SHALL 将版本状态判定为一致
#### Scenario: 诊断版本不匹配
- **WHEN** 前端版本号和后端版本号均可判断且不相同
- **THEN** 前端 SHALL 将版本状态判定为不一致
- **THEN** 前端 SHALL 将该状态作为诊断提示展示
#### Scenario: 诊断版本不可判断
- **WHEN** 任一版本号为空、`dev``unknown`
- **THEN** 前端 SHALL 将版本状态判定为无法判断
- **THEN** 前端 SHALL NOT 将该状态判定为版本不一致

View File

@@ -82,21 +82,29 @@
### Requirement: 记录请求日志
系统 SHALL 记录 HTTP 请求日志。
系统 SHALL 记录 HTTP 请求日志,其中请求开始和请求结束生命周期日志 SHALL 使用 debug 级别记录
#### Scenario: 请求开始日志
- **WHEN** 收到 HTTP 请求
- **THEN** SHALL 以 debug 级别记录请求开始日志
- **THEN** SHALL 记录请求方法、路径、客户端 IP
- **THEN** SHALL 包含请求 ID
#### Scenario: 请求结束日志
- **WHEN** HTTP 请求处理完成
- **THEN** SHALL 以 debug 级别记录请求结束日志
- **THEN** SHALL 记录响应状态码、响应大小
- **THEN** SHALL 记录请求耗时
- **THEN** SHALL 包含请求 ID
#### Scenario: Info 级别过滤请求生命周期日志
- **WHEN** 日志级别配置为 info
- **THEN** SHALL NOT 输出“请求开始”日志
- **THEN** SHALL NOT 输出“请求结束”日志
### Requirement: 支持日志级别
系统 SHALL 支持日志级别控制。

View File

@@ -37,7 +37,7 @@
- **THEN** SHALL 验证 LoadConfigFromPath 正确加载默认值
- **THEN** SHALL 验证 YAML 配置文件正确读取
- **THEN** SHALL 验证优先级链CLI 参数 > 环境变量 > YAML 文件 > 默认值
- **THEN** SHALL 验证首次启动自动创建配置文件
- **THEN** SHALL 验证配置文件缺失时使用默认值,不自动创建配置文件
- **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致
#### Scenario: 环境变量覆盖验证
@@ -46,11 +46,12 @@
- **THEN** SHALL 成功加载
- **THEN** 配置值 SHALL 反映环境变量覆盖
#### Scenario: 自动创建配置文件验证
#### Scenario: 配置文件缺失时使用默认值
- **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径
- **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误)
- **THEN** SHALL 返回默认配置对象
- **THEN** SHALL NOT 自动创建配置文件
#### Scenario: handler 错误分支测试
@@ -279,3 +280,74 @@
- **WHEN** mockgen 生成的 mock 就绪
- **THEN** handler 测试中的手写 mock SHALL 被替换为生成的 mock
- **THEN** 所有测试 SHALL 继续通过,行为不变
### Requirement: 运行时迁移资源测试覆盖
系统 SHALL 覆盖打包迁移资源解析和启动迁移回归场景,确保发布产物不依赖源码迁移目录。
#### Scenario: 运行时迁移资源解析测试
- **WHEN** 运行 database 包单元测试
- **THEN** SHALL 验证 `database.Init` 在当前工作目录不是仓库根目录或 `backend/` 目录时仍能执行迁移
- **THEN** SHALL 验证迁移资源不依赖 `runtime.Caller` 推导的源码路径
- **THEN** SHALL 覆盖 SQLite 方言迁移资源解析
#### Scenario: 双方言迁移资源选择测试
- **WHEN** 运行迁移资源选择相关测试
- **THEN** SHALL 验证 SQLite 方言资源可被解析
- **THEN** SHALL 验证 MySQL 方言资源可被解析
- **THEN** SHALL 验证未知或非法 driver 不会被静默映射到错误方言资源
#### Scenario: desktop 打包迁移资源测试
- **WHEN** 运行 desktop 专属测试
- **THEN** SHALL 验证 desktop 模式启动数据库迁移时使用打包迁移资源
- **THEN** SHALL 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录
### Requirement: Desktop 配置源隔离测试覆盖
系统 SHALL 为 desktop 配置加载行为建立测试覆盖,验证 desktop 只使用默认配置文件和默认值,不受 CLI 参数或 `NEX_*` 环境变量影响。
#### Scenario: Desktop 配置文件端口生效
- **WHEN** 运行 desktop 配置加载相关测试
- **THEN** SHALL 验证 `~/.nex/config.yaml` 或等价测试配置文件中的 `server.port` 会进入 desktop 启动配置快照
- **THEN** SHALL 验证 desktop 端口检测、HTTP 监听地址、浏览器打开地址和托盘端口显示使用同一个配置端口
#### Scenario: Desktop 忽略 CLI 参数
- **WHEN** 测试进程参数包含 `--server-port 9000``--database-path /tmp/test.db``--config /tmp/custom.yaml`
- **THEN** desktop 配置加载 SHALL 忽略这些参数
- **THEN** desktop 配置加载 SHALL 使用默认配置文件路径和配置文件值
#### Scenario: Desktop 忽略未知参数
- **WHEN** 测试进程参数包含未知命令行参数
- **THEN** desktop 配置加载 SHALL 成功或仅因配置文件本身无效而失败
- **THEN** desktop 配置加载 SHALL NOT 因未知参数返回参数解析错误
#### Scenario: Desktop 忽略环境变量
- **WHEN** 测试环境设置 `NEX_SERVER_PORT``NEX_DATABASE_PATH``NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
- **THEN** desktop 配置加载 SHALL NOT 使用这些环境变量覆盖配置文件值
- **THEN** server 配置加载的环境变量覆盖测试 SHALL 继续通过
#### Scenario: Desktop 配置快照不随文件变化自动更新
- **WHEN** desktop 配置已加载为内存中的启动快照
- **AND** 测试修改配置文件中的 `server.port` 或其他配置项
- **THEN** 已加载的配置对象 SHALL 保持原值
- **THEN** 重新启动或重新执行 desktop 配置加载时 SHALL 读取修改后的配置值
#### Scenario: Desktop 无效配置错误提示
- **WHEN** desktop 启动时配置文件存在但 YAML 无法解析或配置验证失败
- **THEN** 测试 SHALL 验证启动流程返回或显示包含配置路径和失败原因的错误
- **THEN** 测试 SHALL 验证 desktop 不会静默回退默认配置继续启动
#### Scenario: 配置文件缺失时使用默认值
- **WHEN** 测试配置加载时指定不存在的配置文件路径
- **THEN** SHALL 返回默认配置值,不自动创建配置文件
- **THEN** 测试 SHALL 验证配置文件未被创建

View File

@@ -0,0 +1,135 @@
# 版本升迁
## Purpose
定义 `version bump` 子命令的版本号递增、下游文件同步、倒退防护及 Makefile 编排规则,确保版本升迁流程安全可自动化。
## Requirements
### Requirement: 版本号递增
`version bump` 子命令 SHALL 支持三种递增模式:`major`major+1, minor=0, patch=0`minor`minor+1, patch=0`patch`patch+1以及直接指定具体版本号。
#### Scenario: minor 递增
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump minor`
- **THEN** VERSION 文件 SHALL 被更新为 `0.2.0`
#### Scenario: major 递增
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump major`
- **THEN** VERSION 文件 SHALL 被更新为 `1.0.0`
#### Scenario: patch 递增
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump patch`
- **THEN** VERSION 文件 SHALL 被更新为 `0.1.1`
#### Scenario: 指定具体版本号
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump 1.0.0`
- **THEN** VERSION 文件 SHALL 被更新为 `1.0.0`
#### Scenario: 指定版本号等于当前 VERSION
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump 0.1.0`
- **THEN** 命令 SHALL 正常执行,完成 sync 和 check输出 `0.1.0`
#### Scenario: 非法 bump 参数
- **WHEN** 执行 `version bump` 传入既非 `major|minor|patch` 也非合法 semver 的参数
- **THEN** 命令 SHALL 以非零退出码失败并输出错误信息
### Requirement: bump 自动同步下游文件
`version bump` 子命令 SHALL 在写回 VERSION 文件后自动执行 sync 和 check确保 `frontend/package.json` 和所有 `frontend/.env.*` 文件与新版本号一致。
#### Scenario: bump 自动 sync 和 check
- **WHEN** 执行 `version bump minor` 且当前 VERSION 为 `0.1.0`
- **THEN** 命令 SHALL 自动将新版本号 `0.2.0` 同步到 `frontend/package.json``version` 字段和所有 `frontend/.env.*``VITE_APP_VERSION` 变量
- **AND** 命令 SHALL 自动验证所有下游文件版本号一致性
#### Scenario: sync 失败时 bump 中止
- **WHEN** 执行 `version bump minor` 但下游文件同步失败(如文件缺失)
- **THEN** 命令 SHALL 以非零退出码失败
### Requirement: 版本号倒退防护
`version bump` 子命令 SHALL 检查新版本号严格大于所有已有 git tag 中的最大版本号,防止版本号倒退。
#### Scenario: 新版本大于已有 tag
- **WHEN** 已有 tag `v0.1.0`,执行 `version bump minor`
- **THEN** 命令 SHALL 成功将版本更新为 `0.2.0`
#### Scenario: 新版本等于已有 tag
- **WHEN** 已有 tag `v0.1.0`,执行 `version bump 0.1.0`
- **THEN** 命令 SHALL 以非零退出码失败并提示版本号已存在
#### Scenario: 新版本小于已有 tag
- **WHEN** 已有 tag `v0.2.0`,执行 `version bump 0.1.5`
- **THEN** 命令 SHALL 以非零退出码失败并提示版本号倒退
#### Scenario: 无已有 tag
- **WHEN** 不存在任何 `v*.*.*` 格式的 git tag执行 `version bump 0.1.0`
- **THEN** 命令 SHALL 成功
### Requirement: bump 输出新版本号
`version bump` 子命令成功时 SHALL 仅将新版本号(不含 `v` 前缀)输出到 stdout供 Makefile 等外部工具使用。
#### Scenario: 输出格式
- **WHEN** 执行 `version bump minor`,当前版本为 `0.1.0`
- **THEN** stdout SHALL 输出 `0.2.0`(换行结尾,无额外内容)
### Requirement: 版本升迁 Makefile 编排
`make version-bump` SHALL 编排完整的版本升迁流程:全量 lint 检查 → 全量单元测试 → 工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch`。lint/test 前置检查 SHALL NOT 替代工作区干净检查。
#### Scenario: 完整升迁流程
- **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0`
- **THEN** Makefile SHALL 依次执行:`make lint``make test` → 工作区检查 → `version bump minor``git add VERSION frontend/``git commit -m "chore: 版本升迁 v0.2.0"``git tag v0.2.0`
#### Scenario: 不传 BUMP 默认 patch
- **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0`
- **THEN** Makefile SHALL 等效于执行 `make version-bump BUMP=patch`,将版本更新为 `0.1.1`
#### Scenario: lint 失败时终止
- **WHEN** 执行 `make version-bump`,但 `make lint` 报告错误
- **THEN** Makefile SHALL 以非零退出码失败SHALL NOT 执行 `version bump`、git commit、git tag
- **THEN** SHALL 输出错误信息提示修复 lint 问题后重试
#### Scenario: test 失败时终止
- **WHEN** 执行 `make version-bump`,但 `make test` 报告测试失败
- **THEN** Makefile SHALL 以非零退出码失败SHALL NOT 执行 `version bump`、git commit、git tag
- **THEN** SHALL 输出错误信息提示修复测试失败后重试
#### Scenario: 工作区不干净
- **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或清理改动
#### Scenario: 支持指定版本号
- **WHEN** 执行 `make version-bump SET_VERSION=1.0.0`
- **THEN** Makefile SHALL 将 `1.0.0` 传递给 `version bump` 子命令
#### Scenario: SET_VERSION 优先于默认 BUMP
- **WHEN** 执行 `make version-bump SET_VERSION=2.0.0`
- **THEN** Makefile SHALL 使用 `SET_VERSION=2.0.0` 而非默认的 `BUMP=patch`
#### Scenario: 不自动推送
- **WHEN** `make version-bump` 成功完成
- **THEN** commit 和 tag SHALL 仅存在于本地SHALL NOT 自动 push 到远程

View File

@@ -8,17 +8,26 @@
### Requirement: 根目录公开命令分层
根目录 `Makefile` SHALL 仅暴露全局命令、版本命令、server 产品命令、desktop 产品命令和 release 命令,不再作为 backend 局部维护命令或内部打包步骤的公开入口。
根目录 `Makefile` SHALL 仅暴露全局命令、版本命令、server 产品命令、desktop 产品命令和 release 命令,不再作为 backend 局部维护命令或内部打包步骤的公开入口。release 命令 SHALL 使用 `release-assets` 前缀,并 SHALL 通过清晰的目标名或变量参数表达 component、platform、arch 和 format。
#### Scenario: 查看根目录公开命令
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
- **THEN** SHALL 仅看到 `lint``test``clean``version-sync``version-check``server-run``server-build``server-lint``server-test``server-clean``desktop-build-mac``desktop-build-win``desktop-build-linux``desktop-lint``desktop-test``desktop-clean``release-assets-linux``release-assets-windows``release-assets-macos` 这类公共入口
- **THEN** SHALL 仅看到 `lint``test``clean``version-sync``version-check``server-run``server-build``server-lint``server-test``server-clean``desktop-build-mac``desktop-build-win``desktop-build-linux``desktop-lint``desktop-test``desktop-clean``release-assets` 前缀的 release 公共入口
- **AND** release 公共入口 SHALL 能覆盖 server、web、desktop 的目标发布产物
#### Scenario: 根目录不暴露局部和内部命令
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
- **THEN** SHALL NOT 暴露 `backend-*``frontend-*`、数据库迁移命令、MySQL 专项测试命令或 `desktop-prepare-*` 之类内部步骤
- **THEN** SHALL NOT 暴露 `dev``build``all``desktop-dev``desktop-build` 这类模糊或聚合式公共命令
#### Scenario: release 内部步骤保持内部化
- **WHEN** 根目录 `Makefile` 需要复用 release 构建、打包、校验辅助步骤
- **THEN** 内部辅助 target SHALL 使用 `_` 前缀或 Make 变量参数化方式表达
- **AND** 内部辅助 target SHALL NOT 成为文档化的公共入口
### Requirement: 全局质量与清理命令
根目录 `Makefile` SHALL 提供 `lint``test``clean` 作为全仓默认入口。
@@ -97,12 +106,33 @@
### Requirement: Release 命令沿用根目录入口
根目录 `Makefile` SHALL 继续提供 `release-assets-*` 作为发布资产入口,并与新的版本校验规则保持一致。
根目录 `Makefile` SHALL 继续提供 `release-assets` 前缀 target 作为发布资产入口,并与版本校验、发布资产预检和多组件打包规则保持一致。
#### Scenario: 执行 release 资产命令
- **WHEN** 执行 `make release-assets-linux``make release-assets-windows``make release-assets-macos`
- **WHEN** 执行任一 `release-assets` 前缀的公共 release target
- **THEN** SHALL 在构建发布资产前执行版本一致性校验
- **THEN** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
- **AND** SHALL 在需要图标或桌面资源的构建前执行发布资产预检
- **AND** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
#### Scenario: release target 职责清晰
- **WHEN** GitHub Actions 调用根目录 `Makefile` 生成 release 产物
- **THEN** 对应 release target SHALL 明确生成的 component、platform、arch 或 format 范围
- **AND** GitHub Actions SHALL NOT 以内联脚本替代 Makefile 中已有的核心构建和打包逻辑
#### Scenario: web release 产物生成
- **WHEN** 执行 web release 资产命令
- **THEN** SHALL 使用 Bun 构建 `frontend/dist`
- **AND** SHALL 打包生成 `nex-web_<version>.tar.gz`
- **AND** SHALL NOT 修改前端版本镜像文件
#### Scenario: checksum release 产物生成
- **WHEN** 执行 release 汇总或 Draft Release 组装相关命令
- **THEN** SHALL 能基于当前 release 产物目录生成 `SHA256SUMS`
- **AND** `SHA256SUMS` SHALL 覆盖除自身以外的全部 release 资产
### Requirement: Backend 局部命令下沉

5
packaging/linux/AppRun Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
APPDIR=$(dirname "$(readlink -f "$0")")
exec "$APPDIR/usr/bin/nex" "$@"

View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Nex
Comment=AI Gateway
Exec=nex
Icon=nex
Terminal=false
Categories=Development;Network;
StartupNotify=false

29
packaging/linux/nex.spec Normal file
View File

@@ -0,0 +1,29 @@
Name: nex
Version: %{nex_version}
Release: 1%{?dist}
Summary: AI Gateway desktop application
License: Apache-2.0
URL: https://github.com/nex/gateway
Requires: gtk3
Requires: libayatana-appindicator-gtk3
Requires: xdg-utils
%description
Nex is an AI Gateway desktop application.
%prep
%build
%install
mkdir -p %{buildroot}/usr/bin
install -m 0755 %{nex_binary} %{buildroot}/usr/bin/nex
mkdir -p %{buildroot}/usr/share/applications
install -m 0644 %{nex_desktop_file} %{buildroot}/usr/share/applications/nex.desktop
mkdir -p %{buildroot}/usr/share/icons/hicolor
cp -a %{nex_icons_dir}/. %{buildroot}/usr/share/icons/hicolor/
%files
/usr/bin/nex
/usr/share/applications/nex.desktop
/usr/share/icons/hicolor/*/apps/nex.png

67
scripts/git-hooks/commit-msg Executable file
View File

@@ -0,0 +1,67 @@
#!/bin/sh
set -e
MSG_FILE=$1
if [ ! -f "$MSG_FILE" ]; then
printf '%s\n' '提交信息文件不存在。' >&2
exit 1
fi
FIRST_LINE=
SECOND_LINE=
HAS_BODY=
LINE_NO=0
while IFS= read -r LINE || [ -n "$LINE" ]; do
case "$LINE" in
\#*) continue ;;
esac
if [ -z "$FIRST_LINE" ]; then
[ -n "$LINE" ] || continue
FIRST_LINE=$LINE
LINE_NO=1
continue
fi
LINE_NO=$((LINE_NO + 1))
case "$LINE_NO" in
2) SECOND_LINE=$LINE ;;
*)
if [ -n "$LINE" ]; then
HAS_BODY=1
fi
;;
esac
done < "$MSG_FILE"
case "$FIRST_LINE" in
Merge*)
exit 0
;;
esac
if ! printf '%s\n' "$FIRST_LINE" | grep -Eq '^(feat|fix|refactor|docs|style|test|chore): .+$'; then
cat >&2 <<'EOF'
提交信息格式错误。
格式: <类型>: <简短描述>
类型: feat / fix / refactor / docs / style / test / chore
示例:
feat: 添加供应商批量管理功能
fix: 修复流式响应断连问题
chore: 版本升迁 v0.2.0
EOF
exit 1
fi
if [ ${#FIRST_LINE} -gt 72 ]; then
printf '%s\n' '警告: 提交信息首行超过 72 个字符,建议保持简短。' >&2
fi
if [ -n "$HAS_BODY" ] && [ -n "$SECOND_LINE" ]; then
printf '%s\n' '提交信息首行后应为空行,再写详细描述。' >&2
exit 1
fi

12
scripts/git-hooks/pre-commit Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
set -e
ROOT_DIR=$(git rev-parse --show-toplevel)
cd "$ROOT_DIR"
command -v make >/dev/null 2>&1 || {
printf '%s\n' '缺少 make 命令,请先安装 Make 或使用 Git Bash/MINGW64 环境。' >&2
exit 1
}
exec make _hooks-pre-commit

View File

@@ -0,0 +1,49 @@
#!/bin/sh
set -e
MSG_FILE=$1
MSG_SOURCE=$2
case "$MSG_SOURCE" in
"") ;;
*) exit 0 ;;
esac
if [ ! -f "$MSG_FILE" ]; then
exit 0
fi
has_content=0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|\#*) ;;
*)
has_content=1
break
;;
esac
done < "$MSG_FILE"
if [ "$has_content" -eq 1 ]; then
exit 0
fi
tmp_file=${MSG_FILE}.nex-template.$$
{
cat <<'EOF'
# <类型>: <简短中文描述>
#
# <详细说明>
#
# 类型: feat / fix / refactor / docs / style / test / chore
# 示例: feat: 添加供应商批量管理功能
EOF
if [ -s "$MSG_FILE" ]; then
printf '\n'
while IFS= read -r line || [ -n "$line" ]; do
printf '%s\n' "$line"
done < "$MSG_FILE"
fi
} > "$tmp_file"
mv "$tmp_file" "$MSG_FILE"

273
scripts/git-hooks/test-hooks.sh Executable file
View File

@@ -0,0 +1,273 @@
#!/bin/sh
set -eu
ROOT_DIR=$(git rev-parse --show-toplevel)
cd "$ROOT_DIR"
TMP_DIR=${TMPDIR:-/tmp}/nex-hooks-test.$$
mkdir -p "$TMP_DIR"
cleanup() {
rm -f \
backend/pkg/buildinfo/hook_bad_test_fixture.go \
frontend/src/hook_bad_fixture.ts \
frontend/src/hook_format_fixture.ts \
docs/hook-doc-fixture.md \
docs/hook-conflict-fixture.md \
docs/hook-large-fixture.txt \
"$TMP_DIR/lfs-pointer-fixture" \
"$TMP_DIR/lfs-bad-fixture"
rm -rf "$TMP_DIR"
}
trap cleanup EXIT HUP INT TERM
pass() {
printf 'OK: %s\n' "$1"
}
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
write_msg() {
file=$1
shift
printf '%s\n' "$*" > "$file"
}
write_conflict() {
file=$1
less7=$(printf '<%.0s' $(seq 7))
eq7=$(printf '=%.0s' $(seq 7))
gt7=$(printf '>%.0s' $(seq 7))
printf '%s\n' "${less7} HEAD" '' "${eq7}" '' "${gt7} branch" > "$file"
}
expect_success() {
name=$1
shift
if "$@" > "$TMP_DIR/out" 2>&1; then
pass "$name"
else
cat "$TMP_DIR/out" >&2
fail "$name"
fi
}
expect_failure() {
name=$1
shift
if "$@" > "$TMP_DIR/out" 2>&1; then
cat "$TMP_DIR/out" >&2
fail "$name"
fi
pass "$name"
}
run_precommit_for() {
index=$TMP_DIR/index
rm -f "$index"
GIT_INDEX_FILE=$index git read-tree HEAD
for file in "$@"; do
GIT_INDEX_FILE=$index git add -f "$file"
done
GIT_INDEX_FILE=$index make _hooks-pre-commit
}
run_hooks_install_missing_source() {
install_repo=$TMP_DIR/hooks-install-missing
rm -rf "$install_repo"
mkdir -p "$install_repo/scripts/git-hooks"
cp Makefile "$install_repo/Makefile"
cp scripts/git-hooks/pre-commit "$install_repo/scripts/git-hooks/pre-commit"
cp scripts/git-hooks/commit-msg "$install_repo/scripts/git-hooks/commit-msg"
git -C "$install_repo" init >/dev/null 2>&1
(cd "$install_repo" && make hooks-install)
}
MSG_FILE=$TMP_DIR/commit-msg.txt
# ============================================
# commit-msg 测试
# ============================================
write_msg "$MSG_FILE" 'feat: 添加 hook 测试'
expect_success 'commit-msg accepts Chinese description' scripts/git-hooks/commit-msg "$MSG_FILE"
write_msg "$MSG_FILE" 'feat: add hook tests'
expect_success 'commit-msg accepts English-only description without CJK enforcement' scripts/git-hooks/commit-msg "$MSG_FILE"
write_msg "$MSG_FILE" 'fix: 修复 auth 模块 bug'
expect_success 'commit-msg accepts Chinese with English technical terms' scripts/git-hooks/commit-msg "$MSG_FILE"
write_msg "$MSG_FILE" 'docs: ajouter une fonctionnalité'
expect_success 'commit-msg accepts non-CJK unicode description without CJK enforcement' scripts/git-hooks/commit-msg "$MSG_FILE"
write_msg "$MSG_FILE" 'update: 添加 hook 测试'
expect_failure 'commit-msg rejects invalid type' scripts/git-hooks/commit-msg "$MSG_FILE"
write_msg "$MSG_FILE" 'Merge branch feature'
expect_success 'commit-msg accepts merge commits' scripts/git-hooks/commit-msg "$MSG_FILE"
write_msg "$MSG_FILE" 'feat: 添加新功能
'
expect_success 'commit-msg accepts single line with trailing newline' scripts/git-hooks/commit-msg "$MSG_FILE"
printf 'feat: 添加新功能\n\n详细描述内容\n' > "$MSG_FILE"
expect_success 'commit-msg accepts multi-line with blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE"
printf 'feat: 添加新功能\n缺少空行\n详细描述\n' > "$MSG_FILE"
expect_failure 'commit-msg rejects multi-line without blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE"
printf 'feat: 添加新功能\n\n' > "$MSG_FILE"
expect_success 'commit-msg accepts two lines with blank line 2' scripts/git-hooks/commit-msg "$MSG_FILE"
printf 'feat: 添加新功能\n非空行\n' > "$MSG_FILE"
expect_success 'commit-msg accepts two lines without body (no line 3)' scripts/git-hooks/commit-msg "$MSG_FILE"
printf 'feat: 添加模板测试\n# <类型>: <简短中文描述>\n#\n# <详细说明>\n' > "$MSG_FILE"
expect_success 'commit-msg ignores template comments after subject' scripts/git-hooks/commit-msg "$MSG_FILE"
printf '# <类型>: <简短中文描述>\n#\nfeat: 添加模板测试\n' > "$MSG_FILE"
expect_success 'commit-msg ignores leading template comments' scripts/git-hooks/commit-msg "$MSG_FILE"
printf 'feat: 添加新功能\n缺少空行\n# 模板注释\n详细描述\n' > "$MSG_FILE"
expect_failure 'commit-msg rejects non-blank separator with intervening comments' scripts/git-hooks/commit-msg "$MSG_FILE"
# ============================================
# prepare-commit-msg 测试
# ============================================
prepare_msg_file="$TMP_DIR/prepare-msg.txt"
rm -f "$prepare_msg_file"
touch "$prepare_msg_file"
expect_success 'prepare-commit-msg writes template for empty commit' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
if grep -q '<类型>' "$prepare_msg_file" && grep -q 'feat / fix / refactor' "$prepare_msg_file"; then
pass 'prepare-commit-msg template contains format guidance'
else
fail 'prepare-commit-msg template contains format guidance'
fi
printf '\n# Please enter the commit message for your changes.\n# On branch main\n' > "$prepare_msg_file"
expect_success 'prepare-commit-msg writes template before git comments' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
if grep -q '<类型>' "$prepare_msg_file" && grep -q 'Please enter the commit message' "$prepare_msg_file"; then
pass 'prepare-commit-msg preserves git comments after template'
else
fail 'prepare-commit-msg preserves git comments after template'
fi
write_msg "$prepare_msg_file" 'existing content'
expect_success 'prepare-commit-msg skips when file has content' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
if printf '%s\n' "$(cat "$prepare_msg_file")" | grep -q '^existing content$'; then
pass 'prepare-commit-msg does not overwrite existing content'
else
fail 'prepare-commit-msg does not overwrite existing content'
fi
rm -f "$prepare_msg_file"
touch "$prepare_msg_file"
expect_success 'prepare-commit-msg skips for merge' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" "merge"
if [ ! -s "$prepare_msg_file" ]; then
pass 'prepare-commit-msg skips template for merge'
else
fail 'prepare-commit-msg skips template for merge'
fi
# ============================================
# hooks-install 测试
# ============================================
expect_failure 'hooks-install rejects missing source hook' run_hooks_install_missing_source
# ============================================
# pre-commit 测试
# ============================================
cat > backend/pkg/buildinfo/hook_bad_test_fixture.go <<'EOF'
package buildinfo
import "fmt"
func hookBadTestFixture() {
fmt.Println("bad")
}
EOF
expect_failure 'pre-commit rejects Go lint errors (delegated to _backend-lint)' run_precommit_for backend/pkg/buildinfo/hook_bad_test_fixture.go
rm -f backend/pkg/buildinfo/hook_bad_test_fixture.go
cat > frontend/src/hook_bad_fixture.ts <<'EOF'
console.log('bad')
EOF
expect_failure 'pre-commit rejects frontend lint errors (delegated to _frontend-check)' run_precommit_for frontend/src/hook_bad_fixture.ts
rm -f frontend/src/hook_bad_fixture.ts
cat > frontend/src/hook_format_fixture.ts <<'EOF'
const hookFormatFixture={foo:"bar"}
export { hookFormatFixture }
EOF
expect_failure 'pre-commit rejects frontend format errors (delegated to _frontend-check)' run_precommit_for frontend/src/hook_format_fixture.ts
rm -f frontend/src/hook_format_fixture.ts
cat > docs/hook-doc-fixture.md <<'EOF'
hook doc fixture
EOF
expect_success 'pre-commit skips non-code staged files' run_precommit_for docs/hook-doc-fixture.md
rm -f docs/hook-doc-fixture.md
write_conflict docs/hook-conflict-fixture.md
expect_failure 'pre-commit rejects conflict markers' run_precommit_for docs/hook-conflict-fixture.md
rm -f docs/hook-conflict-fixture.md
index=$TMP_DIR/index
rm -f "$index"
GIT_INDEX_FILE=$index git read-tree HEAD
write_conflict "$TMP_DIR/hook-conflict-fixture.sh"
hash=$(git hash-object -w "$TMP_DIR/hook-conflict-fixture.sh")
rm -f "$TMP_DIR/hook-conflict-fixture.sh"
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "scripts/git-hooks/hook-conflict-fixture.sh"
expect_failure 'pre-commit rejects conflict markers in hook scripts' env GIT_INDEX_FILE=$index make _hooks-pre-commit
i=0
while [ "$i" -lt 40000 ]; do
printf 'large hook fixture line\n'
i=$((i + 1))
done > docs/hook-large-fixture.txt
if run_precommit_for docs/hook-large-fixture.txt > "$TMP_DIR/out" 2>&1 && grep -q 'Warning: large staged text file' "$TMP_DIR/out"; then
pass 'pre-commit warns for large text files'
else
cat "$TMP_DIR/out" >&2
fail 'pre-commit warns for large text files'
fi
rm -f docs/hook-large-fixture.txt
# LFS pointer 校验
lfs_pointer='version https://git-lfs.github.com/spec/v1
oid sha256:abc123
size 100
'
printf '%s\n' "$lfs_pointer" > "$TMP_DIR/lfs-pointer-fixture"
hash=$(git hash-object -w "$TMP_DIR/lfs-pointer-fixture")
index=$TMP_DIR/index
rm -f "$index"
GIT_INDEX_FILE=$index git read-tree HEAD
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-fixture.png"
if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then
pass 'pre-commit allows LFS pointer files'
else
cat "$TMP_DIR/out" >&2
fail 'pre-commit allows LFS pointer files'
fi
printf 'fake binary content\n' > "$TMP_DIR/lfs-bad-fixture"
hash=$(git hash-object -w "$TMP_DIR/lfs-bad-fixture")
rm -f "$index"
GIT_INDEX_FILE=$index git read-tree HEAD
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-bad-fixture.png"
if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then
cat "$TMP_DIR/out" >&2
fail 'pre-commit rejects non-pointer LFS files'
fi
pass 'pre-commit rejects non-pointer LFS files'

Some files were not shown because too many files have changed in this diff Show More