// Package migrate 数据迁移端到端测试(真库,P1-04 验收:重跑幂等 + 事务边界正确)。 // // DSN 纪律(对齐 identity e2e): // - 三个 DSN 走环境变量,**不经测试代码硬编码、不落盘**: // UNIFIED_MIGRATE_TEACHING_DSN 教学库 yuansiwei_i(只读源) // UNIFIED_MIGRATE_FINANCE_DSN 教务库 finance_bk(只读源) // UNIFIED_MIGRATE_TARGET_DSN 目标库(读写,建议独立测试库如 e2e_migrate) // - 任一缺失 → t.Skip(CI/无库环境 go test ./... 不受影响)。 // // 验证链路(TestMigrateE2E): // 1. EnsureTables:目标库建 12 表(AutoMigrate 幂等); // 2. 首次 run:读 legacy 全量 → 归一 → 置信分级 → 写 5 表 + 机构/校区; // 3. verify:唯一键完整性 + 引用完整性(phone 撞号/unionid 重复/孤儿 均应为 0); // 4. 重跑 run(幂等):ULID 复用(读 t_legacy_mapping)→ UsersCreated 恒 0、 // 全部 Skipped;身份/成员/锚点经唯一键去重,量趋 0; // 5. 清理:drop 12 表(回到无表态,不污染其他 e2e)。 // // 跑法(终端手动,密码不经 agent): // // export UNIFIED_MIGRATE_TEACHING_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Local' // export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local' // export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/e2e_migrate?charset=utf8mb4&parseTime=True&loc=Local' // go test ./internal/migrate/ -run TestMigrateE2E -v package migrate import ( "context" "os" "testing" "gorm.io/gorm" ) // migrateDSNsOrSkip 读三个迁移 DSN,任一缺失则跳过测试。 func migrateDSNsOrSkip(t *testing.T) (teaching, finance, target string) { teaching = os.Getenv("UNIFIED_MIGRATE_TEACHING_DSN") finance = os.Getenv("UNIFIED_MIGRATE_FINANCE_DSN") target = os.Getenv("UNIFIED_MIGRATE_TARGET_DSN") if teaching == "" || finance == "" || target == "" { t.Skip("未设置 UNIFIED_MIGRATE_{TEACHING,FINANCE,TARGET}_DSN,跳过迁移端到端测试") } return } // dropAllTables 清理目标库 12 表(回到无表态,隔离其他 e2e)。 func dropAllTables(t *testing.T, gdb *gorm.DB) { t.Helper() tables := []string{ "t_membership", "t_guardian_link", "t_role_permission", "t_identity", "t_wechat_union", "t_legacy_mapping", "t_audit_log", "t_user", "t_campus", "t_institution", "t_permission", "t_role", } for _, tb := range tables { // 不建物理 FK,drop 顺序无关;忽略"表不存在"错误(首次跑前无表) _ = gdb.Migrator().DropTable(tb) } } // TestMigrateE2E 迁移端到端全链路(首次 run + verify 唯一键 + 重跑幂等)。 func TestMigrateE2E(t *testing.T) { teaching, finance, target := migrateDSNsOrSkip(t) ctx := context.Background() // 目标库连接 + 清理旧表(幂等起点) targetDB, err := gorm.Open(gormMySQLTarget(target), gormMigrateCfg()) if err != nil { t.Fatalf("目标库连接失败: %v", err) } defer func() { if sqlDB, err := targetDB.DB(); err == nil { _ = sqlDB.Close() } }() dropAllTables(t, targetDB) // ---- ① 首次 run(EnsureTables + 单事务写 5 表)---- runner, err := NewRunner(Options{ TeachingDSN: teaching, FinanceDSN: finance, TargetDSN: target, DryRun: false, }) if err != nil { t.Fatalf("装配 Runner 失败: %v", err) } if err := runner.EnsureTables(ctx); err != nil { t.Fatalf("EnsureTables 失败: %v", err) } first, err := runner.Run(ctx) if err != nil { t.Fatalf("首次 run 失败: %v", err) } _ = runner.Close() // 首次 run 断言:用户全量新建 if first.UsersCreated != first.UsersTotal { t.Errorf("首次 run UsersCreated=%d ≠ UsersTotal=%d(应有少量 username 冲突跳过)", first.UsersCreated, first.UsersTotal) } if first.UsersTotal <= 0 { t.Fatalf("UsersTotal=0(legacy t_user 读取异常)") } t.Logf("首次 run:用户 %d / 机构 %d / 校区 %d / 身份(phone=%d wechat=%d) / 锚点 %d / 成员 %d / 映射 %d", first.UsersCreated, first.InstitutionsCreated, first.CampusesCreated, first.IdentitiesPhone, first.IdentitiesWechat, first.WechatUnionCreated, first.MembershipsCreated, first.LegacyMappingsCreated) // ---- ② verify:唯一键 + 引用完整性 ---- verifier, err := NewVerifier(target) if err != nil { t.Fatalf("装配 Verifier 失败: %v", err) } vrep, err := verifier.Verify(ctx) if err != nil { t.Fatalf("verify 失败: %v", err) } if !vrep.OK() { t.Errorf("verify 未通过(唯一键/引用完整性违规):\n%s", vrep.String()) } _ = verifier.Close() // ---- ③ 重跑 run(幂等断言:ULID 复用 → 0 新建)---- runner2, err := NewRunner(Options{ TeachingDSN: teaching, FinanceDSN: finance, TargetDSN: target, DryRun: false, }) if err != nil { t.Fatalf("装配 Runner(重跑) 失败: %v", err) } second, err := runner2.Run(ctx) if err != nil { t.Fatalf("重跑 run 失败: %v", err) } _ = runner2.Close() // 幂等断言:重跑时所有 legacy id 已有 ULID 映射 → t_user 0 新建、全 Skipped if second.UsersCreated != 0 { t.Errorf("幂等违反:重跑 UsersCreated=%d(期望 0,ULID 应复用)", second.UsersCreated) } if second.UsersSkipped != first.UsersTotal { t.Errorf("幂等违反:重跑 UsersSkipped=%d(期望 %d=首次全量)", second.UsersSkipped, first.UsersTotal) } // 成员/映射重跑也应 0 新建(唯一键去重) if second.MembershipsCreated != 0 { t.Errorf("幂等违反:重跑 MembershipsCreated=%d(期望 0)", second.MembershipsCreated) } if second.LegacyMappingsCreated != 0 { t.Errorf("幂等违反:重跑 LegacyMappingsCreated=%d(期望 0)", second.LegacyMappingsCreated) } t.Logf("重跑 run:UsersCreated=%d UsersSkipped=%d MembershipsCreated=%d(幂等验证)", second.UsersCreated, second.UsersSkipped, second.MembershipsCreated) // ---- ④ 清理 ---- dropAllTables(t, targetDB) }