// migrate_single_table 把历史草稿数据搬迁进正式表(单表发布模型的数据迁移,一次性执行)。 // // 用法: // // go run ./scripts/migrate_single_table -dry-run # 只统计与校验,不写库 // go run ./scripts/migrate_single_table # 真正搬迁 // // 搬迁规则: // - 正式表既有行:status 已由迁移 2026-09-22-01 置为 published,本脚本不动。 // - pending / rejected 草稿:插入正式表并保留原状态,草稿图片插入正式图片表。 // - approved 草稿:其内容当年已由「晋升」写入正式表,**不重复插入**,只丢弃元数据。 // - 街拍副图:parent_image_id 从「草稿图片 id」改写为「新正式图片 id」;映射缺失则降级为主图。 // // 校验不通过即中止(退出码 1),且不删任何数据;删草稿表由后续迁移人工执行。 // // 实体键口径必须与唯一索引**同源**(决定撞不撞键):漏写 title 之类的条件会让护栏比索引更松, // 于是「本该拦住」的情况被放行,直到插入时才失败(见下方半搬迁说明)。 // - 走秀:brand_runways (brand_id, season_code, collection_type) WHERE is_deleted = 0(uq_br_entity) // - 街拍:street_snaps (city, year, COALESCE(title,'')) WHERE is_deleted = 0(uq_ss_entity, // 由 db/migrations/2026-09-22-04-street-entity-key-title.sql 细化) // // 为什么撞键校验必须在**任何写入之前**完成:搬迁是逐条草稿独立事务、首个错误即返回, // 中途撞键会留下「前几条已进正式表、后几条只在草稿表」的半搬迁状态;而本脚本不标记 // 「已搬过」,重跑会在第一条已搬草稿处再次撞键 —— 既不重复也无法续跑。 // 此时若接着执行删草稿表,未搬草稿的内容就永久丢失了。 package main import ( "context" "flag" "fmt" "log" "strings" "fashionapi/internal/config" "fashionapi/internal/database" "fashionapi/internal/model" "gorm.io/gorm" ) func main() { dryRun := flag.Bool("dry-run", false, "只统计与校验,不写库") flag.Parse() cfg, err := config.Load("") if err != nil { log.Fatalf("加载配置失败: %v", err) } db, err := database.New(cfg.Database) if err != nil { log.Fatalf("连接数据库失败: %v", err) } defer database.Close(db) ctx := context.Background() if err := run(ctx, db, *dryRun); err != nil { log.Fatalf("搬迁失败: %v", err) } if *dryRun { fmt.Println("dry-run 结束:未写入任何数据") } } func run(ctx context.Context, db *gorm.DB, dryRun bool) error { // 1) 前置校验:每个 approved 草稿都必须能在正式表找到对应实体行, // 否则说明有「只存在于草稿」的内容,需要人工处理,不能盲目删表。 // 实体键口径与唯一索引一致(街拍含 title):护栏若比实体键松, // 会把「同城同年但标题不同」的 approved 草稿误判为「已晋升」而放行, // 删草稿表时就静默丢掉这篇内容 —— 而这道校验的唯一职责正是挡住它。 var orphans int64 if err := db.WithContext(ctx).Raw(` SELECT count(*) FROM brand_runway_drafts d WHERE d.status = 'approved' AND d.is_deleted = 0 AND NOT EXISTS ( SELECT 1 FROM brand_runways r WHERE r.brand_id = d.brand_id AND r.season_code = d.season_code AND r.collection_type = d.collection_type AND r.is_deleted = 0)`).Scan(&orphans).Error; err != nil { return fmt.Errorf("校验走秀孤儿草稿: %w", err) } if orphans > 0 { return fmt.Errorf("有 %d 条 approved 走秀草稿在正式表找不到对应记录,请人工处理后重跑", orphans) } if err := db.WithContext(ctx).Raw(` SELECT count(*) FROM street_snap_drafts d WHERE d.status = 'approved' AND d.is_deleted = 0 AND NOT EXISTS ( SELECT 1 FROM street_snaps s WHERE s.city = d.city AND s.year = d.year AND COALESCE(s.title, '') = COALESCE(d.title, '') AND s.is_deleted = 0)`).Scan(&orphans).Error; err != nil { return fmt.Errorf("校验街拍孤儿草稿: %w", err) } if orphans > 0 { return fmt.Errorf("有 %d 条 approved 街拍草稿在正式表找不到对应记录,请人工处理后重跑", orphans) } // 2) 对称前置校验:待搬迁(pending/rejected)草稿的实体键若在正式表已存在, // 插入必然撞唯一索引;必须在任何写入之前中止(理由见文件头)。 if err := ensureNoEntityKeyConflict(ctx, db); err != nil { return err } // 3) 守恒核对基准:搬迁前记下草稿侧待搬条数与图片全量行数。 // 计数一律用全量行(含软删行)——搬迁是整行照搬,软删行也会进正式表, // 只有同口径才能与搬迁后正式表的增量对上。 want, err := draftSideCounts(ctx, db) if err != nil { return err } imgBefore, err := formalImageCounts(ctx, db) if err != nil { return err } runwayMoved, err := migrateRunways(ctx, db, dryRun) if err != nil { return fmt.Errorf("走秀侧已提交 %d 条后中止: %w", runwayMoved, err) } streetMoved, err := migrateStreets(ctx, db, dryRun) if err != nil { return fmt.Errorf("街拍侧已提交 %d 条后中止: %w", streetMoved, err) } if dryRun { fmt.Printf("dry-run 统计:走秀待搬迁 %d 条,街拍待搬迁 %d 条\n", runwayMoved, streetMoved) return nil } // 4) 守恒核对(设计文档 §8 第 2 步第 2 条):任何一项不等即中止。 // 这是「搬迁条数 ≠ 待搬条数」唯一能自动报警的手段, // 校验不过绝不能进入删草稿表(第 3 步)。 if err := verifyConservation(ctx, db, want, runwayMoved, streetMoved, imgBefore); err != nil { return err } fmt.Printf("搬迁完成:走秀草稿 %d 条,街拍草稿 %d 条\n", runwayMoved, streetMoved) return nil } // ensureNoEntityKeyConflict 在任何写入之前挡住「待搬迁草稿撞唯一索引」。 // 与唯一索引同源:走秀 (brand_id, season_code, collection_type), // 街拍 (city, year, COALESCE(title,''))。 func ensureNoEntityKeyConflict(ctx context.Context, db *gorm.DB) error { states := []string{model.StatusPending, model.StatusRejected} var conflicts int64 if err := db.WithContext(ctx).Raw(` SELECT count(*) FROM brand_runway_drafts d WHERE d.status IN ? AND d.is_deleted = 0 AND EXISTS ( SELECT 1 FROM brand_runways r WHERE r.brand_id = d.brand_id AND r.season_code = d.season_code AND r.collection_type = d.collection_type AND r.is_deleted = 0)`, states).Scan(&conflicts).Error; err != nil { return fmt.Errorf("校验走秀待搬草稿实体键冲突: %w", err) } if conflicts > 0 { return fmt.Errorf("有 %d 条待搬迁走秀草稿的实体键在正式表已存在(uq_br_entity 会拒绝插入),请人工处理后重跑", conflicts) } if err := db.WithContext(ctx).Raw(` SELECT count(*) FROM street_snap_drafts d WHERE d.status IN ? AND d.is_deleted = 0 AND EXISTS ( SELECT 1 FROM street_snaps s WHERE s.city = d.city AND s.year = d.year AND COALESCE(s.title, '') = COALESCE(d.title, '') AND s.is_deleted = 0)`, states).Scan(&conflicts).Error; err != nil { return fmt.Errorf("校验街拍待搬草稿实体键冲突: %w", err) } if conflicts > 0 { return fmt.Errorf("有 %d 条待搬迁街拍草稿的实体键在正式表已存在(uq_ss_entity 会拒绝插入),请人工处理后重跑", conflicts) } return nil } // migrationCounts 草稿侧待搬迁条数快照(守恒核对的「应搬」基准)。计数为全量行(含软删行)。 type migrationCounts struct { runwayDrafts int64 // 待搬迁走秀草稿条数 runwayImgs int64 // 这些草稿名下走秀图片全量行数 streetDrafts int64 // 待搬迁街拍草稿条数 streetImgs int64 // 这些草稿名下街拍图片全量行数 } // formalCounts 正式图片表全量行数快照(守恒核对的「实搬」基准)。 type formalCounts struct { runwayImgs int64 streetImgs int64 } // draftSideCounts 统计待搬迁草稿侧的条数与图片行数。 // 图片刻意不按 is_deleted 过滤:搬迁整行照搬,软删行同样会进正式表。 func draftSideCounts(ctx context.Context, db *gorm.DB) (migrationCounts, error) { var c migrationCounts states := []string{model.StatusPending, model.StatusRejected} if err := db.WithContext(ctx).Raw( `SELECT count(*) FROM brand_runway_drafts WHERE is_deleted = 0 AND status IN ?`, states). Scan(&c.runwayDrafts).Error; err != nil { return c, fmt.Errorf("统计走秀待搬草稿: %w", err) } if err := db.WithContext(ctx).Raw(` SELECT count(*) FROM brand_runway_draft_images i JOIN brand_runway_drafts d ON d.id = i.draft_id WHERE d.is_deleted = 0 AND d.status IN ?`, states).Scan(&c.runwayImgs).Error; err != nil { return c, fmt.Errorf("统计走秀草稿图片: %w", err) } if err := db.WithContext(ctx).Raw( `SELECT count(*) FROM street_snap_drafts WHERE is_deleted = 0 AND status IN ?`, states). Scan(&c.streetDrafts).Error; err != nil { return c, fmt.Errorf("统计街拍待搬草稿: %w", err) } if err := db.WithContext(ctx).Raw(` SELECT count(*) FROM street_snap_draft_images i JOIN street_snap_drafts d ON d.id = i.draft_id WHERE d.is_deleted = 0 AND d.status IN ?`, states).Scan(&c.streetImgs).Error; err != nil { return c, fmt.Errorf("统计街拍草稿图片: %w", err) } return c, nil } // formalImageCounts 统计正式图片表的全量行数(含软删行)。 func formalImageCounts(ctx context.Context, db *gorm.DB) (formalCounts, error) { var c formalCounts if err := db.WithContext(ctx).Model(&model.BrandRunwayImage{}).Count(&c.runwayImgs).Error; err != nil { return c, fmt.Errorf("统计正式走秀图片: %w", err) } if err := db.WithContext(ctx).Model(&model.StreetSnapImage{}).Count(&c.streetImgs).Error; err != nil { return c, fmt.Errorf("统计正式街拍图片: %w", err) } return c, nil } // verifyConservation 搬迁后的守恒核对(设计文档 §8 第 2 步第 2 条): // 待审/驳回草稿的**记录数与图片数**在迁移前后必须守恒,任何一项不等即返回错误。 // 图片用「正式表新增行数」对比「草稿侧待搬全量行数」——两侧都不含 is_deleted 过滤,口径一致。 func verifyConservation(ctx context.Context, db *gorm.DB, want migrationCounts, movedRunway, movedStreet int, before formalCounts) error { after, err := formalImageCounts(ctx, db) if err != nil { return err } var bad []string if int64(movedRunway) != want.runwayDrafts { bad = append(bad, fmt.Sprintf("走秀草稿条数:实际搬迁 %d ≠ 待搬 %d", movedRunway, want.runwayDrafts)) } if int64(movedStreet) != want.streetDrafts { bad = append(bad, fmt.Sprintf("街拍草稿条数:实际搬迁 %d ≠ 待搬 %d", movedStreet, want.streetDrafts)) } if got := after.runwayImgs - before.runwayImgs; got != want.runwayImgs { bad = append(bad, fmt.Sprintf("走秀图片行数:正式表新增 %d ≠ 草稿侧待搬 %d", got, want.runwayImgs)) } if got := after.streetImgs - before.streetImgs; got != want.streetImgs { bad = append(bad, fmt.Sprintf("街拍图片行数:正式表新增 %d ≠ 草稿侧待搬 %d", got, want.streetImgs)) } if len(bad) > 0 { return fmt.Errorf("守恒核对不通过:%s", strings.Join(bad, ";")) } fmt.Printf("守恒核对通过:走秀草稿 %d 条/图片 %d 行,街拍草稿 %d 条/图片 %d 行\n", movedRunway, want.runwayImgs, movedStreet, want.streetImgs) return nil } // migrateRunways 搬迁 pending / rejected 的走秀草稿(含图片),返回搬迁条数。 func migrateRunways(ctx context.Context, db *gorm.DB, dryRun bool) (int, error) { var drafts []model.BrandRunwayDraft if err := db.WithContext(ctx). Where("status IN ? AND is_deleted = 0", []string{model.StatusPending, model.StatusRejected}). Order("id ASC").Find(&drafts).Error; err != nil { return 0, err } fmt.Printf("走秀待搬迁草稿:%d 条(图片数为全量行,含软删行)\n", len(drafts)) if dryRun { for _, d := range drafts { var n int64 if cErr := db.WithContext(ctx).Model(&model.BrandRunwayDraftImage{}). Where("draft_id = ?", d.ID).Count(&n).Error; cErr != nil { log.Printf("统计走秀草稿 %d 图片失败: %v", d.ID, cErr) } fmt.Printf(" draft=%d status=%s title=%q 图片=%d\n", d.ID, d.Status, d.TitleEn, n) } return len(drafts), nil } moved := 0 for _, d := range drafts { err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { rw := model.BrandRunway{ JobID: d.JobID, BrandID: d.BrandID, TitleEn: d.TitleEn, TitleCn: d.TitleCn, DescriptionEn: d.DescriptionEn, DescriptionCn: d.DescriptionCn, Year: d.Year, Season: d.Season, CollectionType: d.CollectionType, SeasonCode: d.SeasonCode, Cover: d.Cover, ImageCount: d.ImageCount, Status: d.Status, Reviewer: d.Reviewer, RejectReason: d.RejectReason, IsDeleted: d.IsDeleted, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, } if cErr := tx.Create(&rw).Error; cErr != nil { return cErr } var imgs []model.BrandRunwayDraftImage if fErr := tx.Where("draft_id = ?", d.ID).Order("sort_order ASC, id ASC").Find(&imgs).Error; fErr != nil { return fErr } if len(imgs) == 0 { return nil } rows := make([]model.BrandRunwayImage, 0, len(imgs)) for _, im := range imgs { rows = append(rows, model.BrandRunwayImage{ RunwayID: rw.ID, BrandID: d.BrandID, Image: im.Image, Name: im.Name, SortOrder: im.SortOrder, LookIndex: im.LookIndex, IsDetail: im.IsDetail, IsDeleted: im.IsDeleted, Phash: im.Phash, IsDuplicate: im.IsDuplicate, DupOf: im.DupOf, CreatedAt: im.CreatedAt, UpdatedAt: im.UpdatedAt, }) } return tx.Create(&rows).Error }) if err != nil { return moved, fmt.Errorf("搬迁走秀草稿 %d: %w", d.ID, err) } moved++ } return moved, nil } // migrateStreets 搬迁 pending / rejected 的街拍草稿,并改写副图父引用。 func migrateStreets(ctx context.Context, db *gorm.DB, dryRun bool) (int, error) { var drafts []model.StreetSnapDraft if err := db.WithContext(ctx). Where("status IN ? AND is_deleted = 0", []string{model.StatusPending, model.StatusRejected}). Order("id ASC").Find(&drafts).Error; err != nil { return 0, err } fmt.Printf("街拍待搬迁草稿:%d 条(图片数为全量行,含软删行)\n", len(drafts)) if dryRun { for _, d := range drafts { var n int64 if cErr := db.WithContext(ctx).Model(&model.StreetSnapDraftImage{}). Where("draft_id = ?", d.ID).Count(&n).Error; cErr != nil { log.Printf("统计街拍草稿 %d 图片失败: %v", d.ID, cErr) } fmt.Printf(" draft=%d status=%s title=%q city=%s 图片=%d\n", d.ID, d.Status, d.Title, d.City, n) } return len(drafts), nil } moved := 0 for _, d := range drafts { err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { snap := model.StreetSnap{ JobID: d.JobID, Title: d.Title, Year: d.Year, City: d.City, Cover: d.Cover, ImageCount: d.ImageCount, Status: d.Status, Reviewer: d.Reviewer, RejectReason: d.RejectReason, IsDeleted: d.IsDeleted, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, } if cErr := tx.Create(&snap).Error; cErr != nil { return cErr } var imgs []model.StreetSnapDraftImage if fErr := tx.Where("draft_id = ?", d.ID).Order("sort_order ASC, id ASC").Find(&imgs).Error; fErr != nil { return fErr } if len(imgs) == 0 { return nil } // 第一遍:插入主图,记下「草稿图片 id → 新正式图片 id」。 idMap := make(map[uint32]uint32, len(imgs)) mains := make([]model.StreetSnapImage, 0, len(imgs)) for _, im := range imgs { if im.IsDetail == 1 { continue } mains = append(mains, model.StreetSnapImage{ SnapID: snap.ID, Image: im.Image, Name: im.Name, SortOrder: im.SortOrder, IsDetail: 0, IsDeleted: im.IsDeleted, Phash: im.Phash, IsDuplicate: im.IsDuplicate, DupOf: im.DupOf, CreatedAt: im.CreatedAt, UpdatedAt: im.UpdatedAt, }) } for i := range mains { if cErr := tx.Create(&mains[i]).Error; cErr != nil { return cErr } } mi := 0 for _, im := range imgs { if im.IsDetail == 1 { continue } idMap[im.ID] = mains[mi].ID mi++ } // 第二遍:插入副图并把父引用从小表 id 改写成新正式行 id;映射缺失则降级为主图。 details := make([]model.StreetSnapImage, 0, len(imgs)-len(mains)) for _, im := range imgs { if im.IsDetail != 1 { continue } row := model.StreetSnapImage{ SnapID: snap.ID, Image: im.Image, Name: im.Name, SortOrder: im.SortOrder, IsDetail: 1, IsDeleted: im.IsDeleted, Phash: im.Phash, IsDuplicate: im.IsDuplicate, DupOf: im.DupOf, CreatedAt: im.CreatedAt, UpdatedAt: im.UpdatedAt, } if newParent, ok := idMap[im.ParentImageID]; ok { row.ParentImageID = newParent } else { row.IsDetail = 0 // 父行缺失:按主图渲染,与读侧容错一致 } details = append(details, row) } if len(details) > 0 { if cErr := tx.Create(&details).Error; cErr != nil { return cErr } } return nil }) if err != nil { return moved, fmt.Errorf("搬迁街拍草稿 %d: %w", d.ID, err) } moved++ } return moved, nil }