db_test.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  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 清理 12 张表(保留 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_wechat_union", "t_legacy_mapping", "t_audit_log",
  65. "t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link",
  66. "t_permission", "t_role_permission")
  67. })
  68. // 逐列对账预期(与 T1 core-model-ddl.sql 一致)
  69. expects := []expectedColumn{
  70. // D1:t_user 主键 ULID varchar(26)
  71. {"t_user", "canonical_user_id", "varchar(26)", "NO", true},
  72. {"t_user", "username", "varchar(64)", "YES", false},
  73. {"t_user", "person_type", "varchar(16)", "NO", false},
  74. {"t_user", "password_hash", "varchar(255)", "YES", false},
  75. {"t_user", "status", "varchar(16)", "NO", false},
  76. // D2:机构/校区主键 bigint
  77. {"t_institution", "institution_id", "bigint", "NO", true},
  78. {"t_institution", "institution_name", "varchar(128)", "NO", false},
  79. {"t_campus", "campus_id", "bigint", "NO", true},
  80. {"t_campus", "institution_id", "bigint", "NO", false},
  81. // D3:关系/日志表主键 bigint + 引用人列 varchar(26)
  82. {"t_identity", "identity_id", "bigint", "NO", true},
  83. {"t_identity", "canonical_user_id", "varchar(26)", "NO", false},
  84. {"t_identity", "type", "varchar(32)", "NO", false},
  85. {"t_identity", "appid", "varchar(64)", "NO", false}, // v1.2:NOT NULL DEFAULT '',进唯一键
  86. {"t_identity", "verified", "tinyint(1)", "NO", false},
  87. {"t_wechat_union", "id", "bigint", "NO", true},
  88. {"t_wechat_union", "canonical_user_id", "varchar(26)", "NO", false},
  89. {"t_wechat_union", "unionid", "varchar(100)", "NO", false},
  90. {"t_membership", "membership_id", "bigint", "NO", true},
  91. {"t_membership", "canonical_user_id", "varchar(26)", "NO", false},
  92. {"t_membership", "role", "varchar(32)", "NO", false},
  93. {"t_role", "role_code", "varchar(32)", "NO", true},
  94. {"t_role", "login_allowed", "tinyint(1)", "NO", false},
  95. {"t_guardian_link", "link_id", "bigint", "NO", true},
  96. {"t_guardian_link", "guardian_id", "varchar(26)", "NO", false},
  97. {"t_guardian_link", "student_id", "varchar(26)", "NO", false},
  98. {"t_legacy_mapping", "id", "bigint", "NO", true},
  99. {"t_legacy_mapping", "canonical_user_id", "varchar(26)", "NO", false},
  100. {"t_legacy_mapping", "legacy_academic_user_id", "int", "YES", false},
  101. {"t_audit_log", "id", "bigint", "NO", true},
  102. {"t_audit_log", "actor_user_id", "varchar(26)", "YES", false},
  103. {"t_audit_log", "action", "varchar(64)", "NO", false},
  104. }
  105. var failed []string
  106. for _, e := range expects {
  107. got := queryColumn(t, gdb, e.table, e.column)
  108. if got == nil {
  109. failed = append(failed, fmt.Sprintf("%s.%s: 列不存在", e.table, e.column))
  110. continue
  111. }
  112. if e.typePrefix != "" && !strings.HasPrefix(got.Coltype, e.typePrefix) {
  113. failed = append(failed, fmt.Sprintf("%s.%s: 类型=%q 期望前缀=%q", e.table, e.column, got.Coltype, e.typePrefix))
  114. }
  115. if got.Nullable != e.nullable {
  116. failed = append(failed, fmt.Sprintf("%s.%s: IS_NULLABLE=%q 期望=%q", e.table, e.column, got.Nullable, e.nullable))
  117. }
  118. hasPri := strings.Contains(got.Colkey, "PRI")
  119. if hasPri != e.primaryKey {
  120. failed = append(failed, fmt.Sprintf("%s.%s: 主键=%v 期望=%v", e.table, e.column, hasPri, e.primaryKey))
  121. }
  122. }
  123. if len(failed) > 0 {
  124. t.Errorf("AutoMigrate 对账发现 %d 处 model tag 与 DDL 不符:\n %s", len(failed), strings.Join(failed, "\n "))
  125. }
  126. }
  127. // columnInfo 单列核对结果(来自 information_schema.COLUMNS,列名经 alias 对齐字段名供 GORM Scan 映射)。
  128. // 字段必须**导出**(首字母大写)——GORM 靠反射赋值,未导出字段(小写开头)会被静默跳过,
  129. // 导致全留零值、误判"列不存在"(T2 实踩)。GORM 命名策略把 Coltype→coltype / Nullable→nullable /
  130. // Colkey→colkey,与下方 SQL alias 对齐。
  131. type columnInfo struct {
  132. Coltype string // COLUMN_TYPE(如 varchar(26)/bigint/int/tinyint(1))
  133. Nullable string // IS_NULLABLE(YES/NO)
  134. Colkey string // COLUMN_KEY(PRI/UNI/MUL/"")
  135. }
  136. // queryColumn 读 information_schema 取单列元信息(列不存在返回 nil,由调用方判 fail)。
  137. //
  138. // 入参:
  139. // - t *testing.T:测试上下文。
  140. // - gdb *gorm.DB:连接句柄。
  141. // - table / column string:目标表/列。
  142. //
  143. // 出参:*columnInfo;列不存在(表不存在或列名错)返回 nil。
  144. // 说明:SELECT 列名用 alias 对齐 columnInfo 字段名(coltype/nullable/colkey),
  145. // GORM Scan 按"列名==字段名(不区分大小写)"映射,避免 COLUMN_TYPE→columnType 映射不上。
  146. func queryColumn(t *testing.T, gdb *gorm.DB, table, column string) *columnInfo {
  147. t.Helper()
  148. // 取当前连接的 database 名(information_schema 限定 schema)
  149. var schema string
  150. if err := gdb.Raw("SELECT DATABASE()").Scan(&schema).Error; err != nil {
  151. t.Fatalf("取 database 名失败: %v", err)
  152. }
  153. var c columnInfo
  154. if err := gdb.Raw(`
  155. SELECT COLUMN_TYPE AS coltype, IS_NULLABLE AS nullable, COLUMN_KEY AS colkey
  156. FROM information_schema.COLUMNS
  157. WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
  158. `, schema, table, column).Scan(&c).Error; err != nil {
  159. t.Fatalf("查 information_schema %s.%s 失败: %v", table, column, err)
  160. }
  161. // COLUMN_TYPE 恒非空;空值代表"无匹配行"(表/列不存在)→ 返回 nil 由调用方 fail
  162. if c.Coltype == "" {
  163. return nil
  164. }
  165. return &c
  166. }