package db import ( "sort" "time" "github.com/pkg/errors" "github.com/sirupsen/logrus" "gorm.io/gorm" ) // SchemaMigration 记录一次已应用的数据库迁移。一行 = 一个已跑过的版本。 // 这是迁移机制的账本:启动时按版本号升序执行「账本里还没有」的迁移,已应用的跳过。 // // 为什么需要它:GORM 的 AutoMigrate 只会「加表 / 加列 / 加索引」,从不「改列名 / // 删列 / 改约束」。任何破坏性 schema 变更若靠 AutoMigrate 的 HasColumn 判断或幂等补丁 // 硬撑,都会在「改列名 → 新旧两列并存、新列恒空」这种形态下静默丢数据,而且没有任何 // 地方记录「这个库已经跑过哪些变更」。版本表把「跑过什么、顺序如何」固化下来, // 让迁移可解释、可回看、可审计。 type SchemaMigration struct { // Version 单调递增的版本号,迁移按它升序执行。 Version int `gorm:"primaryKey"` // Name 迁移的简短描述,用于日志与人工排查。 Name string `gorm:"type:varchar(128)"` // AppliedAt 应用时间,仅供审计。 AppliedAt time.Time } // Migration 一个数据库迁移步骤。 type Migration struct { Version int Name string // Up 执行迁移。应当尽量幂等,因为早期版本在收编进版本表之前可能已在旧流程里 // 跑过一次;靠版本账本保证「收编后只跑一次」。 Up func(db *gorm.DB) error } // migrations 按版本号升序排列的迁移清单。新增迁移时追加到末尾并分配一个更大的版本号。 // // 收编说明:下面这些迁移对应的函数原本散落在 db.Init 里,靠 HasColumn 判断或幂等硬撑, // 每次启动都会重新执行一遍。收编进版本表后,它们只在「该版本号尚未记录」时执行一次, // 之后启动直接跳过 —— 既省掉无谓的全表扫描,也消除了「幂等补丁某天不小心变得不幂等」 // 时对历史数据的重复改写风险。 var migrations = []Migration{ { Version: 1, Name: "backfill questions.answered_at", Up: backfillAnsweredAt, }, { Version: 2, Name: "backfill feature_controls", Up: backfillFeatureControls, }, { Version: 3, Name: "reconcile legacy chat enabled", Up: reconcileLegacyChatEnabled, }, { Version: 4, Name: "normalize chat quota", Up: normalizeChatQuota, }, { Version: 5, Name: "normalize harassment setting", Up: normalizeHarassmentSetting, }, { // 新增列 AutoMigrate 只补 0,而 0 与「无授权」的零值撞车 —— 必须回填, // 否则升级后存量用户的页面密码会被静默绕过。 Version: 6, Name: "backfill page password version", Up: backfillPagePasswordVer, }, } // runMigrations 按版本号升序执行尚未应用的迁移。 // // 执行模型:先建账本表(若无),读取已应用版本集合;对每个未应用的迁移, // 在事务里跑 Up 并写入账本行。任一步失败即返回错误并终止后续迁移 —— 迁移是有序依赖的, // 半途而废比硬着头皮跳过更安全(调用方会阻断启动,让运维看到明确的失败版本)。 // // 与 AutoMigrate 的分工:AutoMigrate 仍负责「新增表 / 加列 / 加索引」这类声明式、 // 幂等、无数据丢失风险的变更(它本来就擅长且安全);版本迁移负责 AutoMigrate // 做不了的破坏性变更与一次性数据回填。两者在 db.Init 里顺序执行,互不替代。 func runMigrations(db *gorm.DB) error { if err := db.AutoMigrate(&SchemaMigration{}); err != nil { return errors.Wrap(err, "create schema_migrations table") } var applied []SchemaMigration if err := db.Order("version ASC").Find(&applied).Error; err != nil { return errors.Wrap(err, "load applied migrations") } appliedSet := make(map[int]bool, len(applied)) for _, m := range applied { appliedSet[m.Version] = true } // 防御:清单必须按版本号严格升序且无重复,否则顺序执行会出错、账本会混乱。 ordered := make([]Migration, len(migrations)) copy(ordered, migrations) sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Version < ordered[j].Version }) for i := 1; i < len(ordered); i++ { if ordered[i].Version == ordered[i-1].Version { return errors.Errorf("duplicate migration version %d", ordered[i].Version) } } for _, m := range ordered { if appliedSet[m.Version] { continue } logrus.WithField("version", m.Version).WithField("name", m.Name). Info("应用数据库迁移") // 迁移必须在一个事务里完成:Up 成功 + 记版本要么同时生效,要么都不生效。 // 否则进程在「Up 成功、账本未记」之间崩溃,重启后会重复执行一次迁移。 err := db.Transaction(func(tx *gorm.DB) error { if err := m.Up(tx); err != nil { return errors.Wrapf(err, "run migration %d (%s)", m.Version, m.Name) } rec := SchemaMigration{Version: m.Version, Name: m.Name, AppliedAt: time.Now()} if err := tx.Create(&rec).Error; err != nil { return errors.Wrapf(err, "record migration %d", m.Version) } return nil }) if err != nil { return err } } return nil } // AppliedMigrations 返回已应用的迁移版本号集合,供测试与运维查询。 func AppliedMigrations(db *gorm.DB) (map[int]bool, error) { var rows []SchemaMigration if err := db.Order("version ASC").Find(&rows).Error; err != nil { return nil, errors.Wrap(err, "load applied migrations") } set := make(map[int]bool, len(rows)) for _, r := range rows { set[r.Version] = true } return set, nil }