1
0

14 Commits

Author SHA1 Message Date
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
43 changed files with 2058 additions and 286 deletions

View File

@@ -136,7 +136,7 @@ jobs:
make release-assets-check make release-assets-check
- name: Build Linux release assets - name: Build Linux release assets
run: make release-assets-linux TARGET_ARCH=${{ matrix.arch }} run: make release-assets-linux
- name: Upload Linux release assets - name: Upload Linux release assets
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -148,22 +148,19 @@ jobs:
build-windows: build-windows:
name: Build Windows ${{ matrix.arch }} Assets name: Build Windows ${{ matrix.arch }} Assets
needs: prepare needs: prepare
runs-on: windows-latest runs-on: ${{ matrix.runner }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- arch: amd64 - arch: amd64
runner: windows-latest
msystem: MINGW64 msystem: MINGW64
cc: gcc
cxx: g++
packages: >- packages: >-
make make
mingw-w64-x86_64-gcc mingw-w64-x86_64-gcc
- arch: arm64
msystem: CLANGARM64
packages: >-
make
mingw-w64-clang-aarch64-clang
mingw-w64-clang-aarch64-llvm
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -193,6 +190,9 @@ jobs:
- name: Preflight Windows release toolchain - name: Preflight Windows release toolchain
shell: msys2 {0} shell: msys2 {0}
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: | run: |
set -euo pipefail set -euo pipefail
command -v go command -v go
@@ -201,21 +201,12 @@ jobs:
bun --version bun --version
command -v make command -v make
make --version make --version
if [ "${{ matrix.arch }}" = "arm64" ]; then command -v "$CC"
command -v clang "$CC" --version
clang --version command -v "$CXX"
if command -v llvm-windres >/dev/null 2>&1; then "$CXX" --version
llvm-windres --version command -v windres
else windres --version
command -v windres
windres --version
fi
else
command -v gcc
gcc --version
command -v windres
windres --version
fi
if command -v powershell.exe >/dev/null 2>&1; then if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' powershell.exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
else else
@@ -226,7 +217,10 @@ jobs:
- name: Build Windows release assets - name: Build Windows release assets
shell: msys2 {0} shell: msys2 {0}
run: make release-assets-windows TARGET_ARCH=${{ matrix.arch }} env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: make release-assets-windows
- name: Upload Windows release assets - name: Upload Windows release assets
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

149
Makefile
View File

@@ -1,5 +1,5 @@
.PHONY: \ .PHONY: \
lint test clean \ lint test clean hooks-install hooks-check hooks-test \
version-sync version-check version-bump \ version-sync version-check version-bump \
server-run server-build server-lint server-test server-clean \ server-run server-build server-lint server-test server-clean \
desktop-build-mac desktop-build-win desktop-build-linux \ desktop-build-mac desktop-build-win desktop-build-linux \
@@ -10,6 +10,7 @@
_backend-lint _backend-test _backend-clean _backend-build \ _backend-lint _backend-test _backend-clean _backend-build \
_versionctl-lint _versionctl-test \ _versionctl-lint _versionctl-test \
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \ _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 \ _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 \ _check-linux-target-arch _check-windows-target-arch _ensure-appimagetool \
@@ -35,16 +36,16 @@ ifeq ($(TARGET_ARCH),arm64)
APPIMAGE_ARCH := aarch64 APPIMAGE_ARCH := aarch64
DEB_ARCH := arm64 DEB_ARCH := arm64
RPM_ARCH := aarch64 RPM_ARCH := aarch64
WINDOWS_WINDRES_FORMAT := pe-aarch64
WINDOWS_RESOURCE := rsrc_windows_arm64.syso
else else
APPIMAGE_ARCH := x86_64 APPIMAGE_ARCH := x86_64
DEB_ARCH := amd64 DEB_ARCH := amd64
RPM_ARCH := x86_64 RPM_ARCH := x86_64
WINDOWS_WINDRES_FORMAT := pe-x86-64
WINDOWS_RESOURCE := rsrc_windows_amd64.syso
endif 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_PATH := build/tools/appimagetool-$(APPIMAGE_ARCH).AppImage
APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage
APPIMAGETOOL ?= $(APPIMAGETOOL_PATH) APPIMAGETOOL ?= $(APPIMAGETOOL_PATH)
@@ -62,6 +63,101 @@ test: _backend-test _frontend-test _desktop-test _versionctl-test
clean: _backend-clean _frontend-clean _desktop-clean clean: _backend-clean _frontend-clean _desktop-clean
@printf 'Clean complete\n' @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'
# ============================================ # ============================================
# 版本管理 # 版本管理
# ============================================ # ============================================
@@ -73,13 +169,21 @@ version-check:
go run ./versionctl check go run ./versionctl check
version-bump: BUMP ?= patch version-bump: BUMP ?= patch
version-bump: version-bump: lint test _check-clean-worktree
$(eval _BUMP_ARG := $(if $(SET_VERSION),$(SET_VERSION),$(BUMP))) @set -e; \
$(eval _NEW_VERSION := $(shell go run ./versionctl bump $(_BUMP_ARG))) bump_arg="$(if $(SET_VERSION),$(SET_VERSION),$(BUMP))"; \
git add VERSION frontend/ new_version=$$(go run ./versionctl bump "$$bump_arg"); \
git commit -m "chore: 版本升迁 v$(_NEW_VERSION)" git add VERSION frontend/; \
git tag "v$(_NEW_VERSION)" git commit -m "chore: 版本升迁 v$$new_version"; \
@printf '版本升迁完成: v%s\n' "$(_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 模式 # Server 模式
@@ -116,6 +220,7 @@ desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embe
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./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 -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' 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' @printf 'Packaging macOS app bundle...\n'
rm -rf build/Nex.app rm -rf build/Nex.app
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
@@ -160,7 +265,7 @@ _desktop-test:
cd backend && go test ./cmd/desktop/... -v cd backend && go test ./cmd/desktop/... -v
_desktop-clean: _desktop-clean:
rm -rf build/ embedfs/assets embedfs/frontend-dist backend/cmd/desktop/rsrc_windows_amd64.syso backend/cmd/desktop/rsrc_windows_arm64.syso rm -rf build/ embedfs/assets embedfs/frontend-dist backend/cmd/desktop/rsrc_windows_amd64.syso
_desktop-prepare-frontend: _frontend-install _desktop-prepare-frontend: _frontend-install
@printf 'Preparing frontend for desktop...\n' @printf 'Preparing frontend for desktop...\n'
@@ -176,13 +281,16 @@ _desktop-prepare-embedfs:
_desktop-prepare-windows-resource: _check-windows-target-arch _desktop-prepare-windows-resource: _check-windows-target-arch
@printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n' @printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n'
@if [ "$(TARGET_ARCH)" = "arm64" ] && [ "$(WINDRES)" = "windres" ] && command -v llvm-windres >/dev/null 2>&1; then \ @WINDRES_CMD="$(WINDRES)"; \
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_BFD)"; \
if command -v llvm-windres >/dev/null 2>&1; then \
WINDRES_CMD=llvm-windres; \ WINDRES_CMD=llvm-windres; \
else \ WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
WINDRES_CMD="$(WINDRES)"; \ elif "$$WINDRES_CMD" --version 2>&1 | grep -qi LLVM; then \
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
fi; \ fi; \
command -v "$$WINDRES_CMD" >/dev/null 2>&1 || { printf 'Missing windres tool: %s\n' "$$WINDRES_CMD"; exit 1; }; \ 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 $(WINDOWS_WINDRES_FORMAT) -i icon_windows.rc -o $(WINDOWS_RESOURCE) cd backend/cmd/desktop && "$$WINDRES_CMD" -O coff -F "$$WINDRES_FMT" -i icon_windows.rc -o $(WINDOWS_RESOURCE)
# ============================================ # ============================================
# 发布资产 # 发布资产
@@ -241,6 +349,7 @@ release-assets-server-macos: version-check
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-arm64 tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-arm64
asset=$$(go run ./versionctl asset-name server macos universal tar.gz); \ asset=$$(go run ./versionctl asset-name server macos universal tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-universal 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-linux: version-check release-assets-check desktop-build-linux _package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm
@@ -251,6 +360,7 @@ release-assets-desktop-windows: version-check release-assets-check desktop-build
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_DESKTOP_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force" "$$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 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: release-assets-checksums:
@cd "$(RELEASE_DIR)" && \ @cd "$(RELEASE_DIR)" && \
@@ -275,7 +385,7 @@ _check-linux-target-arch:
fi fi
_check-windows-target-arch: _check-windows-target-arch:
@if [ "$(TARGET_ARCH)" != "amd64" ] && [ "$(TARGET_ARCH)" != "arm64" ]; then \ @if [ "$(TARGET_ARCH)" != "amd64" ]; then \
printf 'Unsupported Windows TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \ printf 'Unsupported Windows TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
exit 1; \ exit 1; \
fi fi
@@ -366,7 +476,8 @@ _package-macos-dmg:
ln -s /Applications "$$dmgdir/Applications"; \ ln -s /Applications "$$dmgdir/Applications"; \
asset=$$(go run ./versionctl asset-name desktop macos universal dmg); \ asset=$$(go run ./versionctl asset-name desktop macos universal dmg); \
hdiutil create -volname Nex -srcfolder "$$dmgdir" -ov -format UDZO "$(RELEASE_DIR)/$$asset"; \ hdiutil create -volname Nex -srcfolder "$$dmgdir" -ov -format UDZO "$(RELEASE_DIR)/$$asset"; \
hdiutil verify "$(RELEASE_DIR)/$$asset" >/dev/null hdiutil verify "$(RELEASE_DIR)/$$asset" >/dev/null && \
rm -rf "$$dmgdir"
# ============================================ # ============================================
# 共享 helper targets # 共享 helper targets

View File

@@ -66,7 +66,7 @@ nex/
- **ORM**: GORM - **ORM**: GORM
- **数据库**: SQLite / MySQL - **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack结构化日志 + 日志轮转 + 模块标识) - **日志**: zap + lumberjack结构化日志 + 日志轮转 + 模块标识)
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值 - **配置**: Viper + pflagServer 多层配置Desktop 配置文件快照
- **验证**: go-playground/validator/v10 - **验证**: go-playground/validator/v10
- **迁移**: goose - **迁移**: goose
@@ -109,9 +109,6 @@ make desktop-build-mac
# Windows # Windows
make desktop-build-win make desktop-build-win
# Windows arm64
make desktop-build-win TARGET_ARCH=arm64
# Linux # Linux
make desktop-build-linux make desktop-build-linux
@@ -120,7 +117,7 @@ make desktop-build-linux TARGET_ARCH=arm64
``` ```
**使用桌面应用** **使用桌面应用**
- 双击启动应用macOS: Nex.appWindows: nex-win-amd64.exe / nex-win-arm64.exeLinux: nex-linux-amd64 / nex-linux-arm64 - 双击启动应用macOS: Nex.appWindows: nex-win-amd64.exeLinux: nex-linux-amd64 / nex-linux-arm64
- 系统托盘图标出现,浏览器自动打开管理界面 - 系统托盘图标出现,浏览器自动打开管理界面
- 点击托盘图标显示菜单,可打开管理界面或退出 - 点击托盘图标显示菜单,可打开管理界面或退出
- 关闭浏览器后服务继续运行,可通过托盘重新打开 - 关闭浏览器后服务继续运行,可通过托盘重新打开
@@ -150,7 +147,6 @@ make server-run
- 前端开发服务器:`http://localhost:5173` - 前端开发服务器:`http://localhost:5173`
前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动: 前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动:
- 创建配置文件 `~/.nex/config.yaml`
- 初始化数据库 `~/.nex/config.db` - 初始化数据库 `~/.nex/config.db`
- 运行数据库迁移 - 运行数据库迁移
- 创建日志目录 `~/.nex/log/` - 创建日志目录 `~/.nex/log/`
@@ -175,7 +171,6 @@ make server-build
| macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` | | macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` |
| macOS universal | `nex-server_<version>_macos_universal.tar.gz` | | macOS universal | `nex-server_<version>_macos_universal.tar.gz` |
| Windows amd64 | `nex-server_<version>_windows_amd64.zip` | | Windows amd64 | `nex-server_<version>_windows_amd64.zip` |
| Windows arm64 | `nex-server_<version>_windows_arm64.zip` |
**web 产物** **web 产物**
@@ -191,10 +186,11 @@ make server-build
| Linux arm64 | `nex-desktop_<version>_linux_arm64.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` | | macOS universal | `nex-desktop_<version>_macos_universal.zip``nex-desktop_<version>_macos_universal.dmg` |
| Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` | | Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` |
| Windows arm64 | `nex-desktop_<version>_windows_arm64.zip` |
Linux deb 包声明 `libgtk-3-0``libayatana-appindicator3-1``xdg-utils` 运行依赖rpm 包声明 `gtk3``libayatana-appindicator-gtk3``xdg-utils` 运行依赖。Rocky Linux 9 等发行版可能需要启用 EPEL 才能解析 Ayatana AppIndicator 依赖。 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 接口 ## API 接口
### 代理接口(对外部应用) ### 代理接口(对外部应用)
@@ -248,11 +244,14 @@ Linux deb 包声明 `libgtk-3-0`、`libayatana-appindicator3-1`、`xdg-utils`
## 配置 ## 配置
配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值** 配置方式取决于启动模式:
- **Server 模式**`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
- **Desktop 模式**`cmd/desktop`):仅支持配置文件 `~/.nex/config.yaml` > 默认值,修改配置文件后需重启 desktop 生效
### 配置文件 ### 配置文件
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成 配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件
```yaml ```yaml
server: server:
@@ -282,9 +281,9 @@ log:
compress: true compress: true
``` ```
### 环境变量 ### 环境变量(仅 Server 模式)
所有配置项支持环境变量,使用 `NEX_` 前缀: Server 模式下,所有配置项支持环境变量,使用 `NEX_` 前缀:
```bash ```bash
export NEX_SERVER_PORT=9000 export NEX_SERVER_PORT=9000
@@ -302,7 +301,11 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转大写 + 下划线(如 `server.port``NEX_SERVER_PORT`)。 命名规则:配置路径转大写 + 下划线(如 `server.port``NEX_SERVER_PORT`)。
### CLI 参数 **Desktop 模式不支持环境变量覆盖。**Desktop 仅从 `~/.nex/config.yaml` 和默认值读取配置。
### CLI 参数(仅 Server 模式)
Server 模式下,支持命令行参数:
```bash ```bash
./server --server-port 9000 --log-level debug --database-path /tmp/test.db ./server --server-port 9000 --log-level debug --database-path /tmp/test.db
@@ -310,6 +313,8 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转 kebab-case`server.port``--server-port`)。 命名规则:配置路径转 kebab-case`server.port``--server-port`)。
**Desktop 不支持命令行参数覆盖配置。**Desktop 忽略所有 CLI 参数,仅从 `~/.nex/config.yaml` 读取。
### 数据文件 ### 数据文件
- `~/.nex/config.yaml` - 配置文件 - `~/.nex/config.yaml` - 配置文件
@@ -333,7 +338,13 @@ backend 分类测试、MySQL 专项测试和前端 E2E 测试请分别查看 `ba
```bash ```bash
# 首次克隆后安装 Git hooks # 首次克隆后安装 Git hooks
lefthook install make hooks-install
# 检查 Git hooks 安装状态
make hooks-check
# 运行 Git hooks 回归测试
make hooks-test
# 全局命令 # 全局命令
make lint # 前后端共享检查 make lint # 前后端共享检查
@@ -356,6 +367,12 @@ make desktop-test # desktop 专属测试
make desktop-clean # 清理 desktop 产物 make desktop-clean # 清理 desktop 产物
``` ```
Git hooks 使用仓库内 `scripts/git-hooks/` 的原生脚本,不依赖额外 hook 框架。当前 hooks 包含:
- pre-commit检查 staged files 的冲突标记、大文件告警和 LFS 指针并按文件类型委托后端、versionctl、前端检查
- prepare-commit-msg在编辑器打开时提供提交信息模板辅助填写 `类型: 简短描述` 和多行说明
- commit-msg校验提交信息格式为 `类型: 简短描述`多行说明需在首行后保留空行提交描述按项目规范使用中文hook 不做字符集检测
## 版本与发布 ## 版本与发布
### 统一版本源 ### 统一版本源
@@ -366,7 +383,7 @@ make desktop-clean # 清理 desktop 产物
### 本地版本演进 ### 本地版本演进
```bash ```bash
# 递增版本(自动 sync + check + commit + tag # 递增版本(自动 lint + test + 工作区检查 + sync/check + commit + tag
make version-bump BUMP=minor make version-bump BUMP=minor
# 或指定具体版本号 # 或指定具体版本号

View File

@@ -1 +1 @@
0.1.3 0.1.7

View File

@@ -72,7 +72,7 @@ GORM 日志自动桥接到 zapSQL 查询映射到 Debug 级别。
- **ORM**: GORM - **ORM**: GORM
- **数据库**: SQLite / MySQL - **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack - **日志**: zap + lumberjack
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值 - **配置**: Viper + pflagServer 多层配置Desktop 配置文件快照
- **验证**: go-playground/validator/v10 - **验证**: go-playground/validator/v10
- **迁移**: goose - **迁移**: goose
@@ -164,7 +164,11 @@ backend/
│ └── validator/ # 验证器 │ └── validator/ # 验证器
│ └── validator.go │ └── validator.go
├── migrations/ # 数据库迁移 ├── migrations/ # 数据库迁移
── 20260421000001_initial_schema.sql ── embed.go # go:embed 迁移资源入口
│ ├── sqlite/
│ │ └── 20260421000001_initial_schema.sql
│ └── mysql/
│ └── 20260421000001_initial_schema.sql
├── tests/ # 集成测试 ├── tests/ # 集成测试
│ ├── helpers.go # 测试辅助函数 │ ├── helpers.go # 测试辅助函数
│ ├── config/ # 测试配置 │ ├── config/ # 测试配置
@@ -330,15 +334,18 @@ go mod download
go run cmd/server/main.go 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 ```yaml
server: server:
@@ -368,9 +375,9 @@ log:
compress: true compress: true
``` ```
### 环境变量 ### 环境变量(仅 Server 入口)
所有配置项都支持环境变量,使用 `NEX_` 前缀: Server 入口下,所有配置项都支持环境变量,使用 `NEX_` 前缀:
```bash ```bash
export NEX_SERVER_PORT=9000 export NEX_SERVER_PORT=9000
@@ -388,7 +395,7 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port``NEX_SERVER_PORT`)。 命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port``NEX_SERVER_PORT`)。
### 命令行参数 ### 命令行参数(仅 Server 入口)
```bash ```bash
./server --server-port 9000 --log-level debug --database-path /tmp/test.db ./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 ```bash
# 使用 Makefile # 使用 Makefile
make migrate-up DB_DSN=~/.nex/config.db make migrate-up DB_DSN=~/.nex/config.db

View File

@@ -43,10 +43,23 @@ var (
) )
func main() { func main() {
port := 9826
minimalLogger := pkgLogger.NewMinimal() minimalLogger := pkgLogger.NewMinimal()
cfg, err := config.LoadDesktopConfig()
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")) singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
if err := singleLock.Lock(); err != nil { if err := singleLock.Lock(); err != nil {
minimalLogger.Error("已有 Nex 实例运行") 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{ zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
Level: cfg.Log.Level, Level: cfg.Log.Level,
Path: cfg.Log.Path, Path: cfg.Log.Path,
@@ -144,7 +146,7 @@ func main() {
setupStaticFiles(r) setupStaticFiles(r)
server = &http.Server{ server = &http.Server{
Addr: fmt.Sprintf(":%d", port), Addr: desktopListenAddr(port),
Handler: r, Handler: r,
ReadTimeout: cfg.Server.ReadTimeout, ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout, WriteTimeout: cfg.Server.WriteTimeout,
@@ -165,7 +167,7 @@ func main() {
go func() { go func() {
time.Sleep(500 * time.Millisecond) 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)) zapLogger.Warn("无法打开浏览器", zap.Error(err))
} }
}() }()
@@ -309,7 +311,7 @@ func setupSystray(port int) {
systray.AddSeparator() systray.AddSeparator()
mStatus := systray.AddMenuItem("状态: 运行中", "") mStatus := systray.AddMenuItem("状态: 运行中", "")
mStatus.Disable() mStatus.Disable()
mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "") mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "")
mPort.Disable() mPort.Disable()
systray.AddSeparator() systray.AddSeparator()
mQuit := systray.AddMenuItem("退出", "停止服务并退出") mQuit := systray.AddMenuItem("退出", "停止服务并退出")
@@ -318,7 +320,7 @@ func setupSystray(port int) {
for { for {
select { select {
case <-mOpen.ClickedCh: 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)) zapLogger.Warn("打开浏览器失败", zap.Error(err))
} }
case <-mQuit.ClickedCh: case <-mQuit.ClickedCh:
@@ -349,6 +351,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 { func checkPortAvailable(port int) error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil { 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" "errors"
"net" "net"
"net/http" "net/http"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -67,3 +68,62 @@ func TestCheckPortAvailableAfterClose(t *testing.T) {
t.Log("端口关闭后可用测试通过") 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

@@ -29,7 +29,7 @@ import (
func main() { func main() {
minimalLogger := pkgLogger.NewMinimal() minimalLogger := pkgLogger.NewMinimal()
cfg, err := config.LoadConfig() cfg, err := config.LoadServerConfig()
if err != nil { if err != nil {
minimalLogger.Fatal("加载配置失败", zap.Error(err)) minimalLogger.Fatal("加载配置失败", zap.Error(err))
} }

View File

@@ -1,7 +1,6 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -225,68 +224,71 @@ func setupConfigFile(v *viper.Viper, configPath string) error {
v.SetConfigFile(configPath) v.SetConfigFile(configPath)
v.SetConfigType("yaml") v.SetConfigType("yaml")
// 尝试读取配置文件,如果不存在则忽略
if err := v.ReadInConfig(); err != nil { if err := v.ReadInConfig(); err != nil {
if !os.IsNotExist(err) { if os.IsNotExist(err) {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
// 配置文件不存在,创建默认配置文件
writeErr := v.SafeWriteConfigAs(configPath)
if writeErr == nil {
return nil return nil
} }
return appErrors.Wrap(appErrors.ErrInternal, err)
var alreadyExistsErr viper.ConfigFileAlreadyExistsError
if errors.As(writeErr, &alreadyExistsErr) {
return nil
}
return appErrors.Wrap(appErrors.ErrInternal, writeErr)
} }
return nil return nil
} }
// LoadConfig loads config from YAML file, creates default if not exists // loadOptions 控制配置加载器行为
func LoadConfig() (*Config, error) { type loadOptions struct {
configPath, err := GetConfigPath() configPathOverride string
if err != nil { useCLI bool
return nil, appErrors.Wrap(appErrors.ErrInternal, err) useEnv bool
} useConfigFlag bool
return LoadConfigFromPath(configPath)
} }
// LoadConfigFromPath 从指定路径加载配置 // resolveConfigPath 根据 loadOptions 解析 CLI 参数并返回最终配置文件路径
func LoadConfigFromPath(configPath string) (*Config, error) { func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
// 1. 创建 Viper 实例 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
}
// loadConfig 共享配置加载逻辑,通过 loadOptions 控制是否启用 CLI、环境变量和 --config 覆盖
func loadConfig(opts loadOptions) (*Config, error) {
v := viper.New() 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) setupDefaults(v)
// 6. 绑定环境变量 configPath, err := resolveConfigPath(v, opts)
setupEnv(v) if err != nil {
return nil, err
}
if opts.useEnv {
setupEnv(v)
}
// 7. 读取配置文件
if err := setupConfigFile(v, configPath); err != nil { if err := setupConfigFile(v, configPath); err != nil {
return nil, err return nil, err
} }
// 8. 反序列化到结构体
cfg := &Config{} cfg := &Config{}
if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeDurationHookFunc(),
@@ -295,7 +297,6 @@ func LoadConfigFromPath(configPath string) (*Config, error) {
return nil, appErrors.Wrap(appErrors.ErrInternal, err) return nil, appErrors.Wrap(appErrors.ErrInternal, err)
} }
// 9. 验证配置
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, err return nil, err
} }
@@ -303,6 +304,61 @@ func LoadConfigFromPath(configPath string) (*Config, error) {
return cfg, nil return cfg, nil
} }
// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config
func LoadServerConfig() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
}
return loadConfig(loadOptions{
configPathOverride: configPath,
useCLI: true,
useEnv: true,
useConfigFlag: true,
})
}
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
func LoadDesktopConfig() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
}
return loadConfig(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 // SaveConfig saves config to YAML file
func SaveConfig(cfg *Config) error { func SaveConfig(cfg *Config) error {
configPath, err := GetConfigPath() configPath, err := GetConfigPath()

View File

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

View File

@@ -1,10 +1,13 @@
package database package database
import ( import (
"io/fs"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"nex/backend/internal/config" "nex/backend/internal/config"
"nex/backend/migrations"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -76,3 +79,87 @@ func TestBuildDSN_EmptyPassword(t *testing.T) {
dsn := BuildDSN(cfg) dsn := BuildDSN(cfg)
assert.Equal(t, "root:@tcp(localhost:3306)/nex?charset=utf8mb4&parseTime=true&loc=Local", dsn) 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,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") 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() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml") configPath := filepath.Join(tmpDir, "config.yaml")
@@ -132,6 +132,9 @@ func TestLoadConfig_AutoCreate(t *testing.T) {
require.NotNil(t, cfg) require.NotNil(t, cfg)
assert.Equal(t, 9826, cfg.Server.Port, "should load with default values") 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) { 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.MaxAge, loaded.Log.MaxAge)
assert.Equal(t, cfg.Log.Compress, loaded.Log.Compress) 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_API_BASE=
VITE_APP_VERSION=0.1.3 VITE_APP_VERSION=0.1.7

View File

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

View File

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

View File

@@ -149,7 +149,8 @@ bun run build
```bash ```bash
bun run lint # ESLint 检查 bun run lint # ESLint 检查
bun run format:check # Prettier 格式检查 bun run format:check # Prettier 格式检查
bun run check # 同时检查 lint 和格式 bun run typecheck # TypeScript 类型检查
bun run check # 同时检查类型、lint 和格式
``` ```
### 代码格式化 ### 代码格式化
@@ -188,13 +189,14 @@ bun run test:e2e
- API Key 脱敏显示 - API Key 脱敏显示
- 启用/禁用状态标签 - 启用/禁用状态标签
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择 - **协议字段**:支持 OpenAI 和 Anthropic 协议选择
- **一键复制**Base URL 和 API Key 支持一键复制到剪贴板
### 模型管理 ### 模型管理
- 展开供应商行查看关联模型 - 展开供应商行查看关联模型
- 添加/编辑/删除模型 - 添加/编辑/删除模型
- 按供应商筛选模型 - 按供应商筛选模型
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别 - **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别,支持一键复制
- **UUID 自动生成**:创建模型时后端自动生成 UUID无需手动输入 ID - **UUID 自动生成**:创建模型时后端自动生成 UUID无需手动输入 ID
### 用量统计 ### 用量统计

View File

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

View File

@@ -4,6 +4,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ModelTable } from '@/pages/Providers/ModelTable' import { ModelTable } from '@/pages/Providers/ModelTable'
import type { Model } from '@/types' 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[] = [ const mockModels: Model[] = [
{ {
id: 'model-1', id: 'model-1',
@@ -44,6 +59,7 @@ const defaultProps = {
describe('ModelTable', () => { describe('ModelTable', () => {
beforeEach(() => { beforeEach(() => {
mockMutate.mockClear() mockMutate.mockClear()
mockMessagePluginSuccess.mockClear()
}) })
it('renders model list with unified ID and model name', () => { it('renders model list with unified ID and model name', () => {
@@ -120,4 +136,19 @@ describe('ModelTable', () => {
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument() 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 { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' 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 { ProviderTable } from '@/pages/Providers/ProviderTable'
import type { Provider } from '@/types' 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 = [ const mockModelsData = [
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' }, { id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
{ {
@@ -54,6 +69,9 @@ const defaultProps = {
} }
describe('ProviderTable', () => { describe('ProviderTable', () => {
beforeEach(() => {
mockMessagePluginSuccess.mockClear()
})
it('renders provider list with name, baseUrl, apiKey, and status tags', () => { it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
render(<ProviderTable {...defaultProps} />) render(<ProviderTable {...defaultProps} />)
@@ -203,4 +221,66 @@ describe('ProviderTable', () => {
const protocolCell = container.querySelector('[data-colkey="protocol"]') const protocolCell = container.querySelector('[data-colkey="protocol"]')
expect(protocolCell).toBeInTheDocument() 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

@@ -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 { useModels, useDeleteModel } from '@/hooks/useModels'
import type { Model } from '@/types' import type { Model } from '@/types'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type' import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
@@ -18,8 +18,32 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
title: '统一模型 ID', title: '统一模型 ID',
colKey: 'unifiedId', colKey: 'unifiedId',
width: 250, width: 250,
ellipsis: true, cell: ({ row }) => {
cell: ({ row }) => row.unifiedId || `${row.providerId}/${row.modelName}`, 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: '模型名称', 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 type { Provider, Model } from '@/types'
import { ModelTable } from './ModelTable' import { ModelTable } from './ModelTable'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type' import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
@@ -32,7 +32,30 @@ export function ProviderTable({
{ {
title: 'Base URL', title: 'Base URL',
colKey: 'baseUrl', 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: '协议', title: '协议',
@@ -47,7 +70,30 @@ export function ProviderTable({
{ {
title: 'API Key', title: 'API Key',
colKey: 'apiKey', 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: '状态', title: '状态',

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

@@ -8,20 +8,27 @@
### Requirement: 使用 YAML 配置文件 ### Requirement: 使用 YAML 配置文件
系统 SHALL 使用 YAML 格式的配置文件。 系统 SHALL 使用 YAML 格式的配置文件,并按入口区分配置文件路径选择能力
#### Scenario: 配置文件路径 #### Scenario: Server 默认配置文件路径
- **WHEN** 应用启动且未指定 `--config` 参数 - **WHEN** server 应用启动且未指定 `--config` 参数
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置 - **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
- **THEN** SHALL 解析 YAML 格式 - **THEN** SHALL 解析 YAML 格式
#### Scenario: 自定义配置文件路径 #### Scenario: Server 自定义配置文件路径
- **WHEN** 应用启动且指定 `--config /path/to/custom.yaml` - **WHEN** server 应用启动且指定 `--config /path/to/custom.yaml`
- **THEN** SHALL 从指定路径加载配置文件 - **THEN** SHALL 从指定路径加载配置文件
- **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml` - **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml`
#### Scenario: Desktop 固定配置文件路径
- **WHEN** desktop 应用启动
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
- **THEN** SHALL 解析 YAML 格式
- **THEN** SHALL NOT 支持通过 `--config` 指定其他配置文件路径
#### Scenario: 配置文件结构 #### Scenario: 配置文件结构
- **WHEN** 加载配置文件 - **WHEN** 加载配置文件
@@ -30,14 +37,14 @@
### Requirement: 自动生成默认配置 ### Requirement: 自动生成默认配置
系统 SHALL 在首次使用时自动生成默认配置 系统 SHALL 在配置文件不存在时使用默认配置值,不自动创建配置文件
#### Scenario: 配置文件不存在 #### Scenario: 配置文件不存在
- **WHEN** 应用启动且配置文件不存在 - **WHEN** 应用启动且配置文件不存在
- **THEN** SHALL 自动创建配置文件 - **THEN** SHALL 使用默认配置值
- **THEN** SHALL 写入默认配置值 - **THEN** SHALL NOT 自动创建配置文件
- **THEN** SHALL 记录日志提示已创建 - **THEN** SHALL NOT 写入默认配置值到磁盘
#### Scenario: 配置文件已存在 #### Scenario: 配置文件已存在
@@ -163,22 +170,36 @@
### Requirement: 配置加载流程 ### Requirement: 配置加载流程
系统 SHALL 实现标准化的配置加载流程。 系统 SHALL 为 server 和 desktop 实现标准化且入口隔离的配置加载流程。
#### Scenario: 加载步骤 #### Scenario: Server 加载步骤
- **WHEN** 应用启动 - **WHEN** server 应用启动
- **THEN** SHALL 按以下顺序加载配置: - **THEN** SHALL 按以下顺序加载配置:
1. 解析 CLI 参数(获取 --config 路径) 1. 解析 CLI 参数(获取 --config 路径)
2. 初始化配置管理器 2. 初始化配置管理器
3. 设置默认值 3. 设置默认值
4. 绑定 CLI 参数 4. 绑定 CLI 参数
5. 绑定环境变量 5. 绑定环境变量
6. 读取配置文件(不存在时自动创建 6. 读取配置文件(不存在时使用默认值
7. 反序列化到结构体 7. 反序列化到结构体
8. 验证配置 8. 验证配置
9. 打印配置摘要 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: 加载失败处理 #### Scenario: 加载失败处理
- **WHEN** 配置加载过程中发生错误 - **WHEN** 配置加载过程中发生错误
@@ -188,25 +209,25 @@
### Requirement: 配置优先级管理 ### Requirement: 配置优先级管理
系统 SHALL 实现明确的配置优先级机制。 系统 SHALL 为不同入口实现明确的配置优先级机制。
#### Scenario: 优先级顺序 #### Scenario: Server 优先级顺序
- **WHEN** 同一配置项在多个配置源中设置 - **WHEN** 同一配置项在多个 server 配置源中设置
- **THEN** SHALL 按以下优先级顺序(从高到低): - **THEN** SHALL 按以下优先级顺序(从高到低):
1. CLI 参数 1. CLI 参数
2. 环境变量 2. 环境变量
3. 配置文件 3. 配置文件
4. 默认值 4. 默认值
#### Scenario: CLI 参数最高优先级 #### Scenario: Server CLI 参数最高优先级
- **WHEN** 配置文件设置 `server.port: 9826` - **WHEN** 配置文件设置 `server.port: 9826`
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000` - **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
- **AND** CLI 参数设置 `--server-port 8080` - **AND** server CLI 参数设置 `--server-port 8080`
- **THEN** SHALL 使用 CLI 参数值 8080 - **THEN** SHALL 使用 CLI 参数值 8080
#### Scenario: 环境变量次高优先级 #### Scenario: Server 环境变量次高优先级
- **WHEN** 配置文件设置 `server.port: 9826` - **WHEN** 配置文件设置 `server.port: 9826`
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000` - **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
@@ -227,21 +248,35 @@
- **AND** 未设置 CLI 参数 - **AND** 未设置 CLI 参数
- **THEN** SHALL 使用默认值 - **THEN** SHALL 使用默认值
#### Scenario: 部分配置覆盖 #### Scenario: Server 部分配置覆盖
- **WHEN** 配置文件设置完整配置 - **WHEN** 配置文件设置完整配置
- **AND** CLI 参数仅覆盖部分配置项 - **AND** server CLI 参数仅覆盖部分配置项
- **THEN** SHALL 合并所有配置源 - **THEN** SHALL 合并所有配置源
- **THEN** SHALL 使用高优先级源覆盖指定项 - **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 仅覆盖 server.port 配置项
- **THEN** SHALL NOT 影响其他配置项 - **THEN** SHALL NOT 影响其他配置项
- **THEN** SHALL 其他配置项使用配置文件或默认值 - **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: 启动后配置锁定 #### Scenario: 启动后配置锁定
- **WHEN** 应用启动完成 - **WHEN** 应用启动完成
@@ -314,67 +349,79 @@
### Requirement: CLI 参数配置支持 ### Requirement: CLI 参数配置支持
系统 SHALL 支持通过命令行参数设置所有配置项。 server 入口 SHALL 支持通过命令行参数设置所有配置项desktop 入口 SHALL NOT 将命令行参数作为配置源
#### Scenario: 基本参数解析 #### Scenario: Server 基本参数解析
- **WHEN** 应用启动时传入命令行参数 - **WHEN** server 应用启动时传入命令行参数
- **THEN** SHALL 解析所有 CLI 参数 - **THEN** SHALL 解析所有 CLI 参数
- **THEN** SHALL 将参数值应用到对应配置项 - **THEN** SHALL 将参数值应用到对应配置项
#### Scenario: 参数命名规范 #### Scenario: 参数命名规范
- **WHEN** 使用命令行参数 - **WHEN** server 使用命令行参数
- **THEN** SHALL 使用 kebab-case 命名(如 `--server-port` - **THEN** SHALL 使用 kebab-case 命名(如 `--server-port`
- **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns` - **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns`
- **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns``--database-max-idle-conns` - **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns``--database-max-idle-conns`
#### Scenario: 参数类型支持 #### Scenario: 参数类型支持
- **WHEN** 解析不同类型的参数 - **WHEN** server 解析不同类型的参数
- **THEN** SHALL 支持 int 类型(如 `--server-port 9000` - **THEN** SHALL 支持 int 类型(如 `--server-port 9000`
- **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db` - **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db`
- **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s` - **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s`
- **THEN** SHALL 支持 bool 类型(如 `--log-compress` - **THEN** SHALL 支持 bool 类型(如 `--log-compress`
#### Scenario: 完整配置覆盖 #### Scenario: Server 完整配置覆盖
- **WHEN** 使用服务器相关参数 - **WHEN** server 使用服务器相关参数
- **THEN** SHALL 支持 `--server-port``--server-read-timeout``--server-write-timeout` - **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` - **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` - **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 按功能分组展示参数(服务器、数据库、日志) - **THEN** SHALL 按功能分组展示参数(服务器、数据库、日志)
- **THEN** SHALL 显示每个参数的默认值和说明 - **THEN** SHALL 显示每个参数的默认值和说明
#### Scenario: 参数错误处理 #### Scenario: Server 参数错误处理
- **WHEN** 传入无效的参数值(如 `--server-port abc` - **WHEN** server 传入无效的参数值(如 `--server-port abc`
- **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型 - **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型
- **THEN** SHALL NOT 启动应用 - **THEN** SHALL NOT 启动应用
- **WHEN** 传入未定义的参数(如 `--unknown-param value` - **WHEN** server 传入未定义的参数(如 `--unknown-param value`
- **THEN** SHALL 返回错误信息,指示未知参数名称 - **THEN** SHALL 返回错误信息,指示未知参数名称
- **THEN** SHALL NOT 启动应用 - **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: 环境变量配置支持 ### 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 自动读取所有 `NEX_` 前缀的环境变量
- **THEN** SHALL 将环境变量值应用到对应配置项 - **THEN** SHALL 将环境变量值应用到对应配置项
#### Scenario: 环境变量命名规范 #### Scenario: 环境变量命名规范
- **WHEN** 使用环境变量配置 - **WHEN** server 使用环境变量配置
- **THEN** SHALL 使用 `NEX_` 前缀 - **THEN** SHALL 使用 `NEX_` 前缀
- **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT` - **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT`
- **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS` - **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS`
@@ -382,35 +429,41 @@
#### Scenario: 环境变量类型转换 #### Scenario: 环境变量类型转换
- **WHEN** 解析不同类型的环境变量 - **WHEN** server 解析不同类型的环境变量
- **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000` - **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000`
- **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db` - **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db`
- **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s` - **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s`
- **THEN** SHALL 支持 bool 类型(如 `NEX_LOG_COMPRESS=true` - **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` - **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` - **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` - **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 通过环境变量区分环境配置
- **THEN** SHALL NOT 修改代码或配置文件 - **THEN** SHALL NOT 修改代码或配置文件
- **WHEN** 配置包含敏感信息(如密钥、密码) - **WHEN** server 配置包含敏感信息(如密钥、密码)
- **THEN** SHALL 通过环境变量传递 - **THEN** SHALL 通过环境变量传递
- **THEN** SHALL NOT 存储在配置文件中 - **THEN** SHALL NOT 存储在配置文件中
#### Scenario: 环境变量错误处理 #### Scenario: Server 环境变量错误处理
- **WHEN** 环境变量值格式无效(如 `NEX_SERVER_PORT=abc` - **WHEN** server 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`
- **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型 - **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型
- **THEN** SHALL NOT 启动应用 - **THEN** SHALL NOT 启动应用
- **WHEN** 必需配置项既无配置文件也无环境变量 - **WHEN** server 必需配置项既无配置文件也无环境变量
- **THEN** SHALL 使用默认值 - **THEN** SHALL 使用默认值
- **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` 和默认值加载配置

View File

@@ -104,14 +104,14 @@
### Requirement: 应用启动时迁移 ### Requirement: 应用启动时迁移
应用 SHALL 在启动时执行迁移。 应用 SHALL 在启动时执行迁移,并 SHALL 使用随应用构建产物可用的打包迁移资源
#### Scenario: 自动迁移 #### Scenario: 自动迁移
- **WHEN** 应用启动 - **WHEN** 应用启动
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移目录和 goose dialect - **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移资源和 goose dialect
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录goose dialect 为 `sqlite3` - **THEN** SHALL 在 `driver=sqlite` 时使用 SQLite 方言迁移资源goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录goose dialect 为 `mysql` - **THEN** SHALL 在 `driver=mysql` 时使用 MySQL 方言迁移资源goose dialect 为 `mysql`
- **THEN** SHALL 自动执行待执行的迁移 - **THEN** SHALL 自动执行待执行的迁移
- **THEN** SHALL 在迁移失败时拒绝启动 - **THEN** SHALL 在迁移失败时拒绝启动
- **THEN** SHALL 记录迁移日志 - **THEN** SHALL 记录迁移日志
@@ -122,6 +122,15 @@
- **THEN** SHALL 检查数据库迁移版本 - **THEN** SHALL 检查数据库迁移版本
- **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: 连接池配置 ### Requirement: 连接池配置
系统 SHALL 配置数据库连接池。 系统 SHALL 配置数据库连接池。
@@ -157,7 +166,7 @@
### Requirement: 迁移文件管理 ### Requirement: 迁移文件管理
迁移文件 SHALL 版本化管理。 迁移文件 SHALL 版本化管理,并 SHALL 在构建发布产物时作为运行时迁移资源打包
#### Scenario: 迁移文件命名 #### Scenario: 迁移文件命名
@@ -171,3 +180,10 @@
- **WHEN** 创建迁移文件 - **WHEN** 创建迁移文件
- **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/``migrations/mysql/` - **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/``migrations/mysql/`
- **THEN** SHALL 提交到版本控制系统 - **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: 桌面应用启动 ### Requirement: 桌面应用启动
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。 系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口
#### Scenario: 双击启动 #### Scenario: 双击启动
- **WHEN** 用户双击桌面应用可执行文件 - **WHEN** 用户双击桌面应用可执行文件
- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁 - **THEN** 系统 `~/.nex/config.yaml` 和默认值加载启动配置快照
- **AND** 系统使用 `gofrs/flock` 尝试获取排他文件锁
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock` - **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
- **AND** 系统启动后端服务 - **AND** 系统使用启动配置中的 `server.port` 启动后端服务
- **AND** 未配置 `server.port` 时默认端口为 9826
- **AND** 系统托盘图标出现 - **AND** 系统托盘图标出现
- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面 - **AND** 浏览器自动打开 `http://localhost:<server.port>` 显示管理界面
#### Scenario: 单实例检查 #### Scenario: 单实例检查
@@ -34,7 +36,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 系统托盘 ### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。 系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`
#### Scenario: 托盘图标显示 #### Scenario: 托盘图标显示
@@ -50,19 +52,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **THEN** 显示托盘菜单 - **THEN** 显示托盘菜单
- **AND** 菜单包含"打开管理界面"选项 - **AND** 菜单包含"打开管理界面"选项
- **AND** 菜单包含"状态: 运行中"选项(禁用状态) - **AND** 菜单包含"状态: 运行中"选项(禁用状态)
- **AND** 菜单包含"端口: 9826"选项(禁用状态) - **AND** 菜单包含"端口: <server.port>"选项(禁用状态)
- **AND** 菜单包含"退出"选项 - **AND** 菜单包含"退出"选项
#### Scenario: 打开管理界面 #### Scenario: 打开管理界面
- **WHEN** 用户点击托盘菜单"打开管理界面" - **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:9826` - **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
#### Scenario: 浏览器打开失败 #### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等) - **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用 - **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:9826` - **AND** 用户可手动访问 `http://localhost:<server.port>`
#### Scenario: 退出应用 #### Scenario: 退出应用
@@ -124,19 +126,81 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 端口冲突检测 ### Requirement: 端口冲突检测
系统 SHALL 在启动前检测端口是否可用。 系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。
#### Scenario: 端口可用 #### Scenario: 配置端口可用
- **WHEN** 端口 9826 未被占用 - **WHEN** 启动配置中的 `server.port` 未被占用
- **THEN** 服务正常启动 - **THEN** 服务正常启动
#### Scenario: 端口被占用 #### Scenario: 配置端口被占用
- **WHEN** 端口 9826 已被其他程序占用 - **WHEN** 启动配置中的 `server.port` 已被其他程序占用
- **THEN** 显示错误提示"端口 9826 已被占用" - **THEN** 显示错误提示"端口 <server.port> 已被占用"
- **AND** 应用退出 - **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: 跨平台构建 ### Requirement: 跨平台构建
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。 系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。
@@ -152,9 +216,9 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
#### Scenario: Windows 构建 #### Scenario: Windows 构建
- **WHEN** 执行 Windows desktop 构建命令且当前版本为 `1.2.3` - **WHEN** 执行 Windows desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 Windows amd64 和 arm64 desktop 可执行文件 - **THEN** 系统 SHALL 生成 Windows amd64 desktop 可执行文件
- **AND** Windows desktop 构建 SHALL 使用 `-H=windowsgui` linker flag 隐藏控制台窗口 - **AND** Windows desktop 构建 SHALL 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
- **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3``windows`对应架构标识 - **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3``windows` `amd64`
#### Scenario: Linux 构建 #### Scenario: Linux 构建
@@ -316,3 +380,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
#### Scenario: 多行文本处理 #### Scenario: 多行文本处理
- **WHEN** 对话框消息包含换行符 `\n` - **WHEN** 对话框消息包含换行符 `\n`
- **THEN** AppleScript 正确显示多行文本 - **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 检查 ### Requirement: 构建集成 lint 检查
前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。 前端 SHALL 在 `build` 命令中集成 TypeScript 类型检查、ESLint 检查和 Prettier 格式检查。
#### Scenario: 构建时执行 lint 和格式检查 #### Scenario: 构建时执行类型检查、lint 和格式检查
- **WHEN** 执行 `bun run build` - **WHEN** 执行 `bun run build`
- **THEN** 构建 SHALL 依次执行 `tsc -b``bun run check``vite build` - **THEN** 构建 SHALL 依次执行 `bun run check``vite build`
- **THEN** `bun run check` SHALL 执行 `bun run lint && bun run format:check` - **THEN** `bun run check` SHALL 依次执行 `bun run typecheck``bun run lint``bun run format:check`
- **THEN** 若 `tsc -b` 报告类型错误,构建 SHALL 中断
- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断 - **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断
- **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断 - **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断
@@ -77,8 +78,13 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
#### Scenario: 统一检查命令 #### Scenario: 统一检查命令
- **WHEN** 执行 `bun run check` - **WHEN** 执行 `bun run check`
- **THEN** SHALL 运行 `bun run lint && bun run format:check` - **THEN** SHALL 依次运行 `bun run typecheck``bun run lint``bun run format:check`
- **THEN** lint 错误和格式问题 SHALL 都被检查 - **THEN** 类型错误、lint 错误和格式问题 SHALL 都被检查
#### Scenario: 单独执行类型检查
- **WHEN** 执行 `bun run typecheck`
- **THEN** SHALL 运行 `tsc -b`
#### Scenario: 统一修复命令 #### Scenario: 统一修复命令

View File

@@ -125,6 +125,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 供应商列表为空 - **WHEN** 供应商列表为空
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加" - **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: 添加新供应商 #### Scenario: 添加新供应商
- **WHEN** 用户点击"添加供应商"按钮 - **WHEN** 用户点击"添加供应商"按钮
@@ -184,6 +204,16 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 模型列表为空 - **WHEN** 模型列表为空
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加" - **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: 为供应商添加模型 #### Scenario: 为供应商添加模型
- **WHEN** 用户在展开行中点击"添加模型" - **WHEN** 用户在展开行中点击"添加模型"

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: 数据库初始化公共包 ### Requirement: 数据库初始化公共包
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server``cmd/desktop` 共同调用。 系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server``cmd/desktop` 共同调用,并 SHALL 使用随应用构建产物打包的迁移资源执行运行时迁移
#### Scenario: 公共包 Init 函数 #### Scenario: 公共包 Init 函数
- **WHEN** 调用 `database.Init(cfg, logger)` - **WHEN** 调用 `database.Init(cfg, logger)`
- **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接 - **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接
- **THEN** SHALL 执行对应方言的 goose 迁移 - **THEN** SHALL 使用随应用构建产物打包的迁移资源执行对应方言的 goose 迁移
- **THEN** SHALL 配置连接池参数 - **THEN** SHALL 配置连接池参数
- **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL` - **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL`
- **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA - **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA
@@ -71,11 +71,20 @@
- **WHEN** 调用 `database.Close(db)` - **WHEN** 调用 `database.Close(db)`
- **THEN** SHALL 获取底层 `sql.DB` 并关闭连接 - **THEN** SHALL 获取底层 `sql.DB` 并关闭连接
#### Scenario: 迁移目录选择 #### Scenario: 迁移方言资源选择
- **WHEN** 执行迁移 - **WHEN** 执行运行时迁移
- **THEN** SHALL 在 `driver=sqlite`使用 `migrations/sqlite/` 目录goose dialect 为 `sqlite3` - **THEN** SHALL 在 `driver=sqlite`选择 SQLite 方言迁移资源goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql`使用 `migrations/mysql/` 目录goose dialect 为 `mysql` - **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 方言迁移文件 ### 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 = "prettier --write ."` — 格式化所有文件
- `format:check = "prettier --check ."` — 检查文件格式 - `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 问题并格式化 - `fix = "bun run lint:fix && bun run format"` — 修复 lint 问题并格式化
#### Scenario: 运行格式化命令 #### Scenario: 运行格式化命令
@@ -207,8 +208,14 @@
#### Scenario: 运行统一检查命令 #### Scenario: 运行统一检查命令
- **WHEN** 执行 `bun run check` - **WHEN** 执行 `bun run check`
- **THEN** SHALL 运行 `bun run lint && bun run format:check` - **THEN** SHALL 依次运行 `bun run typecheck``bun run lint``bun run format:check`
- **THEN** lint 错误和格式问题 SHALL 都被检查 - **THEN** TypeScript 类型错误、lint 错误和格式问题 SHALL 都被检查
#### Scenario: 运行类型检查命令
- **WHEN** 执行 `bun run typecheck`
- **THEN** SHALL 运行 `tsc -b`
- **THEN** TypeScript 类型错误 SHALL 报告错误
#### Scenario: 运行统一修复命令 #### Scenario: 运行统一修复命令

View File

@@ -43,7 +43,6 @@
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_arm64.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>_macos_universal.tar.gz`
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_amd64.zip` - **AND** 系统 SHALL 生成 `nex-server_<version>_windows_amd64.zip`
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_arm64.zip`
#### Scenario: web 发布构建 #### Scenario: web 发布构建
@@ -57,22 +56,33 @@
- **WHEN** 发布流水线执行 Linux desktop 发布构建 - **WHEN** 发布流水线执行 Linux desktop 发布构建
- **THEN** 系统 SHALL 在可访问 Go、Bun、CGO、GTK3、Ayatana AppIndicator 和 Linux 打包工具链的环境中构建 - **THEN** 系统 SHALL 在可访问 Go、Bun、CGO、GTK3、Ayatana AppIndicator 和 Linux 打包工具链的环境中构建
- **AND** 系统 SHALL 为 `amd64``arm64` 分别生成 tar.gz、AppImage、deb 和 rpm desktop 发布资产 - **AND** 系统 SHALL 为 `amd64``arm64` 分别生成 tar.gz、AppImage、deb 和 rpm desktop 发布资产
- **AND** Linux arm64 desktop 发布构建 SHALL 使用原生 arm64 runner 或等价的 arm64 Linux 构建环境 - **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 发布构建 #### Scenario: Windows desktop 发布构建
- **WHEN** 发布流水线执行 Windows desktop 发布构建 - **WHEN** 发布流水线执行 Windows desktop 发布构建
- **THEN** 系统 SHALL 在包含对应架构 MSYS2/MinGW 或等价 CGO 工具链的环境中构建 - **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 生成 `nex-desktop_<version>_windows_amd64.zip`
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_arm64.zip` - **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
#### Scenario: macOS desktop 发布构建 #### Scenario: macOS desktop 发布构建
- **WHEN** 发布流水线执行 macOS desktop 发布构建 - **WHEN** 发布流水线执行 macOS desktop 发布构建
- **THEN** 系统 SHALL 在可访问 Go、Bun、Xcode 命令行工具、`lipo``hdiutil` 和 zip 打包工具的 macOS 环境中构建 - **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.zip`
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.dmg` - **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: 三平台发布构建预检 ### Requirement: 三平台发布构建预检
系统 SHALL 在正式执行各平台 release 构建前验证对应 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。 系统 SHALL 在正式执行各平台 release 构建前验证对应 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。
@@ -166,7 +176,7 @@
- **WHEN** 当前发布版本为 `1.2.3` - **WHEN** 当前发布版本为 `1.2.3`
- **THEN** Linux server 发布资产文件名 SHALL 为 `nex-server_1.2.3_linux_amd64.tar.gz``nex-server_1.2.3_linux_arm64.tar.gz` - **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** 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``nex-server_1.2.3_windows_arm64.zip` - **AND** Windows server 发布资产文件名 SHALL 为 `nex-server_1.2.3_windows_amd64.zip`
#### Scenario: web 资产命名 #### Scenario: web 资产命名
@@ -178,7 +188,7 @@
- **WHEN** 当前发布版本为 `1.2.3` - **WHEN** 当前发布版本为 `1.2.3`
- **THEN** Linux desktop 发布资产文件名 SHALL 使用 `nex-desktop_1.2.3_linux_<arch>.<format>` 格式 - **THEN** Linux desktop 发布资产文件名 SHALL 使用 `nex-desktop_1.2.3_linux_<arch>.<format>` 格式
- **AND** Windows desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_windows_amd64.zip``nex-desktop_1.2.3_windows_arm64.zip` - **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 desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_macos_universal.zip``nex-desktop_1.2.3_macos_universal.dmg`
- **AND** 发布资产文件名中的 macOS 平台字段 SHALL 使用 `macos` 而非 `darwin` - **AND** 发布资产文件名中的 macOS 平台字段 SHALL 使用 `macos` 而非 `darwin`
@@ -210,3 +220,32 @@
- **WHEN** 任一构建 job 尝试上传 release artifact 但匹配不到目标文件 - **WHEN** 任一构建 job 尝试上传 release artifact 但匹配不到目标文件
- **THEN** 该 job SHALL 失败 - **THEN** 该 job SHALL 失败
- **AND** Draft Release 组装 SHALL NOT 继续发布不完整资产集合 - **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 验证 LoadConfigFromPath 正确加载默认值
- **THEN** SHALL 验证 YAML 配置文件正确读取 - **THEN** SHALL 验证 YAML 配置文件正确读取
- **THEN** SHALL 验证优先级链CLI 参数 > 环境变量 > YAML 文件 > 默认值 - **THEN** SHALL 验证优先级链CLI 参数 > 环境变量 > YAML 文件 > 默认值
- **THEN** SHALL 验证首次启动自动创建配置文件 - **THEN** SHALL 验证配置文件缺失时使用默认值,不自动创建配置文件
- **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致 - **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致
#### Scenario: 环境变量覆盖验证 #### Scenario: 环境变量覆盖验证
@@ -46,11 +46,12 @@
- **THEN** SHALL 成功加载 - **THEN** SHALL 成功加载
- **THEN** 配置值 SHALL 反映环境变量覆盖 - **THEN** 配置值 SHALL 反映环境变量覆盖
#### Scenario: 自动创建配置文件验证 #### Scenario: 配置文件缺失时使用默认值
- **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径 - **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径
- **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误) - **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误)
- **THEN** SHALL 返回默认配置对象 - **THEN** SHALL 返回默认配置对象
- **THEN** SHALL NOT 自动创建配置文件
#### Scenario: handler 错误分支测试 #### Scenario: handler 错误分支测试
@@ -279,3 +280,74 @@
- **WHEN** mockgen 生成的 mock 就绪 - **WHEN** mockgen 生成的 mock 就绪
- **THEN** handler 测试中的手写 mock SHALL 被替换为生成的 mock - **THEN** handler 测试中的手写 mock SHALL 被替换为生成的 mock
- **THEN** 所有测试 SHALL 继续通过,行为不变 - **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 编排 ### 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: 完整升迁流程 #### Scenario: 完整升迁流程
- **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0` - **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 #### Scenario: 不传 BUMP 默认 patch
- **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0` - **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0`
- **THEN** Makefile SHALL 等效于执行 `make version-bump BUMP=patch`,将版本更新为 `0.1.1` - **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: 工作区不干净 #### Scenario: 工作区不干净
- **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动 - **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或暂存改动 - **THEN** Makefile SHALL 以非零退出码失败并提示先提交或清理改动
#### Scenario: 支持指定版本号 #### Scenario: 支持指定版本号

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'

View File

@@ -288,7 +288,6 @@ func serverAssetName(version, platform, arch, format string) (string, error) {
{platform: "macos", arch: "arm64", format: "tar.gz"}, {platform: "macos", arch: "arm64", format: "tar.gz"},
{platform: "macos", arch: "universal", format: "tar.gz"}, {platform: "macos", arch: "universal", format: "tar.gz"},
{platform: "windows", arch: "amd64", format: "zip"}, {platform: "windows", arch: "amd64", format: "zip"},
{platform: "windows", arch: "arm64", format: "zip"},
}) { }) {
return "", fmt.Errorf("不支持的 server 资产目标 %s/%s/%s", platform, arch, format) return "", fmt.Errorf("不支持的 server 资产目标 %s/%s/%s", platform, arch, format)
} }
@@ -321,7 +320,6 @@ func desktopAssetName(version, platform, arch, format string) (string, error) {
{platform: "macos", arch: "universal", format: "zip"}, {platform: "macos", arch: "universal", format: "zip"},
{platform: "macos", arch: "universal", format: "dmg"}, {platform: "macos", arch: "universal", format: "dmg"},
{platform: "windows", arch: "amd64", format: "zip"}, {platform: "windows", arch: "amd64", format: "zip"},
{platform: "windows", arch: "arm64", format: "zip"},
}) { }) {
return "", fmt.Errorf("不支持的 desktop 资产目标 %s/%s/%s", platform, arch, format) return "", fmt.Errorf("不支持的 desktop 资产目标 %s/%s/%s", platform, arch, format)
} }

View File

@@ -97,7 +97,6 @@ func TestAssetNames(t *testing.T) {
{"server macos arm64", "server", "macos", "arm64", "tar.gz", "nex-server_1.2.3_macos_arm64.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 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"}, {"server windows amd64", "server", "windows", "amd64", "zip", "nex-server_1.2.3_windows_amd64.zip"},
{"server windows arm64", "server", "windows", "arm64", "zip", "nex-server_1.2.3_windows_arm64.zip"},
{"web", "web", "", "", "tar.gz", "nex-web_1.2.3.tar.gz"}, {"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 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 appimage", "desktop", "linux", "amd64", "AppImage", "nex-desktop_1.2.3_linux_amd64.AppImage"},
@@ -110,7 +109,6 @@ func TestAssetNames(t *testing.T) {
{"desktop macos zip", "desktop", "macos", "universal", "zip", "nex-desktop_1.2.3_macos_universal.zip"}, {"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 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"}, {"desktop windows amd64", "desktop", "windows", "amd64", "zip", "nex-desktop_1.2.3_windows_amd64.zip"},
{"desktop windows arm64", "desktop", "windows", "arm64", "zip", "nex-desktop_1.2.3_windows_arm64.zip"},
} }
for _, tc := range testCases { for _, tc := range testCases {