migrate_dev_check_test.go 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  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. )
  21. // TestDevDBAutoMigrate 对 dev 库执行 AutoMigrate 并用 information_schema 逐列核对(需显式开启)。
  22. func TestDevDBAutoMigrate(t *testing.T) {
  23. if os.Getenv("ACADEMIC_MIGRATE_CHECK") != "1" {
  24. t.Skip("跳过真库核对:设置 ACADEMIC_MIGRATE_CHECK=1 且 config/dev.json 指向 dev 库时执行")
  25. }
  26. chdirRepoRoot(t) // config.Load 按仓库根相对路径读 config/dev.json
  27. cfg, err := config.Load("dev")
  28. if err != nil {
  29. t.Skipf("config.Load(dev) 失败,跳过真库核对: %v", err)
  30. }
  31. gdb, err := infradb.NewDB(cfg.DB)
  32. if err != nil {
  33. t.Skipf("连接 dev 库失败,跳过真库核对: %v", err)
  34. }
  35. t.Cleanup(func() { _ = infradb.Close(gdb) })
  36. // 1) AutoMigrate 27 表(幂等:已存在则只补缺列,不删列)
  37. if err := infradb.Migrate(gdb, AllTables()...); err != nil {
  38. t.Fatalf("AutoMigrate 27 表失败: %v", err)
  39. }
  40. // 2) 读 information_schema 实际列
  41. type colRow struct {
  42. TableName string `gorm:"column:TABLE_NAME"`
  43. ColumnName string `gorm:"column:COLUMN_NAME"`
  44. ColumnType string `gorm:"column:COLUMN_TYPE"`
  45. IsNullable string `gorm:"column:IS_NULLABLE"`
  46. }
  47. ddl := parseDDL(t, ddlSQL)
  48. names := make([]any, 0, len(ddl))
  49. for table := range ddl {
  50. names = append(names, table)
  51. }
  52. var rows []colRow
  53. if err := gdb.Raw(
  54. `SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE
  55. FROM information_schema.COLUMNS
  56. WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ?`, names,
  57. ).Scan(&rows).Error; err != nil {
  58. t.Fatalf("查询 information_schema 失败: %v", err)
  59. }
  60. actual := make(map[string]map[string]colRow, len(ddl))
  61. for _, r := range rows {
  62. if actual[r.TableName] == nil {
  63. actual[r.TableName] = make(map[string]colRow, 16)
  64. }
  65. actual[r.TableName][r.ColumnName] = r
  66. }
  67. checked := 0
  68. for table, cols := range ddl {
  69. got, ok := actual[table]
  70. if !ok {
  71. t.Fatalf("dev 库缺少表 %s(AutoMigrate 未建?)", table)
  72. }
  73. if len(got) != len(cols) {
  74. t.Fatalf("表 %s 列数 = %d,DDL = %d", table, len(got), len(cols))
  75. }
  76. for _, want := range cols {
  77. r, ok := got[want.Name]
  78. if !ok {
  79. t.Fatalf("表 %s 缺少列 %s", table, want.Name)
  80. }
  81. if gotType := strings.ToUpper(r.ColumnType); gotType != want.Type {
  82. t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", table, want.Name, gotType, want.Type)
  83. }
  84. wantNullable := "YES"
  85. if want.NotNull {
  86. wantNullable = "NO"
  87. }
  88. if r.IsNullable != wantNullable {
  89. t.Fatalf("表 %s 列 %s IS_NULLABLE = %s,DDL = %s", table, want.Name, r.IsNullable, wantNullable)
  90. }
  91. checked++
  92. }
  93. }
  94. if checked < 250 {
  95. t.Fatalf("仅核对 %d 列,疑似漏表", checked)
  96. }
  97. // 3) 唯一键核对(7 张 biz_no + 幂等键 uk_request_type + 账户/报名/课酬唯一键)
  98. type idxRow struct {
  99. TableName string `gorm:"column:TABLE_NAME"`
  100. IndexName string `gorm:"column:INDEX_NAME"`
  101. }
  102. var idxRows []idxRow
  103. if err := gdb.Raw(
  104. `SELECT DISTINCT TABLE_NAME, INDEX_NAME
  105. FROM information_schema.STATISTICS
  106. WHERE TABLE_SCHEMA = DATABASE() AND NON_UNIQUE = 0 AND TABLE_NAME IN ?`, names,
  107. ).Scan(&idxRows).Error; err != nil {
  108. t.Fatalf("查询 information_schema.STATISTICS 失败: %v", err)
  109. }
  110. gotIdx := make(map[string]bool, len(idxRows))
  111. for _, r := range idxRows {
  112. gotIdx[r.TableName+"."+r.IndexName] = true
  113. }
  114. wantIdx := []string{
  115. "t_user_balance_request.uk_biz_no",
  116. "t_order_pay.uk_biz_no",
  117. "t_payment_log.uk_biz_no",
  118. "t_courses_activity_request.uk_biz_no",
  119. "t_courses_student.uk_biz_no",
  120. "t_courses_record.uk_biz_no",
  121. "t_courses_record_student.uk_biz_no",
  122. "t_user_expense_log.uk_request_type",
  123. "t_user_expense_school.uk_user_inst_campus",
  124. "t_courses_activity_signed.uk_user_activity",
  125. "t_teacher_courses_percentage.uk_type_teacher",
  126. }
  127. for _, k := range wantIdx {
  128. if !gotIdx[k] {
  129. t.Fatalf("dev 库缺少唯一键 %s(迁移幂等/并发防线依赖它)", k)
  130. }
  131. }
  132. t.Logf("dev 库 27 表 %d 列 + %d 个唯一键核对通过", checked, len(wantIdx))
  133. }
  134. // chdirRepoRoot 把工作目录切到仓库根(向上查找 go.mod),测试结束自动还原。
  135. //
  136. // 原因:`config.Load` 以"仓库根相对路径"读取 config/dev.json,而 `go test` 的工作目录是包目录。
  137. func chdirRepoRoot(t *testing.T) {
  138. t.Helper()
  139. wd, err := os.Getwd()
  140. if err != nil {
  141. t.Fatalf("Getwd 失败: %v", err)
  142. }
  143. dir := wd
  144. for {
  145. if _, err := os.Stat(dir + string(os.PathSeparator) + "go.mod"); err == nil {
  146. break
  147. }
  148. parent := dir[:strings.LastIndexAny(dir, `/\`)]
  149. if parent == "" || parent == dir {
  150. t.Skipf("未找到仓库根 go.mod,跳过真库核对(wd=%s)", wd)
  151. }
  152. dir = parent
  153. }
  154. if err := os.Chdir(dir); err != nil {
  155. t.Fatalf("切换到仓库根失败: %v", err)
  156. }
  157. t.Cleanup(func() { _ = os.Chdir(wd) })
  158. }