Files
ttstd-infinite-canvas/backend/cmd/migrate-sqlite-postgres/main.go
ddcat666 b17877605e fix(release): 发布检查 - 修复 v1.6.1 后端测试失败
兑换码购买链接默认改为空,避免非法占位文案导致外观保存失败;迁移清单补上 upload_reservations;按首尾帧后的实测体积上调 Agent 工具 schema 预算;Grok 生图测试覆盖 response_format 开关的两种返回。
2026-10-06 14:51:43 +08:00

470 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
type tableMigration struct {
name string
run func(source *gorm.DB, target *gorm.DB, copyRows bool) (int, error)
}
func main() {
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 {
tableNames, err := publicTableNames(tx)
if err != nil {
return err
}
copyRows := len(tableNames) == 0
if copyRows {
if err := database.MigrateSchema(tx); err != nil {
return fmt.Errorf("创建目标表结构:%w", err)
}
} else if err := validateTargetTableNames(tableNames); err != nil {
return err
}
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)
}
if err := database.ReconcilePrefixedIDSequences(tx); err != nil {
return fmt.Errorf("校准可读 ID 序列:%w", err)
}
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 publicTableNames(db *gorm.DB) ([]string, error) {
var names []string
err := db.Raw(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
`).Scan(&names).Error
return names, err
}
// validateTargetTableNames compares the existing PostgreSQL schema against the
// same business-table list used by Models() and migrations().
// schema_migrations is created by database.MigrateSchema and is intentionally
// not part of the row-copy list, so it is allowed as the only system table.
func validateTargetTableNames(actual []string) error {
expected := make(map[string]struct{}, len(migrations()))
for _, migration := range migrations() {
expected[migration.name] = struct{}{}
}
actualSet := make(map[string]struct{}, len(actual))
for _, name := range actual {
actualSet[name] = struct{}{}
}
missing := make([]string, 0)
for name := range expected {
if _, ok := actualSet[name]; !ok {
missing = append(missing, name)
}
}
extra := make([]string, 0)
for name := range actualSet {
if name == "schema_migrations" {
continue
}
if _, ok := expected[name]; !ok {
extra = append(extra, name)
}
}
sort.Strings(missing)
sort.Strings(extra)
if len(missing) > 0 || len(extra) > 0 {
return fmt.Errorf("PostgreSQL public schema 与当前模型清单不一致,缺少表:%s,额外表:%s;拒绝覆盖或补写", strings.Join(missing, ", "), strings.Join(extra, ", "))
}
return nil
}
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 err := validateExplicitStringLengths(source, name, sourceRows); 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
},
}
}
// validateExplicitStringLengths catches values that SQLite accepts but
// PostgreSQL would reject for a GORM size:N column. SQLite does not enforce
// VARCHAR(N), so this check must happen before the first target insert.
func validateExplicitStringLengths[T any](db *gorm.DB, tableName string, rows []T) error {
statement := &gorm.Statement{DB: db}
if err := statement.Parse(new(T)); err != nil {
return err
}
if statement.Schema == nil {
return fmt.Errorf("表 %s 缺少 GORM schema", tableName)
}
for rowIndex := range rows {
rowValue := reflect.ValueOf(rows[rowIndex])
key := primaryKeyValue(statement.Schema.PrimaryFields, rowValue)
for _, field := range statement.Schema.Fields {
limit, ok := explicitStringSize(field)
if !ok || field.Serializer != nil || !field.Creatable || !field.Readable {
continue
}
value, zero := field.ValueOf(context.Background(), rowValue)
if zero {
continue
}
text, ok := stringValue(value)
if !ok {
continue
}
actual := utf8.RuneCountInString(text)
if actual <= limit {
continue
}
return fmt.Errorf("表 %s 第 %d 行(主键 %s)列 %s 长度 %d,超过 size:%d", tableName, rowIndex+1, key, field.DBName, actual, limit)
}
}
return nil
}
func explicitStringSize(field *schema.Field) (int, bool) {
// This adapter keeps the validation logic independent from GORM's inferred
// default size (often 255). Only an explicit size:N tag is a PostgreSQL
// contract we can prove from the model.
sizeText, ok := field.TagSettings["SIZE"]
if !ok || strings.TrimSpace(sizeText) == "" {
return 0, false
}
if strings.EqualFold(strings.TrimSpace(field.TagSettings["TYPE"]), "text") {
return 0, false
}
if field.IndirectFieldType.Kind() != reflect.String {
return 0, false
}
limit, err := strconv.Atoi(sizeText)
if err != nil || limit < 0 {
return 0, false
}
return limit, true
}
func stringValue(value any) (string, bool) {
if value == nil {
return "", false
}
rv := reflect.ValueOf(value)
for rv.Kind() == reflect.Interface || rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return "", false
}
rv = rv.Elem()
}
if rv.Kind() != reflect.String {
return "", false
}
return rv.String(), true
}
func primaryKeyValue(fields []*schema.Field, rowValue reflect.Value) string {
if len(fields) == 0 {
return "<无主键>"
}
parts := make([]string, 0, len(fields))
for _, field := range fields {
value, zero := field.ValueOf(context.Background(), rowValue)
if zero || value == nil {
parts = append(parts, field.DBName+"=<空>")
continue
}
if text, ok := stringValue(value); ok {
parts = append(parts, field.DBName+"="+text)
continue
}
parts = append(parts, field.DBName+"="+fmt.Sprint(value))
}
return strings.Join(parts, ",")
}
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.AuthVerification]("auth_verifications"),
migrateTable[model.NotificationQuota]("notification_quota"),
migrateTable[model.SMSChannel]("sms_channels"),
migrateTable[model.SMSRecord]("sms_records"),
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.UploadReservation]("upload_reservations"),
migrateTable[model.Skill]("skills"),
migrateTable[model.SkillLibraryCategory]("skill_library_categories"),
migrateTable[model.SkillVersion]("skill_versions"),
migrateTable[model.SkillFile]("skill_files"),
migrateTable[model.UserSkillState]("user_skill_states"),
migrateTable[model.BuiltinSkillTombstone]("builtin_skill_tombstones"),
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.CloudAgentPiSession]("cloud_agent_pi_sessions"),
migrateTable[model.CloudAgentGeminiCache]("cloud_agent_gemini_caches"),
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"),
}
}