package main import ( "errors" "fmt" "log" "os" "reflect" "strings" "time" "infinite-canvas/backend/internal/config" "infinite-canvas/backend/internal/database" "infinite-canvas/backend/internal/model" "gorm.io/gorm" "gorm.io/gorm/logger" ) type tableMigration struct { name string run func(source *gorm.DB, target *gorm.DB, copyRows bool) (int, error) } func main() { config.LoadDotEnv() sourcePath := strings.TrimSpace(os.Getenv("SQLITE_SOURCE_PATH")) targetDSN := strings.TrimSpace(os.Getenv("DATABASE_URL")) if sourcePath == "" || targetDSN == "" { log.Fatal("必须配置 SQLITE_SOURCE_PATH 和 DATABASE_URL") } if _, err := os.Stat(sourcePath); err != nil { log.Fatalf("读取 SQLite 源文件失败:%v", err) } source, err := database.Open(database.Config{Driver: "sqlite", DSN: "file:" + sourcePath + "?mode=ro&_busy_timeout=5000"}) if err != nil { log.Fatalf("连接 SQLite 失败:%v", err) } target, err := database.Open(database.Config{Driver: "postgres", DSN: targetDSN}) if err != nil { log.Fatalf("连接 PostgreSQL 失败:%v", err) } if err := verifySQLite(source); err != nil { log.Fatalf("SQLite 完整性检查失败:%v", err) } if err := verifyMigrationCoverage(source); err != nil { log.Fatalf("迁移表清单检查失败:%v", err) } source = source.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) target = target.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) // PostgreSQL 的 DDL 参与事务;任一表复制或核对失败都会回滚整个新库结构。 if err := target.Transaction(func(tx *gorm.DB) error { tableCount, err := publicTableCount(tx) if err != nil { return err } copyRows := tableCount == 0 if copyRows { if err := database.MigrateSchema(tx); err != nil { return fmt.Errorf("创建目标表结构:%w", err) } } else if tableCount != int64(len(migrations())) { return fmt.Errorf("PostgreSQL public schema 已有 %d 张表,拒绝覆盖或补写", tableCount) } total := 0 for _, migration := range migrations() { count, err := migration.run(source, tx, copyRows) if err != nil { return fmt.Errorf("迁移表 %s:%w", migration.name, err) } total += count log.Printf("已迁移并核对 %s:%d 行", migration.name, count) } if copyRows { log.Printf("全量迁移核对完成:%d 张表,%d 行", len(migrations()), total) } else { log.Printf("目标库已有完整迁移结果,未重复写入:%d 张表,%d 行", len(migrations()), total) } return nil }); err != nil { log.Fatal(err) } } func verifySQLite(db *gorm.DB) error { var result string if err := db.Raw("PRAGMA quick_check").Scan(&result).Error; err != nil { return err } if result != "ok" { return fmt.Errorf("quick_check 返回 %q", result) } return nil } func publicTableCount(db *gorm.DB) (int64, error) { var count int64 err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'").Scan(&count).Error return count, err } func migrateTable[T any](name string) tableMigration { return tableMigration{ name: name, run: func(source *gorm.DB, target *gorm.DB, copyRows bool) (int, error) { primaryKey, err := primaryKeyColumn[T](source) if err != nil { return 0, err } var sourceRows []T if err := source.Order(primaryKey).Find(&sourceRows).Error; err != nil { return 0, err } if copyRows && len(sourceRows) > 0 { if err := target.CreateInBatches(&sourceRows, 100).Error; err != nil { return 0, err } } var targetRows []T if err := target.Order(primaryKey).Find(&targetRows).Error; err != nil { return 0, err } if !equivalent(reflect.ValueOf(sourceRows), reflect.ValueOf(targetRows)) { return 0, errors.New("源数据与目标数据逐字段核对不一致") } return len(sourceRows), nil }, } } func primaryKeyColumn[T any](db *gorm.DB) (string, error) { statement := &gorm.Statement{DB: db} if err := statement.Parse(new(T)); err != nil { return "", err } if len(statement.Schema.PrimaryFields) == 0 { return "", fmt.Errorf("表 %s 必须有主键", statement.Schema.Table) } columns := make([]string, 0, len(statement.Schema.PrimaryFields)) for _, field := range statement.Schema.PrimaryFields { columns = append(columns, field.DBName) } return strings.Join(columns, ", "), nil } var timeType = reflect.TypeOf(time.Time{}) func equivalent(left reflect.Value, right reflect.Value) bool { if !left.IsValid() || !right.IsValid() { return left.IsValid() == right.IsValid() } if left.Type() != right.Type() { return false } if left.Type() == timeType { leftTime := left.Interface().(time.Time).Truncate(time.Microsecond) rightTime := right.Interface().(time.Time).Truncate(time.Microsecond) return leftTime.Equal(rightTime) } switch left.Kind() { case reflect.Pointer, reflect.Interface: if left.IsNil() || right.IsNil() { return left.IsNil() == right.IsNil() } return equivalent(left.Elem(), right.Elem()) case reflect.Slice, reflect.Array: if left.Len() != right.Len() { return false } for index := 0; index < left.Len(); index++ { if !equivalent(left.Index(index), right.Index(index)) { return false } } return true case reflect.Struct: for index := 0; index < left.NumField(); index++ { field := left.Type().Field(index) if field.Tag.Get("gorm") == "-" || strings.Contains(field.Tag.Get("gorm"), "->") { continue } if !equivalent(left.Field(index), right.Field(index)) { return false } } return true default: return reflect.DeepEqual(left.Interface(), right.Interface()) } } func verifyMigrationCoverage(db *gorm.DB) error { expected := make(map[string]struct{}, len(database.Models())) for _, value := range database.Models() { statement := &gorm.Statement{DB: db} if err := statement.Parse(value); err != nil { return err } expected[statement.Schema.Table] = struct{}{} } for _, migration := range migrations() { if _, exists := expected[migration.name]; !exists { return fmt.Errorf("迁移清单包含未知表 %s", migration.name) } delete(expected, migration.name) } if len(expected) > 0 { missing := make([]string, 0, len(expected)) for name := range expected { missing = append(missing, name) } return fmt.Errorf("迁移清单缺少表:%s", strings.Join(missing, ", ")) } return nil } func migrations() []tableMigration { return []tableMigration{ migrateTable[model.Tool]("tools"), migrateTable[model.ToolFavorite]("tool_favorites"), migrateTable[model.User]("users"), migrateTable[model.AuthSession]("auth_sessions"), migrateTable[model.UserIdentity]("user_identities"), migrateTable[model.OAuthState]("o_auth_states"), migrateTable[model.EmailVerificationCode]("email_verification_codes"), migrateTable[model.ModelChannel]("model_channels"), migrateTable[model.ChannelModel]("channel_models"), migrateTable[model.ChannelModelPriceTier]("channel_model_price_tiers"), migrateTable[model.IDSequence]("id_sequences"), migrateTable[model.LogicalModel]("logical_models"), migrateTable[model.LogicalModelRevision]("logical_model_revisions"), migrateTable[model.LogicalModelRoute]("logical_model_routes"), migrateTable[model.RouteAttempt]("route_attempts"), migrateTable[model.ApiCallLog]("api_call_logs"), migrateTable[model.ModelPricing]("model_pricings"), migrateTable[model.CreditAccount]("credit_accounts"), migrateTable[model.CreditLedgerEntry]("credit_ledger_entries"), migrateTable[model.BillingOrder]("billing_orders"), migrateTable[model.TopupProduct]("topup_products"), migrateTable[model.PaymentProviderConfig]("payment_provider_configs"), migrateTable[model.PaymentOrder]("payment_orders"), migrateTable[model.PaymentNotification]("payment_notifications"), migrateTable[model.PaymentReconciliationRun]("payment_reconciliation_runs"), migrateTable[model.PaymentReconciliationItem]("payment_reconciliation_items"), migrateTable[model.RedeemBatch]("redeem_batches"), migrateTable[model.RedeemCode]("redeem_codes"), migrateTable[model.AdminAuditEvent]("admin_audit_events"), migrateTable[model.UserDailyActivity]("user_daily_activities"), migrateTable[model.SystemSetting]("system_settings"), migrateTable[model.PluginPlatformState]("plugin_platform_states"), migrateTable[model.UserPluginState]("user_plugin_states"), migrateTable[model.ArkPrivateAssetBinding]("ark_private_asset_bindings"), migrateTable[model.UserOSSSetting]("user_oss_settings"), migrateTable[model.StorageLocation]("storage_locations"), migrateTable[model.UserDailyUploadUsage]("user_daily_upload_usages"), migrateTable[model.Skill]("skills"), migrateTable[model.SkillVersion]("skill_versions"), migrateTable[model.SkillFile]("skill_files"), migrateTable[model.UserSkillState]("user_skill_states"), migrateTable[model.Resource]("resources"), migrateTable[model.ResourceDeletionJob]("resource_deletion_jobs"), migrateTable[model.AnnouncementImageDraft]("announcement_image_drafts"), migrateTable[model.Asset]("assets"), migrateTable[model.AssetFolder]("asset_folders"), migrateTable[model.ProjectAssetLink]("project_asset_links"), migrateTable[model.ProjectAssetFolder]("project_asset_folders"), migrateTable[model.ProjectAssetCandidate]("project_asset_candidates"), migrateTable[model.AssetVersion]("asset_versions"), migrateTable[model.AssetRepresentation]("asset_representations"), migrateTable[model.VoiceProfile]("voice_profiles"), migrateTable[model.CharacterVoiceBinding]("character_voice_bindings"), migrateTable[model.Project]("projects"), migrateTable[model.StyleProfile]("style_profiles"), migrateTable[model.ProjectUnit]("project_units"), migrateTable[model.CanvasUnitLink]("canvas_unit_links"), migrateTable[model.Shot]("shots"), migrateTable[model.ShotRevision]("shot_revisions"), migrateTable[model.ShotArtifact]("shot_artifacts"), migrateTable[model.ShotAssetReference]("shot_asset_references"), migrateTable[model.WorkflowTemplateVersion]("workflow_template_versions"), migrateTable[model.WorkflowInstance]("workflow_instances"), migrateTable[model.WorkflowStepInstance]("workflow_step_instances"), migrateTable[model.WorkflowStepTask]("workflow_step_tasks"), migrateTable[model.ProductionTaskLink]("production_task_links"), migrateTable[model.CanvasProject]("canvas_projects"), migrateTable[model.CanvasSnapshot]("canvas_snapshots"), migrateTable[model.CanvasSnapshotResource]("canvas_snapshot_resources"), migrateTable[model.CanvasShare]("canvas_shares"), migrateTable[model.PromptTemplate]("prompt_templates"), migrateTable[model.UserPromptCustomization]("user_prompt_customizations"), migrateTable[model.Announcement]("announcements"), migrateTable[model.UserAnnouncementRead]("user_announcement_reads"), migrateTable[model.BannerAnnouncement]("banner_announcements"), migrateTable[model.CreationRun]("creation_runs"), migrateTable[model.CreationSubmission]("creation_submissions"), migrateTable[model.Task]("tasks"), migrateTable[model.CloudAgentExecution]("cloud_agent_executions"), migrateTable[model.CloudAgentEventRecord]("cloud_agent_event_records"), migrateTable[model.CloudAgentMessageRecord]("cloud_agent_message_records"), migrateTable[model.CloudAgentCanvasMutation]("cloud_agent_canvas_mutations"), migrateTable[model.CloudAgentResourceLease]("cloud_agent_resource_leases"), migrateTable[model.AgentProfile]("agent_profiles"), migrateTable[model.AgentLesson]("agent_lessons"), migrateTable[model.AgentMemorySetting]("agent_memory_settings"), migrateTable[model.TaskTextDelta]("task_text_delta"), migrateTable[model.TaskLog]("task_logs"), migrateTable[model.Result]("results"), } }