db_test.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. // 集成测试:GORM model 与 core-model-ddl.sql 逐列对账(T2 验收判据)。
  2. //
  3. // 运行前提(DSN 含密码,不入库,经环境变量注入):
  4. //
  5. // # Windows
  6. // set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local
  7. // go test ./internal/infra/db/ -run TestAutoMigrateReconcile -v
  8. //
  9. // 未设置 UNIFIED_TEST_DSN 时本测试自动跳过(CI / 无库环境 go test ./... 不受影响)。
  10. // 测试目标库用 e2e_test(dev 账号有建表/DROP 表权限),**不污染** dev 主库 yuansiwei_2026;
  11. // 测后 DropTable 清理 9 张表(保留 e2e_test 库本身,供 W3 e2e 复用)。
  12. //
  13. // 对账逻辑:AutoMigrate 按 model tag 建表 → 读 information_schema → 断言关键列的
  14. // 类型/可空性/主键与 T1 DDL 一致。任一不符说明 model tag 写错(列名/类型/指针化漂移),fail。
  15. package db
  16. import (
  17. "fmt"
  18. "os"
  19. "strings"
  20. "testing"
  21. "gorm.io/gorm"
  22. identitymodel "yuansiwei/unified-backend/internal/identity/model"
  23. "yuansiwei/unified-backend/internal/infra/config"
  24. orgmodel "yuansiwei/unified-backend/internal/org/model"
  25. )
  26. // expectedColumn 单列对账预期(表名 → 列名 → 预期)。
  27. type expectedColumn struct {
  28. table string
  29. column string
  30. // typePrefix 期望 COLUMN_TYPE 前缀(如 "varchar(26)"/"bigint"/"int"/"tinyint(1)"/"datetime");
  31. // 空串表示只核对可空性/主键、不核对类型。
  32. typePrefix string
  33. // nullable 期望 IS_NULLABLE("YES"/"NO")
  34. nullable string
  35. // primaryKey 期望是否主键(COLUMN_KEY 含 "PRI")
  36. primaryKey bool
  37. }
  38. // TestAutoMigrateReconcile GORM model 全表 AutoMigrate 后逐列核对(T2 验收)。
  39. //
  40. // 覆盖(9 表关键列,对齐 T1 DDL 决策 D1/D2/D3):
  41. // - D1 用户类主键/外键 = varchar(26):t_user.canonical_user_id(PRI) + 各表引用人列
  42. // - D2 机构/校区主键 = bigint:t_institution.institution_id / t_campus.campus_id
  43. // - D3 关系/日志表主键 = bigint 自增:t_identity / t_membership / t_guardian_link / t_legacy_mapping / t_audit_log
  44. // - 可空列指针化核对(*字段 ↔ IS_NULLABLE=YES;非 *字段 ↔ NO)
  45. func TestAutoMigrateReconcile(t *testing.T) {
  46. dsn := os.Getenv("UNIFIED_TEST_DSN")
  47. if dsn == "" {
  48. t.Skip("未设置 UNIFIED_TEST_DSN,跳过 AutoMigrate 对账集成测试")
  49. }
  50. cfg := config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60}
  51. gdb, err := NewDB(cfg)
  52. if err != nil {
  53. t.Fatalf("NewDB: %v", err)
  54. }
  55. t.Cleanup(func() { _ = Close(gdb) })
  56. // 全表清单 = identity 4 表 + org 5 表(task 表 T2 不涉及,T4 接入)
  57. all := append(identitymodel.AllTables(), orgmodel.AllTables()...)
  58. if err := Migrate(gdb, all...); err != nil {
  59. t.Fatalf("Migrate: %v", err)
  60. }
  61. // 测后清理(保留库,只 drop 表;e2e_test 供 W3 复用)
  62. t.Cleanup(func() {
  63. m := gdb.Migrator()
  64. _ = m.DropTable("t_user", "t_identity", "t_legacy_mapping", "t_audit_log",
  65. "t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link")
  66. })
  67. // 逐列对账预期(与 T1 core-model-ddl.sql 一致)
  68. expects := []expectedColumn{
  69. // D1:t_user 主键 ULID varchar(26)
  70. {"t_user", "canonical_user_id", "varchar(26)", "NO", true},
  71. {"t_user", "username", "varchar(64)", "YES", false},
  72. {"t_user", "person_type", "varchar(16)", "NO", false},
  73. {"t_user", "password_hash", "varchar(255)", "YES", false},
  74. {"t_user", "status", "varchar(16)", "NO", false},
  75. // D2:机构/校区主键 bigint
  76. {"t_institution", "institution_id", "bigint", "NO", true},
  77. {"t_institution", "institution_name", "varchar(128)", "NO", false},
  78. {"t_campus", "campus_id", "bigint", "NO", true},
  79. {"t_campus", "institution_id", "bigint", "NO", false},
  80. // D3:关系/日志表主键 bigint + 引用人列 varchar(26)
  81. {"t_identity", "identity_id", "bigint", "NO", true},
  82. {"t_identity", "canonical_user_id", "varchar(26)", "NO", false},
  83. {"t_identity", "type", "varchar(32)", "NO", false},
  84. {"t_identity", "verified", "tinyint(1)", "NO", false},
  85. {"t_membership", "membership_id", "bigint", "NO", true},
  86. {"t_membership", "canonical_user_id", "varchar(26)", "NO", false},
  87. {"t_membership", "role", "varchar(32)", "NO", false},
  88. {"t_role", "role_code", "varchar(32)", "NO", true},
  89. {"t_role", "login_allowed", "tinyint(1)", "NO", false},
  90. {"t_guardian_link", "link_id", "bigint", "NO", true},
  91. {"t_guardian_link", "guardian_id", "varchar(26)", "NO", false},
  92. {"t_guardian_link", "student_id", "varchar(26)", "NO", false},
  93. {"t_legacy_mapping", "id", "bigint", "NO", true},
  94. {"t_legacy_mapping", "canonical_user_id", "varchar(26)", "NO", false},
  95. {"t_legacy_mapping", "legacy_academic_user_id", "int", "YES", false},
  96. {"t_audit_log", "id", "bigint", "NO", true},
  97. {"t_audit_log", "actor_user_id", "varchar(26)", "YES", false},
  98. {"t_audit_log", "action", "varchar(64)", "NO", false},
  99. }
  100. var failed []string
  101. for _, e := range expects {
  102. got := queryColumn(t, gdb, e.table, e.column)
  103. if got == nil {
  104. failed = append(failed, fmt.Sprintf("%s.%s: 列不存在", e.table, e.column))
  105. continue
  106. }
  107. if e.typePrefix != "" && !strings.HasPrefix(got.Coltype, e.typePrefix) {
  108. failed = append(failed, fmt.Sprintf("%s.%s: 类型=%q 期望前缀=%q", e.table, e.column, got.Coltype, e.typePrefix))
  109. }
  110. if got.Nullable != e.nullable {
  111. failed = append(failed, fmt.Sprintf("%s.%s: IS_NULLABLE=%q 期望=%q", e.table, e.column, got.Nullable, e.nullable))
  112. }
  113. hasPri := strings.Contains(got.Colkey, "PRI")
  114. if hasPri != e.primaryKey {
  115. failed = append(failed, fmt.Sprintf("%s.%s: 主键=%v 期望=%v", e.table, e.column, hasPri, e.primaryKey))
  116. }
  117. }
  118. if len(failed) > 0 {
  119. t.Errorf("AutoMigrate 对账发现 %d 处 model tag 与 DDL 不符:\n %s", len(failed), strings.Join(failed, "\n "))
  120. }
  121. }
  122. // columnInfo 单列核对结果(来自 information_schema.COLUMNS,列名经 alias 对齐字段名供 GORM Scan 映射)。
  123. // 字段必须**导出**(首字母大写)——GORM 靠反射赋值,未导出字段(小写开头)会被静默跳过,
  124. // 导致全留零值、误判"列不存在"(T2 实踩)。GORM 命名策略把 Coltype→coltype / Nullable→nullable /
  125. // Colkey→colkey,与下方 SQL alias 对齐。
  126. type columnInfo struct {
  127. Coltype string // COLUMN_TYPE(如 varchar(26)/bigint/int/tinyint(1))
  128. Nullable string // IS_NULLABLE(YES/NO)
  129. Colkey string // COLUMN_KEY(PRI/UNI/MUL/"")
  130. }
  131. // queryColumn 读 information_schema 取单列元信息(列不存在返回 nil,由调用方判 fail)。
  132. //
  133. // 入参:
  134. // - t *testing.T:测试上下文。
  135. // - gdb *gorm.DB:连接句柄。
  136. // - table / column string:目标表/列。
  137. //
  138. // 出参:*columnInfo;列不存在(表不存在或列名错)返回 nil。
  139. // 说明:SELECT 列名用 alias 对齐 columnInfo 字段名(coltype/nullable/colkey),
  140. // GORM Scan 按"列名==字段名(不区分大小写)"映射,避免 COLUMN_TYPE→columnType 映射不上。
  141. func queryColumn(t *testing.T, gdb *gorm.DB, table, column string) *columnInfo {
  142. t.Helper()
  143. // 取当前连接的 database 名(information_schema 限定 schema)
  144. var schema string
  145. if err := gdb.Raw("SELECT DATABASE()").Scan(&schema).Error; err != nil {
  146. t.Fatalf("取 database 名失败: %v", err)
  147. }
  148. var c columnInfo
  149. if err := gdb.Raw(`
  150. SELECT COLUMN_TYPE AS coltype, IS_NULLABLE AS nullable, COLUMN_KEY AS colkey
  151. FROM information_schema.COLUMNS
  152. WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
  153. `, schema, table, column).Scan(&c).Error; err != nil {
  154. t.Fatalf("查 information_schema %s.%s 失败: %v", table, column, err)
  155. }
  156. // COLUMN_TYPE 恒非空;空值代表"无匹配行"(表/列不存在)→ 返回 nil 由调用方 fail
  157. if c.Coltype == "" {
  158. return nil
  159. }
  160. return &c
  161. }