| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168 |
- // academic 27 表 **真库** AutoMigrate + information_schema 逐列核对(默认 skip)。
- //
- // 运行方式(仅 dev 环境,且已获得授权时执行):
- //
- // ACADEMIC_MIGRATE_CHECK=1 go test ./internal/academic/model/ -run TestDevDBAutoMigrate -v
- //
- // 前置:`config/dev.json` 存在(DSN 指向 dev 库)。
- // 未设置 ACADEMIC_MIGRATE_CHECK 时本测试直接 skip,保证 `go test ./...`
- // 在无数据库的 CI/容器环境恒绿(与 internal/migrate 的 e2e 门禁同一纪律)。
- //
- // 核对口径(AC-01 验收①):对 27 表逐列比对 `information_schema.COLUMNS` 的
- // COLUMN_NAME / COLUMN_TYPE(含 size/precision)/ IS_NULLABLE 与 DDL fixture 一致。
- package model
- import (
- "os"
- "strings"
- "testing"
- "yuansiwei/unified-backend/internal/infra/config"
- infradb "yuansiwei/unified-backend/internal/infra/db"
- )
- // TestDevDBAutoMigrate 对 dev 库执行 AutoMigrate 并用 information_schema 逐列核对(需显式开启)。
- func TestDevDBAutoMigrate(t *testing.T) {
- if os.Getenv("ACADEMIC_MIGRATE_CHECK") != "1" {
- t.Skip("跳过真库核对:设置 ACADEMIC_MIGRATE_CHECK=1 且 config/dev.json 指向 dev 库时执行")
- }
- chdirRepoRoot(t) // config.Load 按仓库根相对路径读 config/dev.json
- cfg, err := config.Load("dev")
- if err != nil {
- t.Skipf("config.Load(dev) 失败,跳过真库核对: %v", err)
- }
- gdb, err := infradb.NewDB(cfg.DB)
- if err != nil {
- t.Skipf("连接 dev 库失败,跳过真库核对: %v", err)
- }
- t.Cleanup(func() { _ = infradb.Close(gdb) })
- // 1) AutoMigrate 27 表(幂等:已存在则只补缺列,不删列)
- if err := infradb.Migrate(gdb, AllTables()...); err != nil {
- t.Fatalf("AutoMigrate 27 表失败: %v", err)
- }
- // 2) 读 information_schema 实际列
- type colRow struct {
- TableName string `gorm:"column:TABLE_NAME"`
- ColumnName string `gorm:"column:COLUMN_NAME"`
- ColumnType string `gorm:"column:COLUMN_TYPE"`
- IsNullable string `gorm:"column:IS_NULLABLE"`
- }
- ddl := parseDDL(t, ddlSQL)
- names := make([]any, 0, len(ddl))
- for table := range ddl {
- names = append(names, table)
- }
- var rows []colRow
- if err := gdb.Raw(
- `SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ?`, names,
- ).Scan(&rows).Error; err != nil {
- t.Fatalf("查询 information_schema 失败: %v", err)
- }
- actual := make(map[string]map[string]colRow, len(ddl))
- for _, r := range rows {
- if actual[r.TableName] == nil {
- actual[r.TableName] = make(map[string]colRow, 16)
- }
- actual[r.TableName][r.ColumnName] = r
- }
- checked := 0
- for table, cols := range ddl {
- got, ok := actual[table]
- if !ok {
- t.Fatalf("dev 库缺少表 %s(AutoMigrate 未建?)", table)
- }
- if len(got) != len(cols) {
- t.Fatalf("表 %s 列数 = %d,DDL = %d", table, len(got), len(cols))
- }
- for _, want := range cols {
- r, ok := got[want.Name]
- if !ok {
- t.Fatalf("表 %s 缺少列 %s", table, want.Name)
- }
- if gotType := strings.ToUpper(r.ColumnType); gotType != want.Type {
- t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", table, want.Name, gotType, want.Type)
- }
- wantNullable := "YES"
- if want.NotNull {
- wantNullable = "NO"
- }
- if r.IsNullable != wantNullable {
- t.Fatalf("表 %s 列 %s IS_NULLABLE = %s,DDL = %s", table, want.Name, r.IsNullable, wantNullable)
- }
- checked++
- }
- }
- if checked < 250 {
- t.Fatalf("仅核对 %d 列,疑似漏表", checked)
- }
- // 3) 唯一键核对(7 张 biz_no + 幂等键 uk_request_type + 账户/报名/课酬唯一键)
- type idxRow struct {
- TableName string `gorm:"column:TABLE_NAME"`
- IndexName string `gorm:"column:INDEX_NAME"`
- }
- var idxRows []idxRow
- if err := gdb.Raw(
- `SELECT DISTINCT TABLE_NAME, INDEX_NAME
- FROM information_schema.STATISTICS
- WHERE TABLE_SCHEMA = DATABASE() AND NON_UNIQUE = 0 AND TABLE_NAME IN ?`, names,
- ).Scan(&idxRows).Error; err != nil {
- t.Fatalf("查询 information_schema.STATISTICS 失败: %v", err)
- }
- gotIdx := make(map[string]bool, len(idxRows))
- for _, r := range idxRows {
- gotIdx[r.TableName+"."+r.IndexName] = true
- }
- wantIdx := []string{
- "t_user_balance_request.uk_biz_no",
- "t_order_pay.uk_biz_no",
- "t_payment_log.uk_biz_no",
- "t_courses_activity_request.uk_biz_no",
- "t_courses_student.uk_biz_no",
- "t_courses_record.uk_biz_no",
- "t_courses_record_student.uk_biz_no",
- "t_user_expense_log.uk_request_type",
- "t_user_expense_school.uk_user_inst_campus",
- "t_courses_activity_signed.uk_user_activity",
- "t_teacher_courses_percentage.uk_type_teacher",
- }
- for _, k := range wantIdx {
- if !gotIdx[k] {
- t.Fatalf("dev 库缺少唯一键 %s(迁移幂等/并发防线依赖它)", k)
- }
- }
- t.Logf("dev 库 27 表 %d 列 + %d 个唯一键核对通过", checked, len(wantIdx))
- }
- // chdirRepoRoot 把工作目录切到仓库根(向上查找 go.mod),测试结束自动还原。
- //
- // 原因:`config.Load` 以"仓库根相对路径"读取 config/dev.json,而 `go test` 的工作目录是包目录。
- func chdirRepoRoot(t *testing.T) {
- t.Helper()
- wd, err := os.Getwd()
- if err != nil {
- t.Fatalf("Getwd 失败: %v", err)
- }
- dir := wd
- for {
- if _, err := os.Stat(dir + string(os.PathSeparator) + "go.mod"); err == nil {
- break
- }
- parent := dir[:strings.LastIndexAny(dir, `/\`)]
- if parent == "" || parent == dir {
- t.Skipf("未找到仓库根 go.mod,跳过真库核对(wd=%s)", wd)
- }
- dir = parent
- }
- if err := os.Chdir(dir); err != nil {
- t.Fatalf("切换到仓库根失败: %v", err)
- }
- t.Cleanup(func() { _ = os.Chdir(wd) })
- }
|