perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent 165b4443ab
commit 58f01d1eb6
107 changed files with 3025 additions and 1089 deletions

View File

@@ -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) {

View 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")
}
})
}
}

View File

@@ -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 {