chore(publish): 增加历史草稿搬迁脚本(含校验与 dry-run)
This commit is contained in:
255
scripts/migrate_single_table/main.go
Normal file
255
scripts/migrate_single_table/main.go
Normal file
@ -0,0 +1,255 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user