// academic 27 表 **真库** AutoMigrate + information_schema 逐列核对(默认 skip)。 // // 运行方式(仅 dev 环境,且已获得授权时执行): // // ACADEMIC_MIGRATE_CHECK=1 go test ./internal/academic/model/ -run TestDevDBAutoMigrate -v // // 前置:`config/dev.json` 存在(DSN 指向 dev 库)。 // 未设置 ACADEMIC_MIGRATE_CHECK 时本测试直接 skip,保证 `go test ./...` // 在无数据库的 CI/容器环境恒绿(与 internal/migrate 的 e2e 门禁同一纪律)。 // // 核对口径(AC-01 验收①):对 27 表逐列比对 `information_schema.COLUMNS` 的 // COLUMN_NAME / COLUMN_TYPE(含 size/precision)/ IS_NULLABLE 与 DDL fixture 一致。 package model import ( "os" "strings" "testing" "yuansiwei/unified-backend/internal/infra/config" infradb "yuansiwei/unified-backend/internal/infra/db" ) // TestDevDBAutoMigrate 对 dev 库执行 AutoMigrate 并用 information_schema 逐列核对(需显式开启)。 func TestDevDBAutoMigrate(t *testing.T) { if os.Getenv("ACADEMIC_MIGRATE_CHECK") != "1" { t.Skip("跳过真库核对:设置 ACADEMIC_MIGRATE_CHECK=1 且 config/dev.json 指向 dev 库时执行") } chdirRepoRoot(t) // config.Load 按仓库根相对路径读 config/dev.json cfg, err := config.Load("dev") if err != nil { t.Skipf("config.Load(dev) 失败,跳过真库核对: %v", err) } gdb, err := infradb.NewDB(cfg.DB) if err != nil { t.Skipf("连接 dev 库失败,跳过真库核对: %v", err) } t.Cleanup(func() { _ = infradb.Close(gdb) }) // 1) AutoMigrate 27 表(幂等:已存在则只补缺列,不删列) if err := infradb.Migrate(gdb, AllTables()...); err != nil { t.Fatalf("AutoMigrate 27 表失败: %v", err) } // 2) 读 information_schema 实际列 type colRow struct { TableName string `gorm:"column:TABLE_NAME"` ColumnName string `gorm:"column:COLUMN_NAME"` ColumnType string `gorm:"column:COLUMN_TYPE"` IsNullable string `gorm:"column:IS_NULLABLE"` } ddl := parseDDL(t, ddlSQL) names := make([]any, 0, len(ddl)) for table := range ddl { names = append(names, table) } var rows []colRow if err := gdb.Raw( `SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ?`, names, ).Scan(&rows).Error; err != nil { t.Fatalf("查询 information_schema 失败: %v", err) } actual := make(map[string]map[string]colRow, len(ddl)) for _, r := range rows { if actual[r.TableName] == nil { actual[r.TableName] = make(map[string]colRow, 16) } actual[r.TableName][r.ColumnName] = r } checked := 0 for table, cols := range ddl { got, ok := actual[table] if !ok { t.Fatalf("dev 库缺少表 %s(AutoMigrate 未建?)", table) } if len(got) != len(cols) { t.Fatalf("表 %s 列数 = %d,DDL = %d", table, len(got), len(cols)) } for _, want := range cols { r, ok := got[want.Name] if !ok { t.Fatalf("表 %s 缺少列 %s", table, want.Name) } if gotType := strings.ToUpper(r.ColumnType); gotType != want.Type { t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", table, want.Name, gotType, want.Type) } wantNullable := "YES" if want.NotNull { wantNullable = "NO" } if r.IsNullable != wantNullable { t.Fatalf("表 %s 列 %s IS_NULLABLE = %s,DDL = %s", table, want.Name, r.IsNullable, wantNullable) } checked++ } } if checked < 250 { t.Fatalf("仅核对 %d 列,疑似漏表", checked) } // 3) 唯一键核对(7 张 biz_no + 幂等键 uk_request_type + 账户/报名/课酬唯一键) type idxRow struct { TableName string `gorm:"column:TABLE_NAME"` IndexName string `gorm:"column:INDEX_NAME"` } var idxRows []idxRow if err := gdb.Raw( `SELECT DISTINCT TABLE_NAME, INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND NON_UNIQUE = 0 AND TABLE_NAME IN ?`, names, ).Scan(&idxRows).Error; err != nil { t.Fatalf("查询 information_schema.STATISTICS 失败: %v", err) } gotIdx := make(map[string]bool, len(idxRows)) for _, r := range idxRows { gotIdx[r.TableName+"."+r.IndexName] = true } wantIdx := []string{ "t_user_balance_request.uk_biz_no", "t_order_pay.uk_biz_no", "t_payment_log.uk_biz_no", "t_courses_activity_request.uk_biz_no", "t_courses_student.uk_biz_no", "t_courses_record.uk_biz_no", "t_courses_record_student.uk_biz_no", "t_user_expense_log.uk_request_type", "t_user_expense_school.uk_user_inst_campus", "t_courses_activity_signed.uk_user_activity", "t_teacher_courses_percentage.uk_type_teacher", } for _, k := range wantIdx { if !gotIdx[k] { t.Fatalf("dev 库缺少唯一键 %s(迁移幂等/并发防线依赖它)", k) } } t.Logf("dev 库 27 表 %d 列 + %d 个唯一键核对通过", checked, len(wantIdx)) } // chdirRepoRoot 把工作目录切到仓库根(向上查找 go.mod),测试结束自动还原。 // // 原因:`config.Load` 以"仓库根相对路径"读取 config/dev.json,而 `go test` 的工作目录是包目录。 func chdirRepoRoot(t *testing.T) { t.Helper() wd, err := os.Getwd() if err != nil { t.Fatalf("Getwd 失败: %v", err) } dir := wd for { if _, err := os.Stat(dir + string(os.PathSeparator) + "go.mod"); err == nil { break } parent := dir[:strings.LastIndexAny(dir, `/\`)] if parent == "" || parent == dir { t.Skipf("未找到仓库根 go.mod,跳过真库核对(wd=%s)", wd) } dir = parent } if err := os.Chdir(dir); err != nil { t.Fatalf("切换到仓库根失败: %v", err) } t.Cleanup(func() { _ = os.Chdir(wd) }) }