// 集成测试:GORM model 与 core-model-ddl.sql 逐列对账(T2 验收判据)。 // // 运行前提(DSN 含密码,不入库,经环境变量注入): // // # Windows // set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local // go test ./internal/infra/db/ -run TestAutoMigrateReconcile -v // // 未设置 UNIFIED_TEST_DSN 时本测试自动跳过(CI / 无库环境 go test ./... 不受影响)。 // 测试目标库用 e2e_test(dev 账号有建表/DROP 表权限),**不污染** dev 主库 yuansiwei_2026; // 测后 DropTable 清理 9 张表(保留 e2e_test 库本身,供 W3 e2e 复用)。 // // 对账逻辑:AutoMigrate 按 model tag 建表 → 读 information_schema → 断言关键列的 // 类型/可空性/主键与 T1 DDL 一致。任一不符说明 model tag 写错(列名/类型/指针化漂移),fail。 package db import ( "fmt" "os" "strings" "testing" "gorm.io/gorm" identitymodel "yuansiwei/unified-backend/internal/identity/model" "yuansiwei/unified-backend/internal/infra/config" orgmodel "yuansiwei/unified-backend/internal/org/model" ) // expectedColumn 单列对账预期(表名 → 列名 → 预期)。 type expectedColumn struct { table string column string // typePrefix 期望 COLUMN_TYPE 前缀(如 "varchar(26)"/"bigint"/"int"/"tinyint(1)"/"datetime"); // 空串表示只核对可空性/主键、不核对类型。 typePrefix string // nullable 期望 IS_NULLABLE("YES"/"NO") nullable string // primaryKey 期望是否主键(COLUMN_KEY 含 "PRI") primaryKey bool } // TestAutoMigrateReconcile GORM model 全表 AutoMigrate 后逐列核对(T2 验收)。 // // 覆盖(9 表关键列,对齐 T1 DDL 决策 D1/D2/D3): // - D1 用户类主键/外键 = varchar(26):t_user.canonical_user_id(PRI) + 各表引用人列 // - D2 机构/校区主键 = bigint:t_institution.institution_id / t_campus.campus_id // - D3 关系/日志表主键 = bigint 自增:t_identity / t_membership / t_guardian_link / t_legacy_mapping / t_audit_log // - 可空列指针化核对(*字段 ↔ IS_NULLABLE=YES;非 *字段 ↔ NO) func TestAutoMigrateReconcile(t *testing.T) { dsn := os.Getenv("UNIFIED_TEST_DSN") if dsn == "" { t.Skip("未设置 UNIFIED_TEST_DSN,跳过 AutoMigrate 对账集成测试") } cfg := config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60} gdb, err := NewDB(cfg) if err != nil { t.Fatalf("NewDB: %v", err) } t.Cleanup(func() { _ = Close(gdb) }) // 全表清单 = identity 4 表 + org 5 表(task 表 T2 不涉及,T4 接入) all := append(identitymodel.AllTables(), orgmodel.AllTables()...) if err := Migrate(gdb, all...); err != nil { t.Fatalf("Migrate: %v", err) } // 测后清理(保留库,只 drop 表;e2e_test 供 W3 复用) t.Cleanup(func() { m := gdb.Migrator() _ = m.DropTable("t_user", "t_identity", "t_legacy_mapping", "t_audit_log", "t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link") }) // 逐列对账预期(与 T1 core-model-ddl.sql 一致) expects := []expectedColumn{ // D1:t_user 主键 ULID varchar(26) {"t_user", "canonical_user_id", "varchar(26)", "NO", true}, {"t_user", "username", "varchar(64)", "YES", false}, {"t_user", "person_type", "varchar(16)", "NO", false}, {"t_user", "password_hash", "varchar(255)", "YES", false}, {"t_user", "status", "varchar(16)", "NO", false}, // D2:机构/校区主键 bigint {"t_institution", "institution_id", "bigint", "NO", true}, {"t_institution", "institution_name", "varchar(128)", "NO", false}, {"t_campus", "campus_id", "bigint", "NO", true}, {"t_campus", "institution_id", "bigint", "NO", false}, // D3:关系/日志表主键 bigint + 引用人列 varchar(26) {"t_identity", "identity_id", "bigint", "NO", true}, {"t_identity", "canonical_user_id", "varchar(26)", "NO", false}, {"t_identity", "type", "varchar(32)", "NO", false}, {"t_identity", "verified", "tinyint(1)", "NO", false}, {"t_membership", "membership_id", "bigint", "NO", true}, {"t_membership", "canonical_user_id", "varchar(26)", "NO", false}, {"t_membership", "role", "varchar(32)", "NO", false}, {"t_role", "role_code", "varchar(32)", "NO", true}, {"t_role", "login_allowed", "tinyint(1)", "NO", false}, {"t_guardian_link", "link_id", "bigint", "NO", true}, {"t_guardian_link", "guardian_id", "varchar(26)", "NO", false}, {"t_guardian_link", "student_id", "varchar(26)", "NO", false}, {"t_legacy_mapping", "id", "bigint", "NO", true}, {"t_legacy_mapping", "canonical_user_id", "varchar(26)", "NO", false}, {"t_legacy_mapping", "legacy_academic_user_id", "int", "YES", false}, {"t_audit_log", "id", "bigint", "NO", true}, {"t_audit_log", "actor_user_id", "varchar(26)", "YES", false}, {"t_audit_log", "action", "varchar(64)", "NO", false}, } var failed []string for _, e := range expects { got := queryColumn(t, gdb, e.table, e.column) if got == nil { failed = append(failed, fmt.Sprintf("%s.%s: 列不存在", e.table, e.column)) continue } if e.typePrefix != "" && !strings.HasPrefix(got.Coltype, e.typePrefix) { failed = append(failed, fmt.Sprintf("%s.%s: 类型=%q 期望前缀=%q", e.table, e.column, got.Coltype, e.typePrefix)) } if got.Nullable != e.nullable { failed = append(failed, fmt.Sprintf("%s.%s: IS_NULLABLE=%q 期望=%q", e.table, e.column, got.Nullable, e.nullable)) } hasPri := strings.Contains(got.Colkey, "PRI") if hasPri != e.primaryKey { failed = append(failed, fmt.Sprintf("%s.%s: 主键=%v 期望=%v", e.table, e.column, hasPri, e.primaryKey)) } } if len(failed) > 0 { t.Errorf("AutoMigrate 对账发现 %d 处 model tag 与 DDL 不符:\n %s", len(failed), strings.Join(failed, "\n ")) } } // columnInfo 单列核对结果(来自 information_schema.COLUMNS,列名经 alias 对齐字段名供 GORM Scan 映射)。 // 字段必须**导出**(首字母大写)——GORM 靠反射赋值,未导出字段(小写开头)会被静默跳过, // 导致全留零值、误判"列不存在"(T2 实踩)。GORM 命名策略把 Coltype→coltype / Nullable→nullable / // Colkey→colkey,与下方 SQL alias 对齐。 type columnInfo struct { Coltype string // COLUMN_TYPE(如 varchar(26)/bigint/int/tinyint(1)) Nullable string // IS_NULLABLE(YES/NO) Colkey string // COLUMN_KEY(PRI/UNI/MUL/"") } // queryColumn 读 information_schema 取单列元信息(列不存在返回 nil,由调用方判 fail)。 // // 入参: // - t *testing.T:测试上下文。 // - gdb *gorm.DB:连接句柄。 // - table / column string:目标表/列。 // // 出参:*columnInfo;列不存在(表不存在或列名错)返回 nil。 // 说明:SELECT 列名用 alias 对齐 columnInfo 字段名(coltype/nullable/colkey), // GORM Scan 按"列名==字段名(不区分大小写)"映射,避免 COLUMN_TYPE→columnType 映射不上。 func queryColumn(t *testing.T, gdb *gorm.DB, table, column string) *columnInfo { t.Helper() // 取当前连接的 database 名(information_schema 限定 schema) var schema string if err := gdb.Raw("SELECT DATABASE()").Scan(&schema).Error; err != nil { t.Fatalf("取 database 名失败: %v", err) } var c columnInfo if err := gdb.Raw(` SELECT COLUMN_TYPE AS coltype, IS_NULLABLE AS nullable, COLUMN_KEY AS colkey FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ? `, schema, table, column).Scan(&c).Error; err != nil { t.Fatalf("查 information_schema %s.%s 失败: %v", table, column, err) } // COLUMN_TYPE 恒非空;空值代表"无匹配行"(表/列不存在)→ 返回 nil 由调用方 fail if c.Coltype == "" { return nil } return &c }