Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 598e2acb7e | |||
| 4870d29638 | |||
| 8600a39b6c |
22
Makefile
22
Makefile
@@ -98,7 +98,9 @@ _hooks-pre-commit:
|
|||||||
printf 'No staged files to check\n'; \
|
printf 'No staged files to check\n'; \
|
||||||
exit 0; \
|
exit 0; \
|
||||||
fi; \
|
fi; \
|
||||||
printf '%s\n' "$$staged_files" | while IFS= read -r file; do \
|
backend_pkgs=''; \
|
||||||
|
versionctl_pkgs=''; \
|
||||||
|
for file in $$staged_files; do \
|
||||||
[ -n "$$file" ] || continue; \
|
[ -n "$$file" ] || continue; \
|
||||||
case "$$file" in scripts/git-hooks/*) continue ;; esac; \
|
case "$$file" in scripts/git-hooks/*) continue ;; esac; \
|
||||||
if git show ":$$file" 2>/dev/null | grep -Eq '^(<<<<<<<|=======|>>>>>>>)'; then \
|
if git show ":$$file" 2>/dev/null | grep -Eq '^(<<<<<<<|=======|>>>>>>>)'; then \
|
||||||
@@ -114,14 +116,12 @@ _hooks-pre-commit:
|
|||||||
fi; \
|
fi; \
|
||||||
case "$$file" in \
|
case "$$file" in \
|
||||||
backend/*.go) \
|
backend/*.go) \
|
||||||
rel=$${file#backend/}; \
|
dir=$$(dirname "$${file#backend/}"); \
|
||||||
printf 'Go lint: backend/%s\n' "$$rel"; \
|
case " $$backend_pkgs " in *" $$dir "*) ;; *) backend_pkgs="$$backend_pkgs $$dir" ;; esac; \
|
||||||
(cd backend && go tool golangci-lint run "$$rel"); \
|
|
||||||
;; \
|
;; \
|
||||||
versionctl/*.go) \
|
versionctl/*.go) \
|
||||||
rel=$${file#versionctl/}; \
|
dir=$$(dirname "$${file#versionctl/}"); \
|
||||||
printf 'Go lint: versionctl/%s\n' "$$rel"; \
|
case " $$versionctl_pkgs " in *" $$dir "*) ;; *) versionctl_pkgs="$$versionctl_pkgs $$dir" ;; esac; \
|
||||||
(cd versionctl && go tool golangci-lint run "$$rel"); \
|
|
||||||
;; \
|
;; \
|
||||||
frontend/*.ts|frontend/*.tsx) \
|
frontend/*.ts|frontend/*.tsx) \
|
||||||
rel=$${file#frontend/}; \
|
rel=$${file#frontend/}; \
|
||||||
@@ -137,6 +137,14 @@ _hooks-pre-commit:
|
|||||||
;; \
|
;; \
|
||||||
esac; \
|
esac; \
|
||||||
done; \
|
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'
|
printf 'Pre-commit checks passed\n'
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ make server-build
|
|||||||
|
|
||||||
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 接口
|
||||||
|
|
||||||
### 代理接口(对外部应用)
|
### 代理接口(对外部应用)
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ backend/
|
|||||||
│ └── validator/ # 验证器
|
│ └── validator/ # 验证器
|
||||||
│ └── validator.go
|
│ └── validator.go
|
||||||
├── migrations/ # 数据库迁移
|
├── migrations/ # 数据库迁移
|
||||||
|
│ ├── embed.go # go:embed 迁移资源入口
|
||||||
|
│ ├── sqlite/
|
||||||
|
│ │ └── 20260421000001_initial_schema.sql
|
||||||
|
│ └── mysql/
|
||||||
│ └── 20260421000001_initial_schema.sql
|
│ └── 20260421000001_initial_schema.sql
|
||||||
├── tests/ # 集成测试
|
├── tests/ # 集成测试
|
||||||
│ ├── helpers.go # 测试辅助函数
|
│ ├── helpers.go # 测试辅助函数
|
||||||
@@ -456,6 +460,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
|
||||||
|
|||||||
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
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,13 +188,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
|
||||||
|
|
||||||
### 用量统计
|
### 用量统计
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,30 @@ 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'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '模型名称',
|
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 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,28 @@ 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'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '协议',
|
title: '协议',
|
||||||
@@ -47,7 +68,28 @@ 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'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
|
|||||||
@@ -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>/` 目录
|
||||||
|
|||||||
@@ -316,3 +316,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` 文件系统目录不存在而启动失败
|
||||||
|
|||||||
@@ -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** 用户在展开行中点击"添加模型"
|
||||||
|
|||||||
@@ -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 方言迁移文件
|
||||||
|
|
||||||
|
|||||||
@@ -220,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 测试或轻量资源自检完成,不要求启动图形托盘界面
|
||||||
|
|||||||
@@ -279,3 +279,27 @@
|
|||||||
- **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 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录
|
||||||
|
|||||||
Reference in New Issue
Block a user