From 62edf8802969be97ab4fd0d1ba6811ac2e190447 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 29 Oct 2026 19:27:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(db):=20=E7=89=88=E6=9C=AC=E5=8C=96?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E8=BF=81=E7=A7=BB=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E5=88=97=E5=90=8D/=E5=88=A0=E5=88=97=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E4=B8=A2=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GORM 的 AutoMigrate 只会加表/加列/加索引,从不改列名/删列/改约束。此前这类 破坏性变更靠「HasColumn 判断 + 幂等补丁」硬撑,既没有账本记录「跑过什么」, 也把 5 个一次性回填(backfillAnsweredAt / backfillFeatureControls / reconcileLegacyChatEnabled / normalizeChatQuota / normalizeHarassmentSetting) 留成了每次启动都全表扫一遍。 新增 schema_migrations 账本表 + runMigrations: - 迁移按版本号升序执行,每个迁移在事务里「跑 up + 记版本」,任一失败阻断启动 - 那 5 个幂等补丁收编为 v1~v5,收编后只跑一次,之后启动直接跳过 - AutoMigrate 仍负责声明式的加表/加列/加索引,版本迁移负责它做不了的破坏性变更 与一次性回填,两者分工互不替代 测试覆盖:幂等(跑两遍不重复)、失败阻断后续、清单严格升序。实测真实库首次 启动建账本 + v1~v5 落库,二次重启账本不变。 --- internal/db/db.go | 43 ++-------- internal/db/migrations.go | 142 +++++++++++++++++++++++++++++++++ internal/db/migrations_test.go | 71 +++++++++++++++++ 3 files changed, 219 insertions(+), 37 deletions(-) create mode 100644 internal/db/migrations.go create mode 100644 internal/db/migrations_test.go diff --git a/internal/db/db.go b/internal/db/db.go index add0d1a..5106a99 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -90,47 +90,16 @@ func Init(typ, dsn string) (*gorm.DB, error) { } } - // questions.answered_at 是 2026-10 新增的列(首次回答时间,用于算「回答耗时」)。 - // 加列之前已回答的提问没有这个值,只能事后拿 updated_at 近似。 - // 必须在 AutoMigrate 之前判断列是否存在 —— 迁移之后大家都有列了,就分不出谁是历史行。 - hasAnsweredAt := db.Migrator().HasColumn(&Question{}, "answered_at") - + // AutoMigrate 只负责声明式的「加表 / 加列 / 加索引」——这些变更幂等、无数据丢失风险。 if err := db.AutoMigrate(AllTables...); err != nil { return nil, errors.Wrap(err, "auto migrate") } - // 回填只在「这次刚加上列」时做一次。 - // 为什么必须回填:这些行的 updated_at 之后还会被已读/置顶继续往前推, - // 不回填的话历史「回答耗时」会一天天变大,看起来像统计出错。 - // 失败不阻断启动,页面会退回 updated_at 计算。 - if !hasAnsweredAt { - if err := backfillAnsweredAt(db); err != nil { - logrus.WithError(err).Warn("回填 questions.answered_at 时异常(历史回答耗时将退回 updated_at)") - } - } - - // 功能开关初始化:为历史账号补齐 feature_controls 行(私聊沿用 users.allow_chat)。 - // 幂等,已初始化的账号不会重复写入;失败不阻断启动,仅告警,避免依赖表抖动导致服务起不来。 - if err := backfillFeatureControls(db); err != nil { - logrus.WithError(err).Warn("初始化功能开关配置时异常(可在功能管理页手动保存一次以生成配置)") - } - - // 私聊开关纠偏:旧版 backfill 曾把未开启私聊的账号写为开启(GORM 零值陷阱), - // 启动期幂等复位;同样不阻断启动,失败仅告警。 - if err := reconcileLegacyChatEnabled(db); err != nil { - logrus.WithError(err).Warn("私聊开关历史数据纠偏时异常(可在功能管理页手动调整私聊开关)") - } - - // 私聊收集上限下线:私聊不再支持收集上限(UI 已隐藏,保存时强制清零), - // 启动期把历史残留的上限/计数一并清零,避免私聊「莫名关闭」。幂等,失败仅告警。 - if err := normalizeChatQuota(db); err != nil { - logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)") - } - - // 防骚扰设置归一:历史账号 harassment_setting 为空串(建表时无默认值),与 none 语义等价 - // 但取值不同,会导致按 == none 判断时漏判。幂等,失败仅告警。 - if err := normalizeHarassmentSetting(db); err != nil { - logrus.WithError(err).Warn("防骚扰设置历史数据归一时异常(可在防骚扰设置页保存一次)") + // 版本化迁移:AutoMigrate 做不了的破坏性变更(改列名/删列/改约束)与一次性数据回填 + // 都收编进这里,靠 schema_migrations 账本保证「只跑一次、顺序固定、可审计」。 + // 迁移失败会阻断启动 —— 半途而废的迁移比静默跳过更危险(见 runMigrations 注释)。 + if err := runMigrations(db); err != nil { + return nil, errors.Wrap(err, "run versioned migrations") } // 启动期补充高频查询复合索引(幂等,适合既有库)。 diff --git a/internal/db/migrations.go b/internal/db/migrations.go new file mode 100644 index 0000000..dd683fc --- /dev/null +++ b/internal/db/migrations.go @@ -0,0 +1,142 @@ +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, + }, +} + +// 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 +} diff --git a/internal/db/migrations_test.go b/internal/db/migrations_test.go new file mode 100644 index 0000000..70b04d0 --- /dev/null +++ b/internal/db/migrations_test.go @@ -0,0 +1,71 @@ +package db + +import ( + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// TestRunMigrationsIdempotent 迁移账本幂等:同一组迁移跑两遍,第二遍不重复执行。 +func TestRunMigrationsIdempotent(t *testing.T) { + conn, clean := newTestDB(t) + defer clean("schema_migrations", "users", "feature_controls") + + // 第一遍:全部迁移都未应用,应执行并落账本。 + require.NoError(t, runMigrations(conn)) + + applied, err := AppliedMigrations(conn) + require.NoError(t, err) + require.Len(t, applied, len(migrations), "第一遍后所有版本都应已记录") + + // 第二遍:账本齐全,runMigrations 应立即返回且不报错。 + // 若已应用版本被重复执行,迁移事务会因主键冲突报错 —— 无错跑完即证明跳过了。 + require.NoError(t, runMigrations(conn)) + + applied2, err := AppliedMigrations(conn) + require.NoError(t, err) + require.Equal(t, applied, applied2, "重复执行不应改变账本") +} + +// TestRunMigrationsStopsOnFailure 迁移失败必须阻断:失败版本不落账本、后续版本不执行。 +func TestRunMigrationsStopsOnFailure(t *testing.T) { + conn, clean := newTestDB(t) + defer clean("schema_migrations", "users", "feature_controls") + + // 先建账本表(runMigrations 内部会建,但这里手动跑一次无迁移场景需要显式建表)。 + require.NoError(t, conn.AutoMigrate(&SchemaMigration{})) + + // 用一套独立的迁移清单模拟「第二个迁移失败」: + // v100 成功,v101 失败。预期:v100 落账本,v101 不落,且整体返回错误。 + saved := migrations + defer func() { migrations = saved }() + + migrations = []Migration{ + {Version: 100, Name: "ok", Up: func(*gorm.DB) error { return nil }}, + {Version: 101, Name: "boom", Up: func(*gorm.DB) error { return errors.New("intentional") }}, + {Version: 102, Name: "never-runs", Up: func(*gorm.DB) error { + t.Error("v102 不应执行,因为 v101 已失败") + return nil + }}, + } + + err := runMigrations(conn) + require.Error(t, err, "迁移失败必须返回错误") + + applied, aErr := AppliedMigrations(conn) + require.NoError(t, aErr) + require.True(t, applied[100], "成功的 v100 应已落账本") + require.False(t, applied[101], "失败的 v101 不应落账本") + require.False(t, applied[102], "被阻断的 v102 不应落账本") +} + +// TestMigrationsOrdered 迁移清单必须按版本号严格升序且无重复。 +func TestMigrationsOrdered(t *testing.T) { + for i := 1; i < len(migrations); i++ { + require.Less(t, migrations[i-1].Version, migrations[i].Version, + "迁移清单必须按版本号严格升序,第 %d 个版本 %d 不小于第 %d 个版本 %d", + i, migrations[i].Version, i-1, migrations[i-1].Version) + } +}