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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user