1
0

26 Commits

Author SHA1 Message Date
6908b9653b feat: CI check job 扩展为三平台 matrix 并行 lint/test
将 test.yml 的 check job 从单平台 ubuntu 改为 ubuntu/macos/windows 三平台并行,
Linux 额外安装 libayatana-appindicator3-dev 以支持 systray CGo 编译。
2026-05-07 17:07:55 +08:00
d8e64ef0e9 chore: 更新应用图标资源 2026-05-07 15:34:25 +08:00
fb9f6d1d00 refactor: 统计页面改名为"总览"并提升至侧边栏首位
将侧边栏"用量统计"菜单项改名为"总览",移至第一位,
默认路由重定向从 /providers 改为 /stats
2026-05-07 15:05:45 +08:00
e4c96da8a9 fix: CI 触发分支对齐仓库实际默认分支 master 2026-05-07 14:20:29 +08:00
1195e119c6 chore: 合并 dev-ci-optimize 至 master 2026-05-07 14:17:43 +08:00
4eeb14e844 feat: 新增启动参数设置页面,区分 desktop 可编辑与 server 只读 2026-05-07 14:10:56 +08:00
0d30ed9a0f feat: 新增开发 CI 流程,重构 test.yml 支持分层测试
新增 ci.yml,在 push(dev/main)和 PR 时触发快速检查(lint + 全量测试)
重构 test.yml,新增 full 参数控制是否运行 MySQL 和 E2E 测试
release.yml 调用 test.yml 时传 full: true,行为与重构前一致
同步更新 ci-test-gate spec
2026-05-07 12:43:08 +08:00
cd0b3e8fc1 feat: release CI 加入全流程测试门禁
新增独立可复用测试 workflow(test.yml),在 release 构建前串行执行
lint、默认测试、MySQL 测试和 E2E 测试,测试不通过则阻止发布构建。
2026-05-07 12:14:00 +08:00
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
84 changed files with 4945 additions and 515 deletions

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

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

View File

@@ -19,6 +19,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
@@ -35,15 +37,24 @@ jobs:
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@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
@@ -56,14 +67,65 @@ jobs:
- 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
@@ -73,6 +135,12 @@ jobs:
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
@@ -80,18 +148,33 @@ 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@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
@@ -107,15 +190,16 @@ jobs:
- name: Setup MSYS2 toolchain
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
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
@@ -124,8 +208,10 @@ jobs:
bun --version
command -v make
make --version
command -v gcc
gcc --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
@@ -134,26 +220,33 @@ jobs:
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@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
@@ -169,13 +262,16 @@ jobs:
- 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
@@ -185,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:
@@ -200,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:

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

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

2
.gitignore vendored
View File

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

417
Makefile
View File

@@ -1,15 +1,21 @@
.PHONY: \
lint test clean \
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
# 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)))
@@ -17,16 +23,32 @@ 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
SERVER_LINUX_ASSET = $(call lazy_shell,_SERVER_LINUX_ASSET,go run ./versionctl asset-name server linux amd64)
SERVER_WINDOWS_ASSET = $(call lazy_shell,_SERVER_WINDOWS_ASSET,go run ./versionctl asset-name server windows amd64)
SERVER_DARWIN_AMD64_ASSET = $(call lazy_shell,_SERVER_DARWIN_AMD64_ASSET,go run ./versionctl asset-name server darwin amd64)
SERVER_DARWIN_ARM64_ASSET = $(call lazy_shell,_SERVER_DARWIN_ARM64_ASSET,go run ./versionctl asset-name server darwin arm64)
DESKTOP_LINUX_ASSET = $(call lazy_shell,_DESKTOP_LINUX_ASSET,go run ./versionctl asset-name desktop linux)
DESKTOP_WINDOWS_ASSET = $(call lazy_shell,_DESKTOP_WINDOWS_ASSET,go run ./versionctl asset-name desktop windows)
DESKTOP_MACOS_ASSET = $(call lazy_shell,_DESKTOP_MACOS_ASSET,go run ./versionctl asset-name desktop macos)
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)
# ============================================
# 全局命令
@@ -41,6 +63,101 @@ test: _backend-test _frontend-test _desktop-test _versionctl-test
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'
# ============================================
# 版本管理
# ============================================
@@ -52,13 +169,21 @@ version-check:
go run ./versionctl check
version-bump: BUMP ?= patch
version-bump:
$(eval _BUMP_ARG := $(if $(SET_VERSION),$(SET_VERSION),$(BUMP)))
$(eval _NEW_VERSION := $(shell 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)"
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 模式
@@ -94,13 +219,17 @@ 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 \
@@ -111,20 +240,16 @@ desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embe
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
@@ -144,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

103
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
@@ -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,16 +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:
@@ -238,9 +292,9 @@ log:
compress: true
```
### 环境变量
### 环境变量(仅 Server 模式)
所有配置项支持环境变量,使用 `NEX_` 前缀:
Server 模式下,所有配置项支持环境变量,使用 `NEX_` 前缀:
```bash
export NEX_SERVER_PORT=9000
@@ -258,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
@@ -266,6 +324,8 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转 kebab-case`server.port``--server-port`)。
**Desktop 不支持命令行参数覆盖配置。**Desktop 忽略所有 CLI 参数,仅从 `~/.nex/config.yaml` 读取。
### 数据文件
- `~/.nex/config.yaml` - 配置文件
@@ -289,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 # 前后端共享检查
@@ -312,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 不做字符集检测
## 版本与发布
### 统一版本源
@@ -322,7 +394,7 @@ make desktop-clean # 清理 desktop 产物
### 本地版本演进
```bash
# 递增版本(自动 sync + check + commit + tag
# 递增版本(自动 lint + test + 工作区检查 + sync/check + commit + tag
make version-bump BUMP=minor
# 或指定具体版本号
@@ -357,9 +429,10 @@ make release-assets-macos
### GitHub Draft Release
- 推送 `vX.Y.Z` tag 后,`.github/workflows/release.yml` 会自动执行发布流水线
- 三个平台 job 会在正式构建前先检查 `go``bun` 和各自的平台打包工具链,缺失时快速失败并在日志中输出诊断信息
- 流水线会先校验 tag 与 `VERSION` 一致再执行全流程测试门禁lint、默认测试、MySQL 测试、E2E 测试),测试不通过则阻止构建
- 测试通过后,三个平台 job 并行构建,各 job 会在正式构建前先检查 `go``bun` 和各自的平台打包工具链,缺失时快速失败并在日志中输出诊断信息
- Windows 发布 job 在 `MSYS2 / MINGW64` shell 中执行,并继承 `setup-go` / `setup-bun` 准备好的工具链路径
- 流水线会先校验 tag 与 `VERSION` 一致,再构建以下资产并上传到 GitHub Draft Release
- 构建以下资产并上传到 GitHub Draft Release
- Linux server
- Windows server
- darwin-amd64 server

View File

@@ -1 +1 @@
0.1.1
0.1.7

Binary file not shown.

BIN
assets/icon.ico LFS

Binary file not shown.

BIN
assets/icon.png LFS

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

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,
@@ -131,6 +133,7 @@ func main() {
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
versionHandler := handler.NewVersionHandler()
settingsHandler := handler.NewSettingsHandler(cfg, "desktop", true, cfgMeta.ConfigPath)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -140,11 +143,11 @@ func main() {
r.Use(middleware.Logging(zapLogger))
r.Use(middleware.CORS())
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
setupStaticFiles(r)
server = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Addr: desktopListenAddr(port),
Handler: r,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
@@ -165,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))
}
}()
@@ -173,7 +176,7 @@ func main() {
setupSystray(port)
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler) {
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
r.Any("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
r.GET("/api/version", versionHandler.GetVersion)
@@ -202,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"})
})
@@ -309,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("退出", "停止服务并退出")
@@ -318,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:
@@ -349,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

@@ -16,7 +16,7 @@ func TestSetupRoutes_VersionDoesNotFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler())
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "desktop", true, ""))
setupStaticFilesWithFS(r, fstest.MapFS{
"index.html": {Data: []byte("<html>fallback</html>")},
})

View File

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

View File

@@ -15,7 +15,7 @@ func TestSetupRoutes_Version(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler())
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "server", false, ""))
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
w := httptest.NewRecorder()

View File

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

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

@@ -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.1
VITE_APP_VERSION=0.1.7

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# Nex Frontend
AI 网关管理前端,提供供应商配置和用量统计界面。
AI 网关管理前端,提供供应商配置和总览界面。
## 技术栈
@@ -97,7 +97,7 @@ frontend/
│ │ └── useVersion.ts
│ ├── pages/
│ │ ├── Providers/ # 供应商管理(含内嵌模型管理)
│ │ ├── Stats/ # 用量统计
│ │ ├── Stats/ # 总览
│ │ ├── Settings/ # 设置(开发中)
│ │ ├── About/ # 关于页面(品牌与版本信息)
│ │ └── NotFound.tsx
@@ -149,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 和格式
```
### 代码格式化
@@ -188,16 +189,17 @@ 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
### 用量统计
### 总览
- 查看统计数据
- 按供应商筛选

View File

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

View File

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

View File

@@ -1,17 +1,18 @@
{
"name": "frontend",
"private": true,
"version": "0.1.1",
"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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

@@ -2,7 +2,7 @@
## Purpose
TBD - 提供供应商、模型配置和用量统计的前端管理界面
TBD - 提供供应商、模型配置和总览的前端管理界面
## Requirements
@@ -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** 用户在展开行中点击"添加模型"
@@ -344,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
@@ -418,7 +489,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
- **THEN** 侧边栏 SHALL 包含导航菜单
- **THEN** 导航菜单项 SHALL 包含供应商管理ServerIcon 图标、用量统计ChartLineIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
- **THEN** 导航菜单项 SHALL 按以下顺序包含总览ChartLineIcon 图标、供应商管理ServerIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
#### Scenario: 侧边栏折叠品牌显示
@@ -431,7 +502,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计"
- **WHEN** 用户点击导航中的"总览"
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"设置"
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
@@ -447,10 +518,10 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 应用启动
- **THEN** 前端 SHALL 使用 React Router v7 Library 模式BrowserRouter
- **THEN** `/providers` 路径 SHALL 显示供应商管理页面
- **THEN** `/stats` 路径 SHALL 显示用量统计页面
- **THEN** `/stats` 路径 SHALL 显示总览页面
- **THEN** `/settings` 路径 SHALL 显示设置页面
- **THEN** `/about` 路径 SHALL 显示关于页面
- **THEN** `/` 路径 SHALL 重定向到 `/providers`
- **THEN** `/` 路径 SHALL 重定向到 `/stats`
- **THEN** 不存在的路径 SHALL 显示 404 页面
#### Scenario: 路由级懒加载
@@ -464,7 +535,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计"
- **WHEN** 用户点击导航中的"总览"
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
#### Scenario: URL 同步

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

@@ -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,12 +8,12 @@
### Requirement: Tag 驱动发布流水线
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。发布流水线 SHALL 使用 `./versionctl` 而非 `./backend/cmd/versionctl` 调用版本管理工具。
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。发布流水线 SHALL 使用 `./versionctl` 而非 `./backend/cmd/versionctl` 调用版本管理工具。发布流水线 SHALL 在进入构建阶段前完成全流程测试验证,测试未通过 SHALL NOT 执行任何构建。
#### Scenario: 有效发布 tag
- **WHEN** 仓库收到 `v1.2.3` tag push
- **THEN** 发布流水线 SHALL 启动版本校验、构建和 Release 组装步骤
- **THEN** 发布流水线 SHALL 启动版本校验、全流程测试、构建和 Release 组装步骤
- **AND** 版本校验步骤 SHALL 使用 `go run ./versionctl print``go run ./versionctl verify-tag` 获取并验证版本
#### Scenario: 普通分支推送
@@ -21,6 +21,19 @@
- **WHEN** 仓库收到非 tag 的分支 push
- **THEN** 系统 SHALL NOT 创建 GitHub Release
#### Scenario: 测试门禁阻止构建
- **WHEN** 发布流水线中全流程测试步骤lint、默认测试、MySQL 测试、E2E 测试)任一失败
- **THEN** 发布流水线 SHALL NOT 执行任何平台构建
- **THEN** 发布流水线 SHALL NOT 创建 Draft Release
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
#### Scenario: 测试通过后并行构建
- **WHEN** 全流程测试全部通过
- **THEN** web、Linux、Windows、macOS 构建 SHALL 并行执行
- **AND** 所有构建 job SHALL 依赖 `prepare``test-gate`
### Requirement: 发布流水线 Go 模块缓存覆盖
发布流水线 SHALL 在所有 Go module 的 go.sum 文件存在时正确设置 Go 模块缓存路径,确保新增的 `versionctl` module 依赖也被缓存。
@@ -32,58 +45,127 @@
### Requirement: 三平台发布构建
系统 SHALL 在发布流水线中构建 server 与 desktop 的 Linux、Windows、macOS 三个平台产物
系统 SHALL 在发布流水线中构建 server、web 与 desktop 的发布产物,并覆盖 Linux、Windows、macOS 的目标架构和格式矩阵
#### Scenario: Linux 发布构建
#### Scenario: server 发布构建
- **WHEN** 发布流水线执行 Linux 构建 job
- **THEN** 系统 SHALL 在可访问 Go、Bun 和 Linux 桌面构建依赖的 shell 环境中执行 Linux 发布构建
- **AND** 系统 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 在包含 MSYS2 / MINGW64 构建工具且可访问 Go 与 Bun 工具链的 shell 环境中执行 Windows 发布构建
- **AND** 系统 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 在可访问 Go、Bun 和 macOS 打包工具链的 shell 环境中执行 macOS 发布构建
- **AND** 系统 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 在正式执行各平台 `make release-assets-*` 前验证对应发布 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。
系统 SHALL 在正式执行各平台 release 构建前验证对应 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。
#### Scenario: Linux 预检通过后开始构建
- **WHEN** Linux 发布 job 中的 `go``bun` Linux 桌面构建依赖均可用
- **WHEN** Linux 发布 job 中的 `go``bun``gcc``pkg-config`、GTK3、Ayatana AppIndicator 和 Linux 打包工具均可用
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
- **AND** 系统 SHALL 继续执行 `make release-assets-linux`
- **AND** 系统 SHALL 继续执行对应 Linux release 构建
#### Scenario: Windows 预检通过后开始构建
- **WHEN** Windows 发布 job 中的 `go``bun` 与 MSYS2 构建工具均可用
- **WHEN** Windows 发布 job 中的 `go``bun``make`、对应架构 CGO 编译器和 resource 生成工具均可用
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
- **AND** 系统 SHALL 继续执行 `make release-assets-windows`
- **AND** 系统 SHALL 继续执行对应 Windows release 构建
#### Scenario: macOS 预检通过后开始构建
- **WHEN** macOS 发布 job 中的 `go``bun` 与 macOS 打包工具均可用
- **WHEN** macOS 发布 job 中的 `go``bun``ditto``lipo``vtool``hdiutil` 均可用
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
- **AND** 系统 SHALL 继续执行 `make release-assets-macos`
- **AND** 系统 SHALL 继续执行对应 macOS release 构建
#### Scenario: 任一平台预检发现工具缺失
#### Scenario: web 预检通过后开始构建
- **WHEN** 任一平台发布 job 中存在关键工具不可用
- **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 或执行约束。
@@ -100,34 +182,83 @@
### 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

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

@@ -90,22 +90,34 @@
### Requirement: 版本升迁 Makefile 编排
`make version-bump` SHALL 编排完整的版本升迁流程:工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch`
`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 依次执行:工作区检查 → `version bump minor``git add VERSION frontend/``git commit -m "chore: 版本升迁 v0.2.0"``git tag v0.2.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 以非零退出码失败并提示先提交或暂存改动
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或清理改动
#### Scenario: 支持指定版本号

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'

30
scripts/push-all-remotes.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR=$(git rev-parse --show-toplevel)
BRANCH=$(git branch --show-current)
if [[ -z "$BRANCH" ]]; then
echo "当前仓库处于 detached HEAD 状态,无法推送当前分支" >&2
exit 1
fi
cd "$ROOT_DIR"
REMOTES=()
while IFS= read -r REMOTE; do
REMOTES+=("$REMOTE")
done < <(git remote)
if [[ ${#REMOTES[@]} -eq 0 ]]; then
echo "当前仓库未配置任何远端" >&2
exit 1
fi
for REMOTE in "${REMOTES[@]}"; do
echo "推送分支 $BRANCH 和 tags 到远端 $REMOTE"
git push "$REMOTE" "$BRANCH" --tags
done
echo "已推送分支 $BRANCH 和 tags 到所有远端"

View File

@@ -53,6 +53,11 @@ func run(args []string) error {
return printMacOSPlist(root, args[1])
case "asset-name":
return printAssetName(root, args[1:])
case "release-assets-check":
if len(args) != 1 {
return fmt.Errorf("release-assets-check 不需要额外参数")
}
return projectversion.CheckReleaseAssets(root)
default:
return usageError()
}
@@ -101,8 +106,8 @@ func printMacOSPlist(root, minMacOSVersion string) error {
}
func printAssetName(root string, args []string) error {
if len(args) < 2 {
return fmt.Errorf("asset-name 至少需要 kind 和 platform 参数")
if len(args) == 0 {
return fmt.Errorf("asset-name 需要组件参数: server|web|desktop")
}
version, err := projectversion.ReadString(root)
@@ -110,30 +115,31 @@ func printAssetName(root string, args []string) error {
return err
}
var platform, arch, format string
switch args[0] {
case "server":
if len(args) != 3 {
return fmt.Errorf("server 资产命名需要 platformarch 参数")
case "server", "desktop":
if len(args) != 4 {
return fmt.Errorf("%s 资产命名需要 platformarch 和 format 参数", args[0])
}
name, nameErr := projectversion.ServerAssetName(version, args[1], args[2])
if nameErr != nil {
return nameErr
}
fmt.Println(name)
return nil
case "desktop":
platform = args[1]
arch = args[2]
format = args[3]
case "web":
if len(args) != 2 {
return fmt.Errorf("desktop 资产命名只需要 platform 参数")
return fmt.Errorf("web 资产命名只需要 format 参数")
}
name, nameErr := projectversion.DesktopAssetName(version, args[1])
if nameErr != nil {
return nameErr
}
fmt.Println(name)
return nil
format = args[1]
default:
return fmt.Errorf("不支持的资产类型 %q", args[0])
return fmt.Errorf("不支持的资产组件 %q", args[0])
}
name, nameErr := projectversion.ReleaseAssetName(version, args[0], platform, arch, format)
if nameErr != nil {
return nameErr
}
fmt.Println(name)
return nil
}
func mustGetwd() string {
@@ -147,5 +153,5 @@ func mustGetwd() string {
}
func usageError() error {
return fmt.Errorf("用法: version <print|sync|check|verify-tag|bump|macos-plist|asset-name>")
return fmt.Errorf("用法: version <print|sync|check|verify-tag|bump|macos-plist|asset-name|release-assets-check>")
}

View File

@@ -0,0 +1,69 @@
package projectversion
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
)
var releaseAssetChecks = []releaseAssetCheck{
{
path: "assets/icon.ico",
description: "Windows ICO 图标",
magic: []byte{0x00, 0x00, 0x01, 0x00},
},
{
path: "assets/icon.icns",
description: "macOS ICNS 图标",
magic: []byte("icns"),
},
{
path: "assets/icon.png",
description: "PNG 图标",
magic: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
},
{
path: "frontend/public/icon.png",
description: "前端 PNG 图标",
magic: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
},
}
var gitLFSPointerPrefix = []byte("version https://git-lfs.github.com/spec/v1")
type releaseAssetCheck struct {
path string
description string
magic []byte
}
func CheckReleaseAssets(root string) error {
var errs []error
for _, check := range releaseAssetChecks {
if err := checkReleaseAsset(root, check); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func checkReleaseAsset(root string, check releaseAssetCheck) error {
content, err := os.ReadFile(filepath.Join(root, check.path))
if err != nil {
return fmt.Errorf("%s 不可读取: %w", check.path, err)
}
if bytes.HasPrefix(content, gitLFSPointerPrefix) {
return fmt.Errorf("%s 是 Git LFS pointer请先拉取 Git LFS 真实内容", check.path)
}
if !bytes.HasPrefix(content, check.magic) {
return fmt.Errorf("%s 不是有效的%s", check.path, check.description)
}
return nil
}

View File

@@ -0,0 +1,58 @@
package projectversion
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckReleaseAssets(t *testing.T) {
t.Run("valid assets", func(t *testing.T) {
root := setupReleaseAssetRoot(t)
require.NoError(t, CheckReleaseAssets(root))
})
t.Run("lfs pointer", func(t *testing.T) {
root := setupReleaseAssetRoot(t)
writeReleaseAsset(t, root, "assets/icon.ico", []byte("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"))
err := CheckReleaseAssets(root)
require.Error(t, err)
assert.Contains(t, err.Error(), "assets/icon.ico 是 Git LFS pointer")
})
t.Run("invalid format", func(t *testing.T) {
root := setupReleaseAssetRoot(t)
writeReleaseAsset(t, root, "frontend/public/icon.png", []byte("not a png"))
err := CheckReleaseAssets(root)
require.Error(t, err)
assert.Contains(t, err.Error(), "frontend/public/icon.png 不是有效的前端 PNG 图标")
})
}
func setupReleaseAssetRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeReleaseAsset(t, root, "assets/icon.ico", []byte{0x00, 0x00, 0x01, 0x00, 0x01})
writeReleaseAsset(t, root, "assets/icon.icns", []byte("icnsdata"))
writeReleaseAsset(t, root, "assets/icon.png", []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00})
writeReleaseAsset(t, root, "frontend/public/icon.png", []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00})
return root
}
func writeReleaseAsset(t *testing.T, root, relPath string, content []byte) {
t.Helper()
fullPath := filepath.Join(root, relPath)
require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
require.NoError(t, os.WriteFile(fullPath, content, 0o600))
}

View File

@@ -263,44 +263,84 @@ func ReadEnvVar(content, key string) (string, bool) {
return "", false
}
func ServerAssetName(version, goos, arch string) (string, error) {
func ReleaseAssetName(version, component, platform, arch, format string) (string, error) {
if _, err := Parse(version); err != nil {
return "", err
}
switch goos {
case "linux", "windows", "darwin":
switch component {
case "server":
return serverAssetName(version, platform, arch, format)
case "web":
return webAssetName(version, platform, arch, format)
case "desktop":
return desktopAssetName(version, platform, arch, format)
default:
return "", fmt.Errorf("不支持的 server 平台 %q", goos)
return "", fmt.Errorf("不支持的资产组件 %q", component)
}
if arch == "" {
return "", errors.New("server 资产命名缺少架构")
}
ext := ".tar.gz"
if goos == "windows" {
ext = ".zip"
}
return fmt.Sprintf("nex-server_%s_%s_%s%s", version, goos, arch, ext), nil
}
func DesktopAssetName(version, platform string) (string, error) {
if _, err := Parse(version); err != nil {
return "", err
func serverAssetName(version, platform, arch, format string) (string, error) {
if !validCombination(platform, arch, format, []releaseAssetTarget{
{platform: "linux", arch: "amd64", format: "tar.gz"},
{platform: "linux", arch: "arm64", format: "tar.gz"},
{platform: "macos", arch: "amd64", format: "tar.gz"},
{platform: "macos", arch: "arm64", format: "tar.gz"},
{platform: "macos", arch: "universal", format: "tar.gz"},
{platform: "windows", arch: "amd64", format: "zip"},
}) {
return "", fmt.Errorf("不支持的 server 资产目标 %s/%s/%s", platform, arch, format)
}
switch platform {
case "linux":
return fmt.Sprintf("Nex_%s_linux_amd64.tar.gz", version), nil
case "windows":
return fmt.Sprintf("Nex_%s_windows_amd64.zip", version), nil
case "macos":
return fmt.Sprintf("Nex_%s_macOS_universal.zip", version), nil
default:
return "", fmt.Errorf("不支持的 desktop 平台 %q", platform)
return fmt.Sprintf("nex-server_%s_%s_%s.%s", version, platform, arch, format), nil
}
func webAssetName(version, platform, arch, format string) (string, error) {
if platform != "" || arch != "" {
return "", errors.New("web 资产命名不支持平台或架构参数")
}
if format != "tar.gz" {
return "", fmt.Errorf("不支持的 web 资产格式 %q", format)
}
return fmt.Sprintf("nex-web_%s.tar.gz", version), nil
}
func desktopAssetName(version, platform, arch, format string) (string, error) {
if !validCombination(platform, arch, format, []releaseAssetTarget{
{platform: "linux", arch: "amd64", format: "tar.gz"},
{platform: "linux", arch: "amd64", format: "AppImage"},
{platform: "linux", arch: "amd64", format: "deb"},
{platform: "linux", arch: "amd64", format: "rpm"},
{platform: "linux", arch: "arm64", format: "tar.gz"},
{platform: "linux", arch: "arm64", format: "AppImage"},
{platform: "linux", arch: "arm64", format: "deb"},
{platform: "linux", arch: "arm64", format: "rpm"},
{platform: "macos", arch: "universal", format: "zip"},
{platform: "macos", arch: "universal", format: "dmg"},
{platform: "windows", arch: "amd64", format: "zip"},
}) {
return "", fmt.Errorf("不支持的 desktop 资产目标 %s/%s/%s", platform, arch, format)
}
return fmt.Sprintf("nex-desktop_%s_%s_%s.%s", version, platform, arch, format), nil
}
type releaseAssetTarget struct {
platform string
arch string
format string
}
func validCombination(platform, arch, format string, targets []releaseAssetTarget) bool {
for _, target := range targets {
if target.platform == platform && target.arch == arch && target.format == format {
return true
}
}
return false
}
func DesktopInfoPlist(version, minMacOSVersion string) (string, error) {

View File

@@ -83,20 +83,70 @@ func TestVerifyTag(t *testing.T) {
}
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)
testCases := []struct {
name string
component string
platform string
arch string
format string
want string
}{
{"server linux amd64", "server", "linux", "amd64", "tar.gz", "nex-server_1.2.3_linux_amd64.tar.gz"},
{"server linux arm64", "server", "linux", "arm64", "tar.gz", "nex-server_1.2.3_linux_arm64.tar.gz"},
{"server macos amd64", "server", "macos", "amd64", "tar.gz", "nex-server_1.2.3_macos_amd64.tar.gz"},
{"server macos arm64", "server", "macos", "arm64", "tar.gz", "nex-server_1.2.3_macos_arm64.tar.gz"},
{"server macos universal", "server", "macos", "universal", "tar.gz", "nex-server_1.2.3_macos_universal.tar.gz"},
{"server windows amd64", "server", "windows", "amd64", "zip", "nex-server_1.2.3_windows_amd64.zip"},
{"web", "web", "", "", "tar.gz", "nex-web_1.2.3.tar.gz"},
{"desktop linux amd64 tar", "desktop", "linux", "amd64", "tar.gz", "nex-desktop_1.2.3_linux_amd64.tar.gz"},
{"desktop linux amd64 appimage", "desktop", "linux", "amd64", "AppImage", "nex-desktop_1.2.3_linux_amd64.AppImage"},
{"desktop linux amd64 deb", "desktop", "linux", "amd64", "deb", "nex-desktop_1.2.3_linux_amd64.deb"},
{"desktop linux amd64 rpm", "desktop", "linux", "amd64", "rpm", "nex-desktop_1.2.3_linux_amd64.rpm"},
{"desktop linux arm64 tar", "desktop", "linux", "arm64", "tar.gz", "nex-desktop_1.2.3_linux_arm64.tar.gz"},
{"desktop linux arm64 appimage", "desktop", "linux", "arm64", "AppImage", "nex-desktop_1.2.3_linux_arm64.AppImage"},
{"desktop linux arm64 deb", "desktop", "linux", "arm64", "deb", "nex-desktop_1.2.3_linux_arm64.deb"},
{"desktop linux arm64 rpm", "desktop", "linux", "arm64", "rpm", "nex-desktop_1.2.3_linux_arm64.rpm"},
{"desktop macos zip", "desktop", "macos", "universal", "zip", "nex-desktop_1.2.3_macos_universal.zip"},
{"desktop macos dmg", "desktop", "macos", "universal", "dmg", "nex-desktop_1.2.3_macos_universal.dmg"},
{"desktop windows amd64", "desktop", "windows", "amd64", "zip", "nex-desktop_1.2.3_windows_amd64.zip"},
}
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)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := ReleaseAssetName("1.2.3", tc.component, tc.platform, tc.arch, tc.format)
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
macDesktop, err := DesktopAssetName("1.2.3", "macos")
require.NoError(t, err)
assert.Equal(t, "Nex_1.2.3_macOS_universal.zip", macDesktop)
invalidCases := []struct {
name string
component string
platform string
arch string
format string
}{
{"invalid version", "server", "linux", "amd64", "tar.gz"},
{"invalid component", "mobile", "linux", "amd64", "tar.gz"},
{"darwin platform", "server", "darwin", "arm64", "tar.gz"},
{"server unsupported format", "server", "linux", "amd64", "zip"},
{"server unsupported arch", "server", "windows", "universal", "zip"},
{"web with platform", "web", "linux", "amd64", "tar.gz"},
{"web unsupported format", "web", "", "", "zip"},
{"desktop unsupported platform", "desktop", "ios", "arm64", "zip"},
{"desktop unsupported format", "desktop", "macos", "universal", "tar.gz"},
}
_, err = DesktopAssetName("1.2.3", "ios")
assert.Error(t, err)
for _, tc := range invalidCases {
t.Run(tc.name, func(t *testing.T) {
version := "1.2.3"
if tc.name == "invalid version" {
version = "1.2"
}
_, err := ReleaseAssetName(version, tc.component, tc.platform, tc.arch, tc.format)
assert.Error(t, err)
})
}
}
func TestDesktopInfoPlist(t *testing.T) {