migrate_dev_check_test.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. // academic 27 表 **真库** AutoMigrate + information_schema 逐列核对(默认 skip)。
  2. //
  3. // 运行方式(仅 dev 环境,且已获得授权时执行):
  4. //
  5. // ACADEMIC_MIGRATE_CHECK=1 go test ./internal/academic/model/ -run TestDevDBAutoMigrate -v
  6. //
  7. // 前置:`config/dev.json` 存在(DSN 指向 dev 库)。
  8. // 未设置 ACADEMIC_MIGRATE_CHECK 时本测试直接 skip,保证 `go test ./...`
  9. // 在无数据库的 CI/容器环境恒绿(与 internal/migrate 的 e2e 门禁同一纪律)。
  10. //
  11. // 核对口径(AC-01 验收①):对 27 表逐列比对 `information_schema.COLUMNS` 的
  12. // COLUMN_NAME / COLUMN_TYPE(含 size/precision)/ IS_NULLABLE 与 DDL fixture 一致。
  13. package model
  14. import (
  15. "os"
  16. "strings"
  17. "testing"
  18. "yuansiwei/unified-backend/internal/infra/config"
  19. infradb "yuansiwei/unified-backend/internal/infra/db"
  20. "yuansiwei/unified-backend/internal/infra/dbtest"
  21. )
  22. // TestDevDBAutoMigrate 对 dev 库执行 AutoMigrate 并用 information_schema 逐列核对(需显式开启)。
  23. func TestDevDBAutoMigrate(t *testing.T) {
  24. if os.Getenv("ACADEMIC_MIGRATE_CHECK") != "1" {
  25. t.Skip("跳过真库核对:设置 ACADEMIC_MIGRATE_CHECK=1 且 config/dev.json 指向 dev 库时执行")
  26. }
  27. chdirRepoRoot(t) // config.Load 按仓库根相对路径读 config/dev.json
  28. cfg, err := config.Load("dev")
  29. if err != nil {
  30. t.Skipf("config.Load(dev) 失败,跳过真库核对: %v", err)
  31. }
  32. gdb, err := infradb.NewDB(cfg.DB)
  33. if err != nil {
  34. t.Skipf("连接 dev 库失败,跳过真库核对: %v", err)
  35. }
  36. t.Cleanup(func() { _ = infradb.Close(gdb) })
  37. // 1) AutoMigrate 27 表(幂等:已存在则只补缺列,不删列)
  38. if err := infradb.Migrate(gdb, AllTables()...); err != nil {
  39. t.Fatalf("AutoMigrate 27 表失败: %v", err)
  40. }
  41. // 2) 读 information_schema 实际列
  42. type colRow struct {
  43. TableName string `gorm:"column:TABLE_NAME"`
  44. ColumnName string `gorm:"column:COLUMN_NAME"`
  45. ColumnType string `gorm:"column:COLUMN_TYPE"`
  46. IsNullable string `gorm:"column:IS_NULLABLE"`
  47. }
  48. ddl := parseDDL(t, ddlSQL)
  49. names := make([]any, 0, len(ddl))
  50. for table := range ddl {
  51. names = append(names, table)
  52. }
  53. var rows []colRow
  54. if err := gdb.Raw(
  55. `SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE
  56. FROM information_schema.COLUMNS
  57. WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ?`, names,
  58. ).Scan(&rows).Error; err != nil {
  59. t.Fatalf("查询 information_schema 失败: %v", err)
  60. }
  61. actual := make(map[string]map[string]colRow, len(ddl))
  62. for _, r := range rows {
  63. if actual[r.TableName] == nil {
  64. actual[r.TableName] = make(map[string]colRow, 16)
  65. }
  66. actual[r.TableName][r.ColumnName] = r
  67. }
  68. checked := 0
  69. for table, cols := range ddl {
  70. got, ok := actual[table]
  71. if !ok {
  72. t.Fatalf("dev 库缺少表 %s(AutoMigrate 未建?)", table)
  73. }
  74. if len(got) != len(cols) {
  75. t.Fatalf("表 %s 列数 = %d,DDL = %d", table, len(got), len(cols))
  76. }
  77. for _, want := range cols {
  78. r, ok := got[want.Name]
  79. if !ok {
  80. t.Fatalf("表 %s 缺少列 %s", table, want.Name)
  81. }
  82. // 类型比对**两侧归一**(设计 v0.1.2 §2.4「L1 TINYINT(1) 假阳性」裁定):
  83. // information_schema 对 bool/int8 列可能返回 "tinyint(1)" 或 "tinyint"
  84. // (MySQL 8.0.19+ 省略整数显示宽度),DDL 侧写法亦不统一 →
  85. // 归一只放过"显示宽度/同义词"差异;decimal(p,s)/varchar(N)/datetime(3) 的参数
  86. // 仍参与严格比对(负例见 internal/infra/dbtest/columntype_test.go)。
  87. if !dbtest.SameColumnType(r.ColumnType, want.Type) {
  88. t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s(归一后 %q vs %q)",
  89. table, want.Name, r.ColumnType, want.Type,
  90. dbtest.NormalizeColumnType(r.ColumnType), dbtest.NormalizeColumnType(want.Type))
  91. }
  92. wantNullable := "YES"
  93. if want.NotNull {
  94. wantNullable = "NO"
  95. }
  96. if r.IsNullable != wantNullable {
  97. t.Fatalf("表 %s 列 %s IS_NULLABLE = %s,DDL = %s", table, want.Name, r.IsNullable, wantNullable)
  98. }
  99. checked++
  100. }
  101. }
  102. if checked < 250 {
  103. t.Fatalf("仅核对 %d 列,疑似漏表", checked)
  104. }
  105. // 3) 唯一键核对(7 张 biz_no + 幂等键 uk_request_type + 账户/报名/课酬唯一键)
  106. type idxRow struct {
  107. TableName string `gorm:"column:TABLE_NAME"`
  108. IndexName string `gorm:"column:INDEX_NAME"`
  109. }
  110. var idxRows []idxRow
  111. if err := gdb.Raw(
  112. `SELECT DISTINCT TABLE_NAME, INDEX_NAME
  113. FROM information_schema.STATISTICS
  114. WHERE TABLE_SCHEMA = DATABASE() AND NON_UNIQUE = 0 AND TABLE_NAME IN ?`, names,
  115. ).Scan(&idxRows).Error; err != nil {
  116. t.Fatalf("查询 information_schema.STATISTICS 失败: %v", err)
  117. }
  118. gotIdx := make(map[string]bool, len(idxRows))
  119. for _, r := range idxRows {
  120. gotIdx[r.TableName+"."+r.IndexName] = true
  121. }
  122. wantIdx := []string{
  123. "t_user_balance_request.uk_biz_no",
  124. "t_order_pay.uk_biz_no",
  125. "t_payment_log.uk_biz_no",
  126. "t_courses_activity_request.uk_biz_no",
  127. "t_courses_student.uk_biz_no",
  128. "t_courses_record.uk_biz_no",
  129. "t_courses_record_student.uk_biz_no",
  130. "t_user_expense_log.uk_request_type",
  131. "t_user_expense_school.uk_user_inst_campus",
  132. "t_courses_activity_signed.uk_user_activity",
  133. "t_teacher_courses_percentage.uk_type_teacher",
  134. }
  135. for _, k := range wantIdx {
  136. if !gotIdx[k] {
  137. t.Fatalf("dev 库缺少唯一键 %s(迁移幂等/并发防线依赖它)", k)
  138. }
  139. }
  140. t.Logf("dev 库 27 表 %d 列 + %d 个唯一键核对通过", checked, len(wantIdx))
  141. }
  142. // chdirRepoRoot 把工作目录切到仓库根(向上查找 go.mod),测试结束自动还原。
  143. //
  144. // 原因:`config.Load` 以"仓库根相对路径"读取 config/dev.json,而 `go test` 的工作目录是包目录。
  145. func chdirRepoRoot(t *testing.T) {
  146. t.Helper()
  147. wd, err := os.Getwd()
  148. if err != nil {
  149. t.Fatalf("Getwd 失败: %v", err)
  150. }
  151. dir := wd
  152. for {
  153. if _, err := os.Stat(dir + string(os.PathSeparator) + "go.mod"); err == nil {
  154. break
  155. }
  156. parent := dir[:strings.LastIndexAny(dir, `/\`)]
  157. if parent == "" || parent == dir {
  158. t.Skipf("未找到仓库根 go.mod,跳过真库核对(wd=%s)", wd)
  159. }
  160. dir = parent
  161. }
  162. if err := os.Chdir(dir); err != nil {
  163. t.Fatalf("切换到仓库根失败: %v", err)
  164. }
  165. t.Cleanup(func() { _ = os.Chdir(wd) })
  166. }