feat(db): 版本化数据库迁移,改列名/删列不再静默丢数据
GORM 的 AutoMigrate 只会加表/加列/加索引,从不改列名/删列/改约束。此前这类 破坏性变更靠「HasColumn 判断 + 幂等补丁」硬撑,既没有账本记录「跑过什么」, 也把 5 个一次性回填(backfillAnsweredAt / backfillFeatureControls / reconcileLegacyChatEnabled / normalizeChatQuota / normalizeHarassmentSetting) 留成了每次启动都全表扫一遍。 新增 schema_migrations 账本表 + runMigrations: - 迁移按版本号升序执行,每个迁移在事务里「跑 up + 记版本」,任一失败阻断启动 - 那 5 个幂等补丁收编为 v1~v5,收编后只跑一次,之后启动直接跳过 - AutoMigrate 仍负责声明式的加表/加列/加索引,版本迁移负责它做不了的破坏性变更 与一次性回填,两者分工互不替代 测试覆盖:幂等(跑两遍不重复)、失败阻断后续、清单严格升序。实测真实库首次 启动建账本 + v1~v5 落库,二次重启账本不变。
This commit is contained in:
+6
-37
@@ -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")
|
||||
}
|
||||
|
||||
// 启动期补充高频查询复合索引(幂等,适合既有库)。
|
||||
|
||||
Reference in New Issue
Block a user