diff --git a/README.md b/README.md index bdb8b6b..fd3a80f 100644 --- a/README.md +++ b/README.md @@ -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 依赖。 +server 和 desktop 发布产物自包含运行时数据库迁移资源(通过 `go:embed` 嵌入二进制),安装后首次启动不再依赖仓库源码目录。 + ## API 接口 ### 代理接口(对外部应用) diff --git a/backend/README.md b/backend/README.md index 651ed8b..a54667f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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//` 目录,运行时嵌入资源与文件系统目录共享同一批 SQL 文件。 + ```bash # 使用 Makefile make migrate-up DB_DSN=~/.nex/config.db diff --git a/backend/cmd/desktop/migration_test.go b/backend/cmd/desktop/migration_test.go new file mode 100644 index 0000000..d6a2d5c --- /dev/null +++ b/backend/cmd/desktop/migration_test.go @@ -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) +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index ddfca2c..c851b6a 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -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) diff --git a/backend/internal/database/database_test.go b/backend/internal/database/database_test.go index 571dcd5..8614eb8 100644 --- a/backend/internal/database/database_test.go +++ b/backend/internal/database/database_test.go @@ -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 +} diff --git a/backend/internal/database/embedded_migration_test.go b/backend/internal/database/embedded_migration_test.go new file mode 100644 index 0000000..d5a9463 --- /dev/null +++ b/backend/internal/database/embedded_migration_test.go @@ -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()) + } +} diff --git a/backend/migrations/embed.go b/backend/migrations/embed.go new file mode 100644 index 0000000..5c02659 --- /dev/null +++ b/backend/migrations/embed.go @@ -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) + } +} diff --git a/openspec/specs/database-migration/spec.md b/openspec/specs/database-migration/spec.md index fe22417..56c94ba 100644 --- a/openspec/specs/database-migration/spec.md +++ b/openspec/specs/database-migration/spec.md @@ -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//` 目录 diff --git a/openspec/specs/desktop-app/spec.md b/openspec/specs/desktop-app/spec.md index a5e55b8..f09b1b1 100644 --- a/openspec/specs/desktop-app/spec.md +++ b/openspec/specs/desktop-app/spec.md @@ -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` 文件系统目录不存在而启动失败 diff --git a/openspec/specs/mysql-driver/spec.md b/openspec/specs/mysql-driver/spec.md index dbcb979..14550e4 100644 --- a/openspec/specs/mysql-driver/spec.md +++ b/openspec/specs/mysql-driver/spec.md @@ -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 方言迁移文件 diff --git a/openspec/specs/release-pipeline/spec.md b/openspec/specs/release-pipeline/spec.md index f611a66..f7674d7 100644 --- a/openspec/specs/release-pipeline/spec.md +++ b/openspec/specs/release-pipeline/spec.md @@ -220,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 测试或轻量资源自检完成,不要求启动图形托盘界面 diff --git a/openspec/specs/test-coverage/spec.md b/openspec/specs/test-coverage/spec.md index 2b02bd1..67117d7 100644 --- a/openspec/specs/test-coverage/spec.md +++ b/openspec/specs/test-coverage/spec.md @@ -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 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录