Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 598e2acb7e | |||
| 4870d29638 | |||
| 8600a39b6c | |||
| 407d008e19 | |||
| a2751eab31 | |||
| 5655fc5560 |
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
@@ -161,15 +161,6 @@ jobs:
|
||||
packages: >-
|
||||
make
|
||||
mingw-w64-x86_64-gcc
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
msystem: CLANGARM64
|
||||
cc: clang
|
||||
cxx: clang++
|
||||
packages: >-
|
||||
make
|
||||
mingw-w64-clang-aarch64-clang
|
||||
mingw-w64-clang-aarch64-llvm
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -214,17 +205,8 @@ jobs:
|
||||
"$CC" --version
|
||||
command -v "$CXX"
|
||||
"$CXX" --version
|
||||
if [ "${{ matrix.arch }}" = "arm64" ]; then
|
||||
if command -v llvm-windres >/dev/null 2>&1; then
|
||||
llvm-windres --version
|
||||
else
|
||||
command -v windres
|
||||
windres --version
|
||||
fi
|
||||
else
|
||||
command -v windres
|
||||
windres --version
|
||||
fi
|
||||
command -v windres
|
||||
windres --version
|
||||
if command -v powershell.exe >/dev/null 2>&1; then
|
||||
powershell.exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
|
||||
else
|
||||
|
||||
119
Makefile
119
Makefile
@@ -1,5 +1,5 @@
|
||||
.PHONY: \
|
||||
lint test clean \
|
||||
lint test clean hooks-install hooks-check hooks-test \
|
||||
version-sync version-check version-bump \
|
||||
server-run server-build server-lint server-test server-clean \
|
||||
desktop-build-mac desktop-build-win desktop-build-linux \
|
||||
@@ -10,6 +10,7 @@
|
||||
_backend-lint _backend-test _backend-clean _backend-build \
|
||||
_versionctl-lint _versionctl-test \
|
||||
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \
|
||||
_hooks-pre-commit _check-clean-worktree \
|
||||
_desktop-test _desktop-clean _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource \
|
||||
_server-run-backend _server-run-frontend \
|
||||
_check-linux-target-arch _check-windows-target-arch _ensure-appimagetool \
|
||||
@@ -35,17 +36,15 @@ ifeq ($(TARGET_ARCH),arm64)
|
||||
APPIMAGE_ARCH := aarch64
|
||||
DEB_ARCH := arm64
|
||||
RPM_ARCH := aarch64
|
||||
WINDOWS_WINDRES_FORMAT_BFD := pe-aarch64
|
||||
WINDOWS_WINDRES_FORMAT_LLVM := aarch64-w64-mingw32
|
||||
WINDOWS_RESOURCE := rsrc_windows_arm64.syso
|
||||
else
|
||||
APPIMAGE_ARCH := x86_64
|
||||
DEB_ARCH := amd64
|
||||
RPM_ARCH := x86_64
|
||||
endif
|
||||
|
||||
WINDOWS_WINDRES_FORMAT_BFD := pe-x86-64
|
||||
WINDOWS_WINDRES_FORMAT_LLVM := x86_64-w64-mingw32
|
||||
WINDOWS_RESOURCE := rsrc_windows_amd64.syso
|
||||
endif
|
||||
|
||||
APPIMAGETOOL_PATH := build/tools/appimagetool-$(APPIMAGE_ARCH).AppImage
|
||||
APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage
|
||||
@@ -64,6 +63,90 @@ test: _backend-test _frontend-test _desktop-test _versionctl-test
|
||||
clean: _backend-clean _frontend-clean _desktop-clean
|
||||
@printf 'Clean complete\n'
|
||||
|
||||
# ============================================
|
||||
# Git hooks
|
||||
# ============================================
|
||||
|
||||
hooks-install:
|
||||
@hooks_dir=$$(git rev-parse --git-path hooks); \
|
||||
mkdir -p "$$hooks_dir"; \
|
||||
cp scripts/git-hooks/pre-commit "$$hooks_dir/pre-commit"; \
|
||||
cp scripts/git-hooks/commit-msg "$$hooks_dir/commit-msg"; \
|
||||
chmod +x "$$hooks_dir/pre-commit" "$$hooks_dir/commit-msg"; \
|
||||
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; 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 -e; \
|
||||
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; \
|
||||
backend_pkgs=''; \
|
||||
versionctl_pkgs=''; \
|
||||
for file in $$staged_files; do \
|
||||
[ -n "$$file" ] || continue; \
|
||||
case "$$file" in scripts/git-hooks/*) continue ;; esac; \
|
||||
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; \
|
||||
case "$$file" in \
|
||||
backend/*.go) \
|
||||
dir=$$(dirname "$${file#backend/}"); \
|
||||
case " $$backend_pkgs " in *" $$dir "*) ;; *) backend_pkgs="$$backend_pkgs $$dir" ;; esac; \
|
||||
;; \
|
||||
versionctl/*.go) \
|
||||
dir=$$(dirname "$${file#versionctl/}"); \
|
||||
case " $$versionctl_pkgs " in *" $$dir "*) ;; *) versionctl_pkgs="$$versionctl_pkgs $$dir" ;; esac; \
|
||||
;; \
|
||||
frontend/*.ts|frontend/*.tsx) \
|
||||
rel=$${file#frontend/}; \
|
||||
printf 'Frontend lint: frontend/%s\n' "$$rel"; \
|
||||
(cd frontend && bunx eslint "$$rel"); \
|
||||
printf 'Frontend format: frontend/%s\n' "$$rel"; \
|
||||
(cd frontend && bunx prettier --check "$$rel"); \
|
||||
;; \
|
||||
frontend/*.scss) \
|
||||
rel=$${file#frontend/}; \
|
||||
printf 'Frontend format: frontend/%s\n' "$$rel"; \
|
||||
(cd frontend && bunx prettier --check "$$rel"); \
|
||||
;; \
|
||||
esac; \
|
||||
done; \
|
||||
for dir in $$backend_pkgs; do \
|
||||
printf 'Go lint: backend/%s\n' "$$dir"; \
|
||||
(cd backend && go tool golangci-lint run "$$dir/"); \
|
||||
done; \
|
||||
for dir in $$versionctl_pkgs; do \
|
||||
printf 'Go lint: versionctl/%s\n' "$$dir"; \
|
||||
(cd versionctl && go tool golangci-lint run "$$dir/"); \
|
||||
done; \
|
||||
printf 'Pre-commit checks passed\n'
|
||||
|
||||
# ============================================
|
||||
# 版本管理
|
||||
# ============================================
|
||||
@@ -75,13 +158,21 @@ version-check:
|
||||
go run ./versionctl check
|
||||
|
||||
version-bump: BUMP ?= patch
|
||||
version-bump:
|
||||
$(eval _BUMP_ARG := $(if $(SET_VERSION),$(SET_VERSION),$(BUMP)))
|
||||
$(eval _NEW_VERSION := $(shell go run ./versionctl bump $(_BUMP_ARG)))
|
||||
git add VERSION frontend/
|
||||
git commit -m "chore: 版本升迁 v$(_NEW_VERSION)"
|
||||
git tag "v$(_NEW_VERSION)"
|
||||
@printf '版本升迁完成: v%s\n' "$(_NEW_VERSION)"
|
||||
version-bump: lint test _check-clean-worktree
|
||||
@set -e; \
|
||||
bump_arg="$(if $(SET_VERSION),$(SET_VERSION),$(BUMP))"; \
|
||||
new_version=$$(go run ./versionctl bump "$$bump_arg"); \
|
||||
git add VERSION frontend/; \
|
||||
git commit -m "chore: 版本升迁 v$$new_version"; \
|
||||
git tag "v$$new_version"; \
|
||||
printf '版本升迁完成: v%s\n' "$$new_version"
|
||||
|
||||
_check-clean-worktree:
|
||||
@if [ -n "$$(git status --porcelain)" ]; then \
|
||||
printf '工作区不干净,请先提交或清理改动后再执行版本升迁。\n' >&2; \
|
||||
git status --short; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# Server 模式
|
||||
@@ -163,7 +254,7 @@ _desktop-test:
|
||||
cd backend && go test ./cmd/desktop/... -v
|
||||
|
||||
_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
|
||||
@printf 'Preparing frontend for desktop...\n'
|
||||
@@ -283,7 +374,7 @@ _check-linux-target-arch:
|
||||
fi
|
||||
|
||||
_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)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
24
README.md
24
README.md
@@ -109,9 +109,6 @@ make desktop-build-mac
|
||||
# Windows
|
||||
make desktop-build-win
|
||||
|
||||
# Windows arm64
|
||||
make desktop-build-win TARGET_ARCH=arm64
|
||||
|
||||
# Linux
|
||||
make desktop-build-linux
|
||||
|
||||
@@ -120,7 +117,7 @@ make desktop-build-linux TARGET_ARCH=arm64
|
||||
```
|
||||
|
||||
**使用桌面应用**:
|
||||
- 双击启动应用(macOS: Nex.app,Windows: nex-win-amd64.exe / nex-win-arm64.exe,Linux: nex-linux-amd64 / nex-linux-arm64)
|
||||
- 双击启动应用(macOS: Nex.app,Windows: nex-win-amd64.exe,Linux: nex-linux-amd64 / nex-linux-arm64)
|
||||
- 系统托盘图标出现,浏览器自动打开管理界面
|
||||
- 点击托盘图标显示菜单,可打开管理界面或退出
|
||||
- 关闭浏览器后服务继续运行,可通过托盘重新打开
|
||||
@@ -175,7 +172,6 @@ make server-build
|
||||
| macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` |
|
||||
| macOS universal | `nex-server_<version>_macos_universal.tar.gz` |
|
||||
| Windows amd64 | `nex-server_<version>_windows_amd64.zip` |
|
||||
| Windows arm64 | `nex-server_<version>_windows_arm64.zip` |
|
||||
|
||||
**web 产物**:
|
||||
|
||||
@@ -191,10 +187,11 @@ make server-build
|
||||
| Linux arm64 | `nex-desktop_<version>_linux_arm64.tar.gz`、`.AppImage`、`.deb`、`.rpm` |
|
||||
| macOS universal | `nex-desktop_<version>_macos_universal.zip`、`nex-desktop_<version>_macos_universal.dmg` |
|
||||
| Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` |
|
||||
| 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 依赖。
|
||||
|
||||
server 和 desktop 发布产物自包含运行时数据库迁移资源(通过 `go:embed` 嵌入二进制),安装后首次启动不再依赖仓库源码目录。
|
||||
|
||||
## API 接口
|
||||
|
||||
### 代理接口(对外部应用)
|
||||
@@ -333,7 +330,13 @@ backend 分类测试、MySQL 专项测试和前端 E2E 测试请分别查看 `ba
|
||||
|
||||
```bash
|
||||
# 首次克隆后安装 Git hooks
|
||||
lefthook install
|
||||
make hooks-install
|
||||
|
||||
# 检查 Git hooks 安装状态
|
||||
make hooks-check
|
||||
|
||||
# 运行 Git hooks 回归测试
|
||||
make hooks-test
|
||||
|
||||
# 全局命令
|
||||
make lint # 前后端共享检查
|
||||
@@ -356,6 +359,11 @@ make desktop-test # desktop 专属测试
|
||||
make desktop-clean # 清理 desktop 产物
|
||||
```
|
||||
|
||||
Git hooks 使用仓库内 `scripts/git-hooks/` 的原生脚本,不依赖额外 hook 框架。当前 hooks 包含:
|
||||
|
||||
- pre-commit:检查 staged files 的冲突标记、Go lint、前端 lint/格式和大文件告警
|
||||
- commit-msg:校验提交信息格式为 `类型: 简短描述`,描述需使用中文
|
||||
|
||||
## 版本与发布
|
||||
|
||||
### 统一版本源
|
||||
@@ -366,7 +374,7 @@ make desktop-clean # 清理 desktop 产物
|
||||
### 本地版本演进
|
||||
|
||||
```bash
|
||||
# 递增版本(自动 sync + check + commit + tag)
|
||||
# 递增版本(自动 lint + test + 工作区检查 + sync/check + commit + tag)
|
||||
make version-bump BUMP=minor
|
||||
|
||||
# 或指定具体版本号
|
||||
|
||||
@@ -164,7 +164,11 @@ backend/
|
||||
│ └── validator/ # 验证器
|
||||
│ └── validator.go
|
||||
├── migrations/ # 数据库迁移
|
||||
│ └── 20260421000001_initial_schema.sql
|
||||
│ ├── embed.go # go:embed 迁移资源入口
|
||||
│ ├── sqlite/
|
||||
│ │ └── 20260421000001_initial_schema.sql
|
||||
│ └── mysql/
|
||||
│ └── 20260421000001_initial_schema.sql
|
||||
├── tests/ # 集成测试
|
||||
│ ├── helpers.go # 测试辅助函数
|
||||
│ ├── config/ # 测试配置
|
||||
@@ -456,6 +460,8 @@ make mysql-test-quick
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
应用启动时使用随二进制打包的迁移资源(`go:embed`)自动执行迁移,server 和 desktop 发布产物均自包含,不依赖源码目录。开发时可继续通过 Makefile goose CLI 操作文件系统中的 `migrations/<dialect>/` 目录,运行时嵌入资源与文件系统目录共享同一批 SQL 文件。
|
||||
|
||||
```bash
|
||||
# 使用 Makefile
|
||||
make migrate-up DB_DSN=~/.nex/config.db
|
||||
|
||||
43
backend/cmd/desktop/migration_test.go
Normal file
43
backend/cmd/desktop/migration_test.go
Normal 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)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"go.uber.org/zap"
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
"nex/backend/migrations"
|
||||
pkglogger "nex/backend/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -77,29 +78,24 @@ func runMigrations(db *gorm.DB, driver string, zapLogger *zap.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
gooseDialect := "sqlite3"
|
||||
migrationsSubDir := "sqlite"
|
||||
if driver == "mysql" {
|
||||
gooseDialect = "mysql"
|
||||
migrationsSubDir = "mysql"
|
||||
}
|
||||
|
||||
migrationsDir := getMigrationsDir(driver)
|
||||
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
|
||||
dialect, fsys, err := migrations.ForDriver(driver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if zapLogger != nil {
|
||||
zapLogger.Info("执行数据库迁移",
|
||||
zap.String("dialect", gooseDialect),
|
||||
zap.String("dir", migrationsSubDir))
|
||||
zap.String("dialect", string(dialect)),
|
||||
zap.String("driver", driver))
|
||||
}
|
||||
|
||||
if err := goose.SetDialect(gooseDialect); err != nil {
|
||||
return err
|
||||
provider, err := goose.NewProvider(dialect, sqlDB, fsys)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建迁移提供者失败: %w", err)
|
||||
}
|
||||
if err := goose.Up(sqlDB, migrationsDir); err != nil {
|
||||
return err
|
||||
|
||||
if _, err := provider.Up(context.Background()); err != nil {
|
||||
return fmt.Errorf("执行迁移失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -130,21 +126,6 @@ func configurePool(db *gorm.DB, cfg *config.DatabaseConfig, zapLogger *zap.Logge
|
||||
}
|
||||
}
|
||||
|
||||
func getMigrationsDir(driver string) string {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if ok {
|
||||
subDir := "sqlite"
|
||||
if driver == "mysql" {
|
||||
subDir = "mysql"
|
||||
}
|
||||
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", subDir)
|
||||
if abs, err := filepath.Abs(dir); err == nil {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
return "./migrations"
|
||||
}
|
||||
|
||||
func BuildDSN(cfg *config.DatabaseConfig) string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
|
||||
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
"nex/backend/migrations"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -76,3 +79,87 @@ func TestBuildDSN_EmptyPassword(t *testing.T) {
|
||||
dsn := BuildDSN(cfg)
|
||||
assert.Equal(t, "root:@tcp(localhost:3306)/nex?charset=utf8mb4&parseTime=true&loc=Local", dsn)
|
||||
}
|
||||
|
||||
func TestInit_SQLite_AnyCWD(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
origDir, err := os.Getwd()
|
||||
if err == nil {
|
||||
defer func() {
|
||||
if chdirErr := os.Chdir(origDir); chdirErr != nil {
|
||||
t.Logf("无法恢复工作目录: %v", chdirErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if chdirErr := os.Chdir(dir); chdirErr != nil {
|
||||
t.Skipf("无法切换到临时目录: %v", chdirErr)
|
||||
}
|
||||
|
||||
cfg := &config.DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
Path: filepath.Join(dir, "test.db"),
|
||||
MaxIdleConns: 5,
|
||||
MaxOpenConns: 10,
|
||||
ConnMaxLifetime: 0,
|
||||
}
|
||||
|
||||
zapLogger := zap.NewNop()
|
||||
db, err := Init(cfg, zapLogger)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
defer Close(db)
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sqlDB)
|
||||
}
|
||||
|
||||
func TestForDriverDialect_SQLite(t *testing.T) {
|
||||
require.NoError(t, testMigrateWithDriver(t, "sqlite"))
|
||||
}
|
||||
|
||||
func TestForDriverDialect_MySQL(t *testing.T) {
|
||||
dialect, fsys, err := migrations.ForDriver("mysql")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mysql", string(dialect))
|
||||
entries, fsErr := fs.ReadDir(fsys, ".")
|
||||
require.NoError(t, fsErr)
|
||||
assert.NotEmpty(t, entries, "MySQL 迁移资源应至少包含一个文件")
|
||||
}
|
||||
|
||||
func TestForDriverDialect_Invalid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.DatabaseConfig{
|
||||
Driver: "postgres",
|
||||
Path: filepath.Join(dir, "test.db"),
|
||||
MaxIdleConns: 5,
|
||||
MaxOpenConns: 10,
|
||||
ConnMaxLifetime: 0,
|
||||
}
|
||||
|
||||
zapLogger := zap.NewNop()
|
||||
_, err := Init(cfg, zapLogger)
|
||||
assert.Error(t, err, "非法 driver 应返回错误")
|
||||
assert.Contains(t, err.Error(), "不支持的数据库驱动")
|
||||
}
|
||||
|
||||
func testMigrateWithDriver(t *testing.T, driver string) error {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := &config.DatabaseConfig{
|
||||
Driver: driver,
|
||||
Path: filepath.Join(dir, "test.db"),
|
||||
MaxIdleConns: 5,
|
||||
MaxOpenConns: 10,
|
||||
ConnMaxLifetime: 0,
|
||||
}
|
||||
|
||||
zapLogger := zap.NewNop()
|
||||
db, err := Init(cfg, zapLogger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Close(db)
|
||||
return nil
|
||||
}
|
||||
|
||||
71
backend/internal/database/embedded_migration_test.go
Normal file
71
backend/internal/database/embedded_migration_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
31
backend/migrations/embed.go
Normal file
31
backend/migrations/embed.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_APP_VERSION=0.1.6
|
||||
VITE_APP_VERSION=0.1.7
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_APP_VERSION=0.1.6
|
||||
VITE_APP_VERSION=0.1.7
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE=/api
|
||||
VITE_APP_VERSION=0.1.6
|
||||
VITE_APP_VERSION=0.1.7
|
||||
|
||||
@@ -188,13 +188,14 @@ bun run test:e2e
|
||||
- API Key 脱敏显示
|
||||
- 启用/禁用状态标签
|
||||
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择
|
||||
- **一键复制**:Base URL 和 API Key 支持一键复制到剪贴板
|
||||
|
||||
### 模型管理
|
||||
|
||||
- 展开供应商行查看关联模型
|
||||
- 添加/编辑/删除模型
|
||||
- 按供应商筛选模型
|
||||
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别
|
||||
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别,支持一键复制
|
||||
- **UUID 自动生成**:创建模型时后端自动生成 UUID,无需手动输入 ID
|
||||
|
||||
### 用量统计
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,6 +4,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ModelTable } from '@/pages/Providers/ModelTable'
|
||||
import type { Model } from '@/types'
|
||||
|
||||
const { mockMessagePluginSuccess } = vi.hoisted(() => ({
|
||||
mockMessagePluginSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('tdesign-react', async () => {
|
||||
const actual = await vi.importActual('tdesign-react')
|
||||
return {
|
||||
...actual,
|
||||
MessagePlugin: {
|
||||
success: mockMessagePluginSuccess,
|
||||
error: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockModels: Model[] = [
|
||||
{
|
||||
id: 'model-1',
|
||||
@@ -44,6 +59,7 @@ const defaultProps = {
|
||||
describe('ModelTable', () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear()
|
||||
mockMessagePluginSuccess.mockClear()
|
||||
})
|
||||
|
||||
it('renders model list with unified ID and model name', () => {
|
||||
@@ -120,4 +136,19 @@ describe('ModelTable', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders unified model ID with copy button and copies on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<ModelTable {...defaultProps} />)
|
||||
|
||||
const allCells = container.querySelectorAll('td')
|
||||
const modelIdCell = Array.from(allCells).find((td) => td.textContent?.includes('openai/gpt-4o'))
|
||||
expect(modelIdCell).toBeTruthy()
|
||||
|
||||
const buttons = modelIdCell!.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(buttons[0]!)
|
||||
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制统一模型 ID')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ProviderTable } from '@/pages/Providers/ProviderTable'
|
||||
import type { Provider } from '@/types'
|
||||
|
||||
const { mockMessagePluginSuccess } = vi.hoisted(() => ({
|
||||
mockMessagePluginSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('tdesign-react', async () => {
|
||||
const actual = await vi.importActual('tdesign-react')
|
||||
return {
|
||||
...actual,
|
||||
MessagePlugin: {
|
||||
success: mockMessagePluginSuccess,
|
||||
error: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockModelsData = [
|
||||
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
|
||||
{
|
||||
@@ -54,6 +69,9 @@ const defaultProps = {
|
||||
}
|
||||
|
||||
describe('ProviderTable', () => {
|
||||
beforeEach(() => {
|
||||
mockMessagePluginSuccess.mockClear()
|
||||
})
|
||||
it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
|
||||
render(<ProviderTable {...defaultProps} />)
|
||||
|
||||
@@ -203,4 +221,66 @@ describe('ProviderTable', () => {
|
||||
const protocolCell = container.querySelector('[data-colkey="protocol"]')
|
||||
expect(protocolCell).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Base URL with copy button and copies on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<ProviderTable {...defaultProps} />)
|
||||
|
||||
const baseUrlCells = container.querySelectorAll('td')
|
||||
const baseUrlCellWithContent = Array.from(baseUrlCells).find((td) =>
|
||||
td.textContent?.includes('https://api.openai.com/v1')
|
||||
)
|
||||
expect(baseUrlCellWithContent).toBeTruthy()
|
||||
|
||||
const buttons = baseUrlCellWithContent!.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(buttons[0]!)
|
||||
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 Base URL')
|
||||
})
|
||||
|
||||
it('renders API Key with copy button and copies on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<ProviderTable {...defaultProps} />)
|
||||
|
||||
const allCells = container.querySelectorAll('td')
|
||||
const apiKeyCell = Array.from(allCells).find((td) => td.textContent?.includes('sk-abcdefgh12345678'))
|
||||
expect(apiKeyCell).toBeTruthy()
|
||||
|
||||
const buttons = apiKeyCell!.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(buttons[0]!)
|
||||
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 API Key')
|
||||
})
|
||||
|
||||
it('does not render copy button when Base URL is empty', () => {
|
||||
const emptyUrlProvider: Provider[] = [
|
||||
{
|
||||
...mockProviders[0],
|
||||
id: 'empty-url',
|
||||
baseUrl: '',
|
||||
},
|
||||
]
|
||||
const { container } = render(<ProviderTable {...defaultProps} providers={emptyUrlProvider} />)
|
||||
|
||||
const allCells = container.querySelectorAll('td')
|
||||
const baseUrlCells = Array.from(allCells).filter((td) => td.textContent === '')
|
||||
expect(baseUrlCells.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('does not render copy button when API Key is empty', () => {
|
||||
const emptyKeyProvider: Provider[] = [
|
||||
{
|
||||
...mockProviders[0],
|
||||
id: 'empty-key',
|
||||
apiKey: '',
|
||||
},
|
||||
]
|
||||
const { container } = render(<ProviderTable {...defaultProps} providers={emptyKeyProvider} />)
|
||||
|
||||
const allCells = container.querySelectorAll('td')
|
||||
const apiKeyCells = Array.from(allCells).filter((td) => td.textContent === '')
|
||||
expect(apiKeyCells.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'
|
||||
import { Button, Table, Tag, Popconfirm, Space, Typography, MessagePlugin } from 'tdesign-react'
|
||||
import { useModels, useDeleteModel } from '@/hooks/useModels'
|
||||
import type { Model } from '@/types'
|
||||
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
||||
@@ -18,8 +18,30 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
||||
title: '统一模型 ID',
|
||||
colKey: 'unifiedId',
|
||||
width: 250,
|
||||
ellipsis: true,
|
||||
cell: ({ row }) => row.unifiedId || `${row.providerId}/${row.modelName}`,
|
||||
cell: ({ row }) => {
|
||||
const id = row.unifiedId || `${row.providerId}/${row.modelName}`
|
||||
return id ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
<Typography.Text
|
||||
style={{ flexShrink: 0 }}
|
||||
copyable={{
|
||||
text: id,
|
||||
onCopy: () => MessagePlugin.success('已复制统一模型 ID'),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : null
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '模型名称',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'
|
||||
import { Button, Table, Tag, Popconfirm, Space, Card, Typography, MessagePlugin } from 'tdesign-react'
|
||||
import type { Provider, Model } from '@/types'
|
||||
import { ModelTable } from './ModelTable'
|
||||
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
||||
@@ -32,7 +32,28 @@ export function ProviderTable({
|
||||
{
|
||||
title: 'Base URL',
|
||||
colKey: 'baseUrl',
|
||||
ellipsis: true,
|
||||
cell: ({ row }) =>
|
||||
row.baseUrl ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{row.baseUrl}
|
||||
</span>
|
||||
<Typography.Text
|
||||
style={{ flexShrink: 0 }}
|
||||
copyable={{
|
||||
text: row.baseUrl,
|
||||
onCopy: () => MessagePlugin.success('已复制 Base URL'),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: '协议',
|
||||
@@ -47,7 +68,28 @@ export function ProviderTable({
|
||||
{
|
||||
title: 'API Key',
|
||||
colKey: 'apiKey',
|
||||
ellipsis: true,
|
||||
cell: ({ row }) =>
|
||||
row.apiKey ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{row.apiKey}
|
||||
</span>
|
||||
<Typography.Text
|
||||
style={{ flexShrink: 0 }}
|
||||
copyable={{
|
||||
text: row.apiKey,
|
||||
onCopy: () => MessagePlugin.success('已复制 API Key'),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
pre-commit:
|
||||
commands:
|
||||
backend-lint:
|
||||
glob: "backend/**/*.go"
|
||||
run: cd backend && go tool golangci-lint run --new-from-rev HEAD ./...
|
||||
@@ -104,14 +104,14 @@
|
||||
|
||||
### Requirement: 应用启动时迁移
|
||||
|
||||
应用 SHALL 在启动时执行迁移。
|
||||
应用 SHALL 在启动时执行迁移,并 SHALL 使用随应用构建产物可用的打包迁移资源。
|
||||
|
||||
#### Scenario: 自动迁移
|
||||
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移目录和 goose dialect
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录,goose dialect 为 `mysql`
|
||||
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移资源和 goose dialect
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 SQLite 方言迁移资源,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时使用 MySQL 方言迁移资源,goose dialect 为 `mysql`
|
||||
- **THEN** SHALL 自动执行待执行的迁移
|
||||
- **THEN** SHALL 在迁移失败时拒绝启动
|
||||
- **THEN** SHALL 记录迁移日志
|
||||
@@ -122,6 +122,15 @@
|
||||
- **THEN** SHALL 检查数据库迁移版本
|
||||
- **THEN** SHALL 在版本不匹配时执行迁移
|
||||
|
||||
#### Scenario: 发布产物无源码目录时自动迁移
|
||||
|
||||
- **WHEN** 应用以发布产物形式运行,且安装环境不存在仓库源码目录
|
||||
- **THEN** 应用启动 SHALL 能找到迁移资源
|
||||
- **THEN** SHALL 自动执行待执行迁移
|
||||
- **THEN** SHALL NOT 依赖源码工作区中的 `backend/migrations/...` 文件系统路径
|
||||
- **THEN** SHALL NOT 通过 `runtime.Caller` 推导构建机或源码目录作为运行时迁移目录
|
||||
- **THEN** 日志中的迁移资源位置 SHALL NOT 指向构建机路径,如 `/Users/runner/work/...`
|
||||
|
||||
### Requirement: 连接池配置
|
||||
|
||||
系统 SHALL 配置数据库连接池。
|
||||
@@ -157,7 +166,7 @@
|
||||
|
||||
### Requirement: 迁移文件管理
|
||||
|
||||
迁移文件 SHALL 版本化管理。
|
||||
迁移文件 SHALL 版本化管理,并 SHALL 在构建发布产物时作为运行时迁移资源打包。
|
||||
|
||||
#### Scenario: 迁移文件命名
|
||||
|
||||
@@ -171,3 +180,10 @@
|
||||
- **WHEN** 创建迁移文件
|
||||
- **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/` 或 `migrations/mysql/`)
|
||||
- **THEN** SHALL 提交到版本控制系统
|
||||
|
||||
#### Scenario: 迁移文件打包
|
||||
|
||||
- **WHEN** 构建 server 或 desktop 二进制
|
||||
- **THEN** SQLite 和 MySQL 迁移文件 SHALL 被作为运行时迁移资源打包进二进制或等效发布资源
|
||||
- **THEN** 应用启动迁移 SHALL 使用该打包资源
|
||||
- **THEN** backend Makefile 的 goose CLI 迁移命令 MAY 继续使用文件系统中的 `migrations/<dialect>/` 目录
|
||||
|
||||
@@ -152,9 +152,9 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
#### Scenario: Windows 构建
|
||||
|
||||
- **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 包含 `1.2.3`、`windows` 和对应架构标识
|
||||
- **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3`、`windows` 和 `amd64`
|
||||
|
||||
#### Scenario: Linux 构建
|
||||
|
||||
@@ -316,3 +316,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
#### Scenario: 多行文本处理
|
||||
- **WHEN** 对话框消息包含换行符 `\n`
|
||||
- **THEN** AppleScript 正确显示多行文本
|
||||
|
||||
### Requirement: 桌面应用打包迁移资源
|
||||
|
||||
桌面应用 SHALL 在打包安装后仍能访问数据库迁移资源,并 SHALL 在首次启动时完成数据库初始化和迁移。
|
||||
|
||||
#### Scenario: 打包安装后首次启动执行迁移
|
||||
|
||||
- **WHEN** 用户从 macOS DMG 安装并首次启动 `Nex.app`
|
||||
- **THEN** 系统 SHALL 初始化默认配置和数据库
|
||||
- **THEN** 系统 SHALL 使用打包在应用内的迁移资源执行 SQLite 迁移
|
||||
- **THEN** 系统 SHALL NOT 尝试访问构建机源码路径或仓库源码路径
|
||||
- **THEN** 系统 SHALL 成功启动后端服务、托盘和管理界面
|
||||
|
||||
#### Scenario: .app 包含运行时必需迁移资源
|
||||
|
||||
- **WHEN** 执行 macOS 桌面打包脚本
|
||||
- **THEN** `Nex.app` SHALL 包含启动后端服务所需的数据库迁移资源
|
||||
- **THEN** 迁移资源 SHALL 随应用移动到任意安装位置后仍可用
|
||||
- **THEN** `.app` SHALL NOT 依赖构建目录、源码目录或 GitHub Actions runner 路径
|
||||
|
||||
#### Scenario: DMG 安装后运行时资源完整
|
||||
|
||||
- **WHEN** 用户从 DMG 将 `Nex.app` 拖入 `/Applications` 并启动
|
||||
- **THEN** 应用 SHALL 能访问数据库迁移资源
|
||||
- **THEN** 应用 SHALL NOT 因 `migrations/sqlite` 或 `migrations/mysql` 文件系统目录不存在而启动失败
|
||||
|
||||
@@ -125,6 +125,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
- **WHEN** 供应商列表为空
|
||||
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加"
|
||||
|
||||
#### Scenario: Base URL 一键复制
|
||||
|
||||
- **WHEN** 供应商表格渲染 Base URL 列
|
||||
- **THEN** Base URL 文本右侧 SHALL 显示复制图标按钮
|
||||
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text` 的 `copyable` 属性
|
||||
- **WHEN** 用户点击 Base URL 的复制按钮
|
||||
- **THEN** 系统 SHALL 将完整 Base URL 写入剪贴板
|
||||
- **THEN** 系统 SHALL 显示 `已复制 Base URL` 成功提示
|
||||
- **THEN** 当 Base URL 为空时,复制按钮 SHALL 禁用
|
||||
|
||||
#### Scenario: API Key 一键复制
|
||||
|
||||
- **WHEN** 供应商表格渲染 API Key 列
|
||||
- **THEN** API Key 文本右侧 SHALL 显示复制图标按钮
|
||||
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text` 的 `copyable` 属性
|
||||
- **WHEN** 用户点击 API Key 的复制按钮
|
||||
- **THEN** 系统 SHALL 将完整 API Key 写入剪贴板
|
||||
- **THEN** 系统 SHALL 显示 `已复制 API Key` 成功提示
|
||||
- **THEN** 当 API Key 为空时,复制按钮 SHALL 禁用
|
||||
|
||||
#### Scenario: 添加新供应商
|
||||
|
||||
- **WHEN** 用户点击"添加供应商"按钮
|
||||
@@ -184,6 +204,16 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
- **WHEN** 模型列表为空
|
||||
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加"
|
||||
|
||||
#### Scenario: 统一模型 ID 一键复制
|
||||
|
||||
- **WHEN** 模型表格渲染统一模型 ID 列
|
||||
- **THEN** 统一模型 ID 文本右侧 SHALL 显示复制图标按钮
|
||||
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text` 的 `copyable` 属性
|
||||
- **WHEN** 用户点击统一模型 ID 的复制按钮
|
||||
- **THEN** 系统 SHALL 将完整统一模型 ID 写入剪贴板
|
||||
- **THEN** 系统 SHALL 显示 `已复制统一模型 ID` 成功提示
|
||||
- **THEN** 当统一模型 ID 为空时,复制按钮 SHALL 禁用
|
||||
|
||||
#### Scenario: 为供应商添加模型
|
||||
|
||||
- **WHEN** 用户在展开行中点击"添加模型"
|
||||
|
||||
167
openspec/specs/git-hooks/spec.md
Normal file
167
openspec/specs/git-hooks/spec.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# git-hooks
|
||||
|
||||
## Purpose
|
||||
|
||||
定义仓库原生 Git hooks 的安装、校验、测试与跨平台执行规则,确保提交前快速检查和提交信息格式校验符合项目规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: pre-commit hook 快速检查
|
||||
|
||||
pre-commit hook SHALL 在 `git commit` 执行前对 staged files 进行快速检查,仅检查本次提交涉及的文件。
|
||||
|
||||
#### Scenario: 无 Go 和前端文件变更时跳过
|
||||
|
||||
- **WHEN** staged files 中既无 `.go` 文件也无 `.ts`/`.tsx`/`.scss` 文件
|
||||
- **THEN** pre-commit hook SHALL 直接通过,不运行任何 linter
|
||||
|
||||
#### Scenario: 冲突标记检测
|
||||
|
||||
- **WHEN** staged files 中包含 `<<<<<<<`、`=======` 或 `>>>>>>>` 冲突标记
|
||||
- **THEN** pre-commit hook SHALL 报告错误并列出包含冲突的文件名
|
||||
- **THEN** commit SHALL 被阻止
|
||||
|
||||
#### Scenario: Go 文件 lint 检查
|
||||
|
||||
- **WHEN** staged files 中包含 `.go` 文件
|
||||
- **THEN** pre-commit hook SHALL 对 staged `.go` 文件运行 `golangci-lint run`(复用 `backend/.golangci.yml` 配置)
|
||||
- **THEN** 若 lint 报告任何错误,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 前端文件 lint 检查
|
||||
|
||||
- **WHEN** staged files 中包含 `.ts` 或 `.tsx` 文件
|
||||
- **THEN** pre-commit hook SHALL 对 staged 前端文件运行 ESLint(复用 `frontend/eslint.config.js` 配置)
|
||||
- **THEN** 若 ESLint 报告任何错误,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 前端文件格式检查
|
||||
|
||||
- **WHEN** staged files 中包含 `.ts`、`.tsx` 或 `.scss` 文件
|
||||
- **THEN** pre-commit hook SHALL 对 staged 前端文件运行 Prettier 格式检查(复用 `frontend/.prettierrc` 配置)
|
||||
- **THEN** 若存在格式不符合规范的文件,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 大文件告警
|
||||
|
||||
- **WHEN** staged files 中存在超过 500KB 的文本文件
|
||||
- **THEN** pre-commit hook SHALL 输出警告信息(不阻止提交),提示检查是否误提交
|
||||
|
||||
#### Scenario: commit 被阻止时显示修复提示
|
||||
|
||||
- **WHEN** pre-commit hook 检查失败
|
||||
- **THEN** hook SHALL 输出明确的修复提示(如 `bun run fix`、手动解决冲突标记等)
|
||||
|
||||
### Requirement: commit-msg hook 校验提交信息格式
|
||||
|
||||
commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确保符合项目规范。提交描述 SHALL 使用中文;版本号、英文专有名词可与中文描述混用。
|
||||
|
||||
#### 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: add auth`
|
||||
- **THEN** commit-msg hook SHALL 报告错误,提示提交描述需使用中文
|
||||
- **THEN** 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: 添加供应商批量管理功能`)
|
||||
|
||||
### Requirement: hooks-install 安装命令
|
||||
|
||||
`make hooks-install` SHALL 将 `scripts/git-hooks/` 下的 hook 脚本安装到 `.git/hooks/`,不覆盖 Git LFS 管理的 hook。
|
||||
|
||||
#### Scenario: 安装 pre-commit 和 commit-msg
|
||||
|
||||
- **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** 两个文件 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` 是否存在且可执行
|
||||
- **THEN** SHALL 输出每个 hook 的安装状态
|
||||
|
||||
### 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 和 Windows(Git 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 调用项目已有工具链,不重复实现 hook 框架逻辑。commit-msg hook SHALL 在脚本内直接完成格式校验。
|
||||
|
||||
#### Scenario: Go lint 复用后端配置
|
||||
|
||||
- **WHEN** pre-commit 需要检查 Go 文件
|
||||
- **THEN** SHALL 调用 Makefile 逻辑,在 `backend/` 目录对 staged `.go` 文件运行 `go tool golangci-lint run`
|
||||
- **THEN** SHALL 复用 `backend/.golangci.yml` 中的 lint 配置
|
||||
|
||||
#### Scenario: 前端 lint 使用 staged 文件参数
|
||||
|
||||
- **WHEN** pre-commit 需要检查前端文件
|
||||
- **THEN** SHALL 调用 Makefile 逻辑,在 `frontend/` 目录对 staged 前端文件运行 ESLint 和 Prettier 的文件参数模式
|
||||
- **THEN** SHALL NOT 在 pre-commit 阶段运行全量 `bun run check`
|
||||
|
||||
#### Scenario: 终端直接调试
|
||||
|
||||
- **WHEN** 开发者执行 `make _hooks-pre-commit`
|
||||
- **THEN** SHALL 执行与 pre-commit hook 完全相同的检查逻辑
|
||||
- **THEN** 输出 SHALL 与 hook 触发时一致
|
||||
@@ -54,13 +54,13 @@
|
||||
|
||||
### Requirement: 数据库初始化公共包
|
||||
|
||||
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server` 和 `cmd/desktop` 共同调用。
|
||||
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server` 和 `cmd/desktop` 共同调用,并 SHALL 使用随应用构建产物打包的迁移资源执行运行时迁移。
|
||||
|
||||
#### Scenario: 公共包 Init 函数
|
||||
|
||||
- **WHEN** 调用 `database.Init(cfg, logger)`
|
||||
- **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接
|
||||
- **THEN** SHALL 执行对应方言的 goose 迁移
|
||||
- **THEN** SHALL 使用随应用构建产物打包的迁移资源执行对应方言的 goose 迁移
|
||||
- **THEN** SHALL 配置连接池参数
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA
|
||||
@@ -71,11 +71,20 @@
|
||||
- **WHEN** 调用 `database.Close(db)`
|
||||
- **THEN** SHALL 获取底层 `sql.DB` 并关闭连接
|
||||
|
||||
#### Scenario: 迁移目录选择
|
||||
#### Scenario: 迁移方言资源选择
|
||||
|
||||
- **WHEN** 执行迁移
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录,goose dialect 为 `mysql`
|
||||
- **WHEN** 执行运行时迁移
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时选择 SQLite 方言迁移资源,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时选择 MySQL 方言迁移资源,goose dialect 为 `mysql`
|
||||
- **THEN** 运行时迁移资源 SHALL 来源于打包资源而非源码目录
|
||||
- **THEN** SHALL 在方言子资源解析失败时返回明确错误并拒绝启动
|
||||
|
||||
#### Scenario: 公共包迁移资源来源
|
||||
|
||||
- **WHEN** 调用 `database.Init(cfg, logger)` 且当前工作目录不是仓库根目录或 `backend/` 目录
|
||||
- **THEN** SHALL 仍能解析并执行对应方言的迁移资源
|
||||
- **THEN** SHALL NOT 要求当前进程工作目录位于仓库根目录或 `backend/` 目录
|
||||
- **THEN** SHALL NOT 依赖 `runtime.Caller` 推导源码路径
|
||||
|
||||
### Requirement: MySQL 方言迁移文件
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_arm64.tar.gz`
|
||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_universal.tar.gz`
|
||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_amd64.zip`
|
||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_arm64.zip`
|
||||
|
||||
#### Scenario: web 发布构建
|
||||
|
||||
@@ -66,9 +65,7 @@
|
||||
- **WHEN** 发布流水线执行 Windows desktop 发布构建
|
||||
- **THEN** 系统 SHALL 在包含对应架构 MSYS2/MinGW 或等价 CGO 工具链的环境中构建
|
||||
- **AND** Windows amd64 desktop 发布构建 SHALL 在 `windows-latest` runner 上的 MSYS2 MINGW64 环境中执行
|
||||
- **AND** Windows arm64 desktop 发布构建 SHALL 在 `windows-11-arm` runner 上的 MSYS2 CLANGARM64 环境中执行
|
||||
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_amd64.zip`
|
||||
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_arm64.zip`
|
||||
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
|
||||
|
||||
#### Scenario: macOS desktop 发布构建
|
||||
@@ -120,22 +117,6 @@
|
||||
- **THEN** 发布流水线 SHALL 在正式构建前失败
|
||||
- **AND** 系统 SHALL 在日志中标识缺失的工具链名称
|
||||
|
||||
### Requirement: Windows arm64 CGO 编译器指定
|
||||
|
||||
系统 SHALL 在 Windows arm64 发布构建中显式指定 `CC=clang` 和 `CXX=clang++` 环境变量,确保 Go cgo 在 MSYS2 CLANGARM64 环境下使用正确的 C 编译器进行 `windows/arm64` 交叉编译。
|
||||
|
||||
#### Scenario: Windows arm64 构建使用 clang
|
||||
|
||||
- **WHEN** 发布流水线在 `windows-11-arm` runner 上执行 Windows arm64 构建步骤
|
||||
- **THEN** 构建步骤 SHALL 将 `CC=clang` 和 `CXX=clang++` 注入 go build 环境
|
||||
- **AND** Go cgo SHALL 使用 `clang` 编译 `runtime/cgo` 等 CGO 组件
|
||||
|
||||
#### Scenario: Windows amd64 构建保持 gcc
|
||||
|
||||
- **WHEN** 发布流水线在 `windows-latest` runner 上执行 Windows amd64 构建步骤
|
||||
- **THEN** 构建步骤 MAY 显式指定 `CC=gcc` 和 `CXX=g++` 或依赖 Go 默认编译器探测
|
||||
- **AND** 显式指定的 `gcc` SHALL 等价于 MSYS2 MINGW64 默认 C 编译器(`x86_64-w64-mingw32-gcc`)
|
||||
|
||||
### Requirement: 发布流水线 LFS 资产拉取
|
||||
|
||||
发布流水线 SHALL 在所有会 checkout 仓库并参与版本校验、web 构建、server 构建或 desktop 构建的 job 中拉取 Git LFS 真实文件,确保发布构建读取到真实二进制资产而非 LFS pointer 文本。
|
||||
@@ -195,7 +176,7 @@
|
||||
- **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`
|
||||
- **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 资产命名
|
||||
|
||||
@@ -207,7 +188,7 @@
|
||||
|
||||
- **WHEN** 当前发布版本为 `1.2.3`
|
||||
- **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 平台字段 SHALL 使用 `macos` 而非 `darwin`
|
||||
|
||||
@@ -239,3 +220,32 @@
|
||||
- **WHEN** 任一构建 job 尝试上传 release artifact 但匹配不到目标文件
|
||||
- **THEN** 该 job SHALL 失败
|
||||
- **AND** Draft Release 组装 SHALL NOT 继续发布不完整资产集合
|
||||
|
||||
### Requirement: 发布产物运行时资源完整性
|
||||
|
||||
发布流水线 SHALL 确保 server 和 desktop 发布产物包含运行时启动所需的数据库迁移资源,且 SHALL NOT 依赖 CI runner 的源码路径。
|
||||
|
||||
#### Scenario: desktop 发布产物包含迁移资源
|
||||
|
||||
- **WHEN** 发布流水线构建 desktop 发布资产
|
||||
- **THEN** 生成的 desktop 二进制或应用包 SHALL 包含 SQLite 和 MySQL 迁移资源
|
||||
- **THEN** macOS `.app`、`.zip` 和 `.dmg` 安装后 SHALL 不需要仓库源码目录即可执行启动迁移
|
||||
|
||||
#### Scenario: server 发布产物包含迁移资源
|
||||
|
||||
- **WHEN** 发布流水线构建 server 发布资产
|
||||
- **THEN** 生成的 server 二进制 SHALL 包含 SQLite 和 MySQL 迁移资源
|
||||
- **THEN** server 发布资产 SHALL 不需要仓库源码目录即可执行启动迁移
|
||||
|
||||
#### Scenario: 发布产物不泄漏构建机迁移路径
|
||||
|
||||
- **WHEN** 发布流水线完成 server 或 desktop 构建
|
||||
- **THEN** 构建产物 SHALL NOT 在运行时使用 `/Users/runner/work/.../backend/migrations/...` 作为迁移目录
|
||||
- **THEN** 若检测到运行时迁移路径依赖 CI runner 源码路径,发布构建 SHALL 失败
|
||||
|
||||
#### Scenario: 发布构建迁移资源验证
|
||||
|
||||
- **WHEN** 发布流水线执行 release 构建验证
|
||||
- **THEN** 验证 SHALL 覆盖迁移资源可用性
|
||||
- **THEN** 验证 SHALL 覆盖安装包内应用在无源码目录环境下可解析迁移资源
|
||||
- **THEN** 验证 MAY 通过 Go 测试或轻量资源自检完成,不要求启动图形托盘界面
|
||||
|
||||
@@ -279,3 +279,27 @@
|
||||
- **WHEN** mockgen 生成的 mock 就绪
|
||||
- **THEN** handler 测试中的手写 mock SHALL 被替换为生成的 mock
|
||||
- **THEN** 所有测试 SHALL 继续通过,行为不变
|
||||
|
||||
### Requirement: 运行时迁移资源测试覆盖
|
||||
|
||||
系统 SHALL 覆盖打包迁移资源解析和启动迁移回归场景,确保发布产物不依赖源码迁移目录。
|
||||
|
||||
#### Scenario: 运行时迁移资源解析测试
|
||||
|
||||
- **WHEN** 运行 database 包单元测试
|
||||
- **THEN** SHALL 验证 `database.Init` 在当前工作目录不是仓库根目录或 `backend/` 目录时仍能执行迁移
|
||||
- **THEN** SHALL 验证迁移资源不依赖 `runtime.Caller` 推导的源码路径
|
||||
- **THEN** SHALL 覆盖 SQLite 方言迁移资源解析
|
||||
|
||||
#### Scenario: 双方言迁移资源选择测试
|
||||
|
||||
- **WHEN** 运行迁移资源选择相关测试
|
||||
- **THEN** SHALL 验证 SQLite 方言资源可被解析
|
||||
- **THEN** SHALL 验证 MySQL 方言资源可被解析
|
||||
- **THEN** SHALL 验证未知或非法 driver 不会被静默映射到错误方言资源
|
||||
|
||||
#### Scenario: desktop 打包迁移资源测试
|
||||
|
||||
- **WHEN** 运行 desktop 专属测试
|
||||
- **THEN** SHALL 验证 desktop 模式启动数据库迁移时使用打包迁移资源
|
||||
- **THEN** SHALL 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录
|
||||
|
||||
@@ -90,22 +90,34 @@
|
||||
|
||||
### Requirement: 版本升迁 Makefile 编排
|
||||
|
||||
`make version-bump` SHALL 编排完整的版本升迁流程:工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch`。
|
||||
`make version-bump` SHALL 编排完整的版本升迁流程:全量 lint 检查 → 全量单元测试 → 工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch`。lint/test 前置检查 SHALL NOT 替代工作区干净检查。
|
||||
|
||||
#### Scenario: 完整升迁流程
|
||||
|
||||
- **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0`
|
||||
- **THEN** Makefile SHALL 依次执行:工作区检查 → `version bump minor` → `git add VERSION frontend/` → `git commit -m "chore: 版本升迁 v0.2.0"` → `git tag v0.2.0`
|
||||
- **THEN** Makefile SHALL 依次执行:`make lint` → `make test` → 工作区检查 → `version bump minor` → `git add VERSION frontend/` → `git commit -m "chore: 版本升迁 v0.2.0"` → `git tag v0.2.0`
|
||||
|
||||
#### Scenario: 不传 BUMP 默认 patch
|
||||
|
||||
- **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0`
|
||||
- **THEN** Makefile SHALL 等效于执行 `make version-bump BUMP=patch`,将版本更新为 `0.1.1`
|
||||
|
||||
#### Scenario: lint 失败时终止
|
||||
|
||||
- **WHEN** 执行 `make version-bump`,但 `make lint` 报告错误
|
||||
- **THEN** Makefile SHALL 以非零退出码失败,SHALL NOT 执行 `version bump`、git commit、git tag
|
||||
- **THEN** SHALL 输出错误信息提示修复 lint 问题后重试
|
||||
|
||||
#### Scenario: test 失败时终止
|
||||
|
||||
- **WHEN** 执行 `make version-bump`,但 `make test` 报告测试失败
|
||||
- **THEN** Makefile SHALL 以非零退出码失败,SHALL NOT 执行 `version bump`、git commit、git tag
|
||||
- **THEN** SHALL 输出错误信息提示修复测试失败后重试
|
||||
|
||||
#### Scenario: 工作区不干净
|
||||
|
||||
- **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动
|
||||
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或暂存改动
|
||||
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或清理改动
|
||||
|
||||
#### Scenario: 支持指定版本号
|
||||
|
||||
|
||||
42
scripts/git-hooks/commit-msg
Executable file
42
scripts/git-hooks/commit-msg
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
MSG_FILE=$1
|
||||
|
||||
if [ ! -f "$MSG_FILE" ]; then
|
||||
printf '%s\n' '提交信息文件不存在。' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS= read -r FIRST_LINE < "$MSG_FILE" || FIRST_LINE=
|
||||
|
||||
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
|
||||
|
||||
DESCRIPTION=${FIRST_LINE#*: }
|
||||
if printf '%s\n' "$DESCRIPTION" | LC_ALL=C grep -Eq '^[ -~]+$'; then
|
||||
printf '%s\n' '提交描述需使用中文。' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ${#FIRST_LINE} -gt 72 ]; then
|
||||
printf '%s\n' '警告: 提交信息首行超过 72 个字符,建议保持简短。' >&2
|
||||
fi
|
||||
12
scripts/git-hooks/pre-commit
Executable file
12
scripts/git-hooks/pre-commit
Executable 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
|
||||
134
scripts/git-hooks/test-hooks.sh
Executable file
134
scripts/git-hooks/test-hooks.sh
Executable file
@@ -0,0 +1,134 @@
|
||||
#!/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
|
||||
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"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
MSG_FILE=$TMP_DIR/commit-msg.txt
|
||||
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_failure 'commit-msg rejects English-only description' 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"
|
||||
|
||||
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' 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' 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' 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
|
||||
|
||||
cat > docs/hook-conflict-fixture.md <<'EOF'
|
||||
<<<<<<< HEAD
|
||||
conflict
|
||||
=======
|
||||
other
|
||||
>>>>>>> branch
|
||||
EOF
|
||||
expect_failure 'pre-commit rejects conflict markers' run_precommit_for docs/hook-conflict-fixture.md
|
||||
rm -f docs/hook-conflict-fixture.md
|
||||
|
||||
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
|
||||
@@ -288,7 +288,6 @@ func serverAssetName(version, platform, arch, format string) (string, error) {
|
||||
{platform: "macos", arch: "arm64", format: "tar.gz"},
|
||||
{platform: "macos", arch: "universal", format: "tar.gz"},
|
||||
{platform: "windows", arch: "amd64", format: "zip"},
|
||||
{platform: "windows", arch: "arm64", format: "zip"},
|
||||
}) {
|
||||
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: "dmg"},
|
||||
{platform: "windows", arch: "amd64", format: "zip"},
|
||||
{platform: "windows", arch: "arm64", format: "zip"},
|
||||
}) {
|
||||
return "", fmt.Errorf("不支持的 desktop 资产目标 %s/%s/%s", platform, arch, format)
|
||||
}
|
||||
|
||||
@@ -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 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 arm64", "server", "windows", "arm64", "zip", "nex-server_1.2.3_windows_arm64.zip"},
|
||||
{"web", "web", "", "", "tar.gz", "nex-web_1.2.3.tar.gz"},
|
||||
{"desktop linux amd64 tar", "desktop", "linux", "amd64", "tar.gz", "nex-desktop_1.2.3_linux_amd64.tar.gz"},
|
||||
{"desktop linux amd64 appimage", "desktop", "linux", "amd64", "AppImage", "nex-desktop_1.2.3_linux_amd64.AppImage"},
|
||||
@@ -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 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 arm64", "desktop", "windows", "arm64", "zip", "nex-desktop_1.2.3_windows_arm64.zip"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
||||
Reference in New Issue
Block a user