perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
@@ -10,7 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion int64 = 7
|
||||
const CurrentSchemaVersion int64 = 8
|
||||
|
||||
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
|
||||
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
|
||||
@@ -19,6 +19,7 @@ const resourceUploadKeyChecksum = "sha256:resource-upload-key-v4-20260901"
|
||||
const paymentTopupChecksum = "sha256:payment-topup-v5-20260902"
|
||||
const resourcePlaybackChecksum = "sha256:resource-playback-v6-20260902"
|
||||
const assetLibraryFoldersChecksum = "sha256:asset-library-folders-v6-20260902"
|
||||
const logicalModelActiveCodeChecksum = "sha256:logical-model-active-code-v8-20260905"
|
||||
|
||||
const postgresSchemaMigrationLockID int64 = 73123910420260830
|
||||
|
||||
@@ -52,6 +53,35 @@ var schemaMigrations = []migration{
|
||||
{version: 5, name: "payment_topup", checksum: paymentTopupChecksum, apply: migrateSchemaV5},
|
||||
{version: 6, name: "resource_playback_variant", checksum: resourcePlaybackChecksum, apply: migrateSchemaV6},
|
||||
{version: 7, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV7},
|
||||
{version: 8, name: "logical_model_active_code", checksum: logicalModelActiveCodeChecksum, apply: migrateSchemaV8},
|
||||
}
|
||||
|
||||
func migrationsForDatabase(db *gorm.DB) ([]migration, error) {
|
||||
var applied schemaMigration
|
||||
err := db.First(&applied, "version = ?", 6).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return schemaMigrations, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取数据库迁移 6:%w", err)
|
||||
}
|
||||
if applied.Name != "asset_library_folders" {
|
||||
return schemaMigrations, nil
|
||||
}
|
||||
legacy := migration{version: 6, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV7}
|
||||
if err := validateMigrationRecord(applied, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan := append([]migration(nil), schemaMigrations...)
|
||||
for index, item := range plan {
|
||||
switch item.version {
|
||||
case 6:
|
||||
plan[index] = legacy
|
||||
case 7:
|
||||
plan[index] = migration{version: 7, name: "resource_playback_variant", checksum: resourcePlaybackChecksum, apply: migrateSchemaV6}
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func migrateSchemaV2(tx *gorm.DB) error {
|
||||
@@ -155,6 +185,19 @@ func migrateSchemaV7(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateSchemaV8(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasTable(&model.LogicalModel{}) {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Exec("DROP INDEX IF EXISTS idx_logical_models_code").Error; err != nil {
|
||||
return fmt.Errorf("移除前台模型旧 code 唯一索引:%w", err)
|
||||
}
|
||||
if err := tx.Exec("CREATE UNIQUE INDEX idx_logical_models_code ON logical_models(code) WHERE archived_at IS NULL").Error; err != nil {
|
||||
return fmt.Errorf("创建前台模型活动 code 唯一索引:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MigrateSchema(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
@@ -165,7 +208,11 @@ func MigrateSchema(db *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&schemaMigration{}); err != nil {
|
||||
return fmt.Errorf("初始化数据库迁移记录:%w", err)
|
||||
}
|
||||
for _, item := range schemaMigrations {
|
||||
plan, err := migrationsForDatabase(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range plan {
|
||||
var applied schemaMigration
|
||||
err := tx.First(&applied, "version = ?", item.version).Error
|
||||
if err == nil {
|
||||
@@ -208,7 +255,11 @@ func ReadSchemaStatus(db *gorm.DB) (SchemaStatus, error) {
|
||||
}
|
||||
|
||||
func validateMigrationRecords(db *gorm.DB) error {
|
||||
for _, item := range schemaMigrations {
|
||||
plan, err := migrationsForDatabase(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range plan {
|
||||
var applied schemaMigration
|
||||
if err := db.First(&applied, "version = ?", item.version).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
|
||||
119
backend/internal/database/migrations_lineage_test.go
Normal file
119
backend/internal/database/migrations_lineage_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func legacyFolderMigrationDatabase(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:" + t.Name() + "?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range schemaMigrations[:5] {
|
||||
if err := item.apply(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now().UTC()}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := migrateSchemaV7(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, column := range []string{"playback_status", "playback_object_key", "playback_error"} {
|
||||
if db.Migrator().HasColumn(&model.Resource{}, column) {
|
||||
if err := db.Migrator().DropColumn(&model.Resource{}, column); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.Create(&schemaMigration{Version: 6, Name: "asset_library_folders", Checksum: assetLibraryFoldersChecksum, AppliedAt: time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMigrateSchemaPreservesLegacyFolderMigration(t *testing.T) {
|
||||
db := legacyFolderMigrationDatabase(t)
|
||||
var before schemaMigration
|
||||
if err := db.First(&before, "version = 6").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO asset_folders (id, user_id, name) VALUES ('kept-folder', 'owner', 'Keep me')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RequireSchemaVersion(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var after schemaMigration
|
||||
if err := db.First(&after, "version = 6").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before.Name != after.Name || before.Checksum != after.Checksum || !before.AppliedAt.Equal(after.AppliedAt) {
|
||||
t.Fatalf("historical record changed: before=%+v after=%+v", before, after)
|
||||
}
|
||||
var playback schemaMigration
|
||||
if err := db.First(&playback, "version = 7").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if playback.Name != "resource_playback_variant" || playback.Checksum != resourcePlaybackChecksum {
|
||||
t.Fatalf("unexpected playback migration: %+v", playback)
|
||||
}
|
||||
for _, column := range []string{"playback_status", "playback_object_key", "playback_error"} {
|
||||
if !db.Migrator().HasColumn(&model.Resource{}, column) {
|
||||
t.Fatalf("missing playback column %s", column)
|
||||
}
|
||||
}
|
||||
var name string
|
||||
if err := db.Raw("SELECT name FROM asset_folders WHERE id = 'kept-folder'").Scan(&name).Error; err != nil || name != "Keep me" {
|
||||
t.Fatalf("folder data changed: %q %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRejectsUnknownLegacyLineage(t *testing.T) {
|
||||
for _, scenario := range []string{"checksum", "name", "version-seven"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
db := legacyFolderMigrationDatabase(t)
|
||||
switch scenario {
|
||||
case "checksum":
|
||||
if err := db.Model(&schemaMigration{}).Where("version = 6").Update("checksum", "unknown").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "name":
|
||||
if err := db.Model(&schemaMigration{}).Where("version = 6").Update("name", "unknown").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "version-seven":
|
||||
if err := db.Create(&schemaMigration{Version: 7, Name: "asset_library_folders", Checksum: assetLibraryFoldersChecksum, AppliedAt: time.Now().UTC()}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := MigrateSchema(db); err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("expected lineage rejection, got %v", err)
|
||||
}
|
||||
if db.Migrator().HasColumn(&model.Resource{}, "playback_status") {
|
||||
t.Fatal("rejected migration changed resource schema")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,31 @@ func TestMigrateSchemaRecordsAndValidatesVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaV8AllowsReusingArchivedLogicalModelCode(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-logical-model-active-code?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE logical_models (id text PRIMARY KEY, code text NOT NULL, archived_at datetime)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`CREATE UNIQUE INDEX idx_logical_models_code ON logical_models(code)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO logical_models(id, code, archived_at) VALUES ('archived', 'gpt-image-2', CURRENT_TIMESTAMP)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateSchemaV8(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO logical_models(id, code, archived_at) VALUES ('active', 'gpt-image-2', NULL)`).Error; err != nil {
|
||||
t.Fatalf("reusing archived code after migration: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO logical_models(id, code, archived_at) VALUES ('duplicate', 'gpt-image-2', NULL)`).Error; err == nil {
|
||||
t.Fatal("active logical model code must remain unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRejectsChecksumMismatch(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-checksum?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user