From f5bb9ed4f924dd8559284f4f19960a936c6881a3 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 15 Aug 2026 17:00:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(database):=20=E7=B4=A0=E6=9D=90=E5=AD=98?= =?UTF-8?q?=E5=82=A8=20-=20=E6=89=A9=E5=AE=B9=E7=94=9F=E6=88=90=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=20ID=20=E5=8F=8A=E5=85=B3=E8=81=94=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/database/schema.go | 48 ++++++++ .../internal/database/schema_postgres_test.go | 106 ++++++++++++++++++ backend/internal/database/schema_test.go | 62 ++++++++++ backend/internal/model/models_project.go | 10 +- 4 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 backend/internal/database/schema_postgres_test.go create mode 100644 backend/internal/database/schema_test.go diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index a81de6ea3..f64611278 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -1,6 +1,10 @@ package database import ( + "database/sql" + "errors" + "fmt" + "infinite-canvas/backend/internal/model" "gorm.io/gorm" @@ -71,6 +75,9 @@ func MigrateSchema(db *gorm.DB) error { return err } } + if err := widenPostgresAssetIDColumns(db); err != nil { + return err + } if err := db.AutoMigrate(Models()...); err != nil { return err } @@ -83,3 +90,44 @@ func MigrateSchema(db *gorm.DB) error { } return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ON users(lower(email)) WHERE email <> ''").Error } + +type varcharColumnMigration struct { + table string + column string + statement string +} + +var assetIDColumnMigrations = []varcharColumnMigration{ + {table: "assets", column: "id", statement: `ALTER TABLE "assets" ALTER COLUMN "id" TYPE varchar(80)`}, + {table: "project_asset_links", column: "asset_id", statement: `ALTER TABLE "project_asset_links" ALTER COLUMN "asset_id" TYPE varchar(80)`}, + {table: "project_asset_candidates", column: "resolved_asset_id", statement: `ALTER TABLE "project_asset_candidates" ALTER COLUMN "resolved_asset_id" TYPE varchar(80)`}, + {table: "asset_versions", column: "asset_id", statement: `ALTER TABLE "asset_versions" ALTER COLUMN "asset_id" TYPE varchar(80)`}, +} + +// PostgreSQL Migrator 跳过主键列变更,素材 ID 扩容必须在 AutoMigrate 前显式执行。 +func widenPostgresAssetIDColumns(db *gorm.DB) error { + if db.Dialector.Name() != "postgres" { + return nil + } + for _, migration := range assetIDColumnMigrations { + var currentLength sql.NullInt64 + err := db.Raw( + "SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", + migration.table, + migration.column, + ).Row().Scan(¤tLength) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + return fmt.Errorf("检查 PostgreSQL 素材 ID 列 %s.%s:%w", migration.table, migration.column, err) + } + if !currentLength.Valid || currentLength.Int64 >= model.AssetIDMaxLength { + continue + } + if err := db.Exec(migration.statement).Error; err != nil { + return fmt.Errorf("扩容 PostgreSQL 素材 ID 列 %s.%s:%w", migration.table, migration.column, err) + } + } + return nil +} diff --git a/backend/internal/database/schema_postgres_test.go b/backend/internal/database/schema_postgres_test.go new file mode 100644 index 000000000..388f354b2 --- /dev/null +++ b/backend/internal/database/schema_postgres_test.go @@ -0,0 +1,106 @@ +package database + +import ( + "fmt" + "net/url" + "os" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +func TestPostgresAssetIDMigration(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("CANVAS_TEST_POSTGRES_DSN")) + if dsn == "" { + t.Skip("CANVAS_TEST_POSTGRES_DSN is not configured") + } + + base, err := Open(Config{Driver: "postgres", DSN: dsn}) + if err != nil { + t.Fatalf("open postgres: %v", err) + } + baseSQL, err := base.DB() + if err != nil { + t.Fatalf("postgres sql db: %v", err) + } + defer baseSQL.Close() + + schemaName := fmt.Sprintf("asset_id_migration_%d", time.Now().UnixNano()) + if err := base.Exec(`CREATE SCHEMA "` + schemaName + `"`).Error; err != nil { + t.Fatalf("create test schema: %v", err) + } + defer func() { + if err := base.Exec(`DROP SCHEMA IF EXISTS "` + schemaName + `" CASCADE`).Error; err != nil { + t.Errorf("drop test schema: %v", err) + } + }() + + testDSN, err := postgresDSNWithSearchPath(dsn, schemaName) + if err != nil { + t.Fatalf("test postgres dsn: %v", err) + } + db, err := Open(Config{Driver: "postgres", DSN: testDSN}) + if err != nil { + t.Fatalf("open test schema: %v", err) + } + dbSQL, err := db.DB() + if err != nil { + t.Fatalf("test schema sql db: %v", err) + } + defer dbSQL.Close() + + for _, statement := range []string{ + `CREATE TABLE assets (id varchar(36) PRIMARY KEY)`, + `CREATE TABLE project_asset_links (id varchar(36) PRIMARY KEY, asset_id varchar(36))`, + `CREATE TABLE project_asset_candidates (id varchar(36) PRIMARY KEY, resolved_asset_id varchar(36))`, + `CREATE TABLE asset_versions (id varchar(36) PRIMARY KEY, asset_id varchar(36))`, + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("create legacy table: %v", err) + } + } + if err := MigrateSchema(db); err != nil { + t.Fatalf("migrate schema: %v", err) + } + + for _, migration := range assetIDColumnMigrations { + var length int64 + if err := db.Raw( + "SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", + migration.table, + migration.column, + ).Scan(&length).Error; err != nil { + t.Fatalf("read migrated column %s.%s: %v", migration.table, migration.column, err) + } + if length != model.AssetIDMaxLength { + t.Fatalf("%s.%s length = %d, want %d", migration.table, migration.column, length, model.AssetIDMaxLength) + } + } + + assetID := "generation_" + strings.Repeat("a", 64) + if err := db.Create(&model.Asset{ID: assetID, UserID: "user-1", Kind: "image"}).Error; err != nil { + t.Fatalf("insert deterministic generation asset: %v", err) + } + if err := db.Create(&model.ProjectAssetLink{ID: "link-1", ProjectID: "project-1", AssetID: assetID}).Error; err != nil { + t.Fatalf("insert project asset link: %v", err) + } + if err := db.Create(&model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: "project-1", ResolvedAssetID: assetID}).Error; err != nil { + t.Fatalf("insert project asset candidate: %v", err) + } + if err := db.Create(&model.AssetVersion{ID: "version-1", AssetID: assetID, Version: 1}).Error; err != nil { + t.Fatalf("insert asset version: %v", err) + } +} + +func postgresDSNWithSearchPath(dsn string, schemaName string) (string, error) { + parsed, err := url.Parse(dsn) + if err != nil { + return "", err + } + query := parsed.Query() + query.Set("search_path", schemaName) + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} diff --git a/backend/internal/database/schema_test.go b/backend/internal/database/schema_test.go new file mode 100644 index 000000000..8945608b3 --- /dev/null +++ b/backend/internal/database/schema_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "fmt" + "strings" + "sync" + "testing" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm/schema" +) + +func TestAssetIDColumnsUseSharedLimit(t *testing.T) { + tests := []struct { + value any + field string + }{ + {value: &model.Asset{}, field: "ID"}, + {value: &model.ProjectAssetLink{}, field: "AssetID"}, + {value: &model.ProjectAssetCandidate{}, field: "ResolvedAssetID"}, + {value: &model.AssetVersion{}, field: "AssetID"}, + } + + for _, test := range tests { + parsed, err := schema.Parse(test.value, &sync.Map{}, schema.NamingStrategy{}) + if err != nil { + t.Fatalf("parse schema: %v", err) + } + field := parsed.LookUpField(test.field) + if field == nil { + t.Fatalf("field %s not found in %s", test.field, parsed.Table) + } + if field.Size != model.AssetIDMaxLength { + t.Fatalf("%s.%s size = %d, want %d", parsed.Table, field.DBName, field.Size, model.AssetIDMaxLength) + } + } +} + +func TestPostgresAssetIDMigrationsCoverEveryAssetIDColumn(t *testing.T) { + want := map[string]bool{ + "assets.id": false, + "project_asset_links.asset_id": false, + "project_asset_candidates.resolved_asset_id": false, + "asset_versions.asset_id": false, + } + for _, migration := range assetIDColumnMigrations { + key := migration.table + "." + migration.column + if _, exists := want[key]; !exists { + t.Fatalf("unexpected asset ID migration %s", key) + } + if !strings.Contains(migration.statement, fmt.Sprintf("varchar(%d)", model.AssetIDMaxLength)) { + t.Fatalf("asset ID migration %s does not use limit %d", key, model.AssetIDMaxLength) + } + want[key] = true + } + for key, covered := range want { + if !covered { + t.Fatalf("missing asset ID migration %s", key) + } + } +} diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index d2c2bce90..91b8170c6 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -2,6 +2,8 @@ package model import "time" +const AssetIDMaxLength = 80 + type Resource struct { ID string `json:"id" gorm:"primaryKey;size:36"` UserID string `json:"userId" gorm:"index;size:36;index:idx_resources_user_created,priority:1"` @@ -26,7 +28,7 @@ type Resource struct { } type Asset struct { - ID string `json:"id" gorm:"primaryKey;size:36"` + ID string `json:"id" gorm:"primaryKey;size:80"` UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"` Kind string `json:"kind" gorm:"index;size:24"` Category AssetCategory `json:"category" gorm:"index;size:32"` @@ -41,7 +43,7 @@ type Asset struct { type ProjectAssetLink struct { ID string `json:"id" gorm:"primaryKey;size:36"` ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"` + AssetID string `json:"assetId" gorm:"index;size:80;uniqueIndex:idx_project_asset_links_unique,priority:2"` CreatedAt time.Time `json:"createdAt"` } @@ -54,14 +56,14 @@ type ProjectAssetCandidate struct { Category AssetCategory `json:"category" gorm:"index;size:32"` Status string `json:"status" gorm:"index;size:32"` DetailsJSON string `json:"detailsJson" gorm:"type:text"` - ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:36"` + ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:80"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type AssetVersion struct { ID string `json:"id" gorm:"primaryKey;size:36"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_asset_versions_number,priority:1"` + AssetID string `json:"assetId" gorm:"index;size:80;uniqueIndex:idx_asset_versions_number,priority:1"` Version int `json:"version" gorm:"uniqueIndex:idx_asset_versions_number,priority:2"` Status AssetVersionStatus `json:"status" gorm:"index;size:24"` DefinitionJSON string `json:"definitionJson" gorm:"type:text"`