Files
backend_v2/scripts/migrate_single_table/main.go

256 lines
8.6 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.

// 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),且不删任何数据;删草稿表由后续迁移人工执行。
package main
import (
"context"
"flag"
"fmt"
"log"
"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 草稿都必须能在正式表找到对应实体行,
// 否则说明有「只存在于草稿」的内容,需要人工处理,不能盲目删表。
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 s.is_deleted = 0)`).Scan(&orphans).Error; err != nil {
return fmt.Errorf("校验街拍孤儿草稿: %w", err)
}
if orphans > 0 {
return fmt.Errorf("有 %d 条 approved 街拍草稿在正式表找不到对应记录,请人工处理后重跑", orphans)
}
total, err := migrateRunways(ctx, db, dryRun)
if err != nil {
return err
}
streetTotal, err := migrateStreets(ctx, db, dryRun)
if err != nil {
return err
}
fmt.Printf("搬迁完成:走秀草稿 %d 条,街拍草稿 %d 条\n", total, streetTotal)
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
db.WithContext(ctx).Model(&model.BrandRunwayDraftImage{}).
Where("draft_id = ? AND is_deleted = 0", d.ID).Count(&n)
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
db.WithContext(ctx).Model(&model.StreetSnapDraftImage{}).
Where("draft_id = ? AND is_deleted = 0", d.ID).Count(&n)
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
}