Files
backend_v2/scripts/migrate_single_table/main.go

433 lines
17 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),且不删任何数据;删草稿表由后续迁移人工执行。
//
// 实体键口径必须与唯一索引**同源**(决定撞不撞键):漏写 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
}