fix(publish): 搬迁脚本补撞键前置校验与守恒核对,统一 dry-run 统计口径
This commit is contained in:
@ -12,6 +12,17 @@
|
|||||||
// - 街拍副图:parent_image_id 从「草稿图片 id」改写为「新正式图片 id」;映射缺失则降级为主图。
|
// - 街拍副图:parent_image_id 从「草稿图片 id」改写为「新正式图片 id」;映射缺失则降级为主图。
|
||||||
//
|
//
|
||||||
// 校验不通过即中止(退出码 1),且不删任何数据;删草稿表由后续迁移人工执行。
|
// 校验不通过即中止(退出码 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -19,6 +30,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"fashionapi/internal/config"
|
"fashionapi/internal/config"
|
||||||
"fashionapi/internal/database"
|
"fashionapi/internal/database"
|
||||||
@ -53,6 +65,9 @@ func main() {
|
|||||||
func run(ctx context.Context, db *gorm.DB, dryRun bool) error {
|
func run(ctx context.Context, db *gorm.DB, dryRun bool) error {
|
||||||
// 1) 前置校验:每个 approved 草稿都必须能在正式表找到对应实体行,
|
// 1) 前置校验:每个 approved 草稿都必须能在正式表找到对应实体行,
|
||||||
// 否则说明有「只存在于草稿」的内容,需要人工处理,不能盲目删表。
|
// 否则说明有「只存在于草稿」的内容,需要人工处理,不能盲目删表。
|
||||||
|
// 实体键口径与唯一索引一致(街拍含 title):护栏若比实体键松,
|
||||||
|
// 会把「同城同年但标题不同」的 approved 草稿误判为「已晋升」而放行,
|
||||||
|
// 删草稿表时就静默丢掉这篇内容 —— 而这道校验的唯一职责正是挡住它。
|
||||||
var orphans int64
|
var orphans int64
|
||||||
if err := db.WithContext(ctx).Raw(`
|
if err := db.WithContext(ctx).Raw(`
|
||||||
SELECT count(*) FROM brand_runway_drafts d
|
SELECT count(*) FROM brand_runway_drafts d
|
||||||
@ -71,22 +86,180 @@ SELECT count(*) FROM street_snap_drafts d
|
|||||||
WHERE d.status = 'approved' AND d.is_deleted = 0
|
WHERE d.status = 'approved' AND d.is_deleted = 0
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM street_snaps s
|
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 {
|
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)
|
return fmt.Errorf("校验街拍孤儿草稿: %w", err)
|
||||||
}
|
}
|
||||||
if orphans > 0 {
|
if orphans > 0 {
|
||||||
return fmt.Errorf("有 %d 条 approved 街拍草稿在正式表找不到对应记录,请人工处理后重跑", orphans)
|
return fmt.Errorf("有 %d 条 approved 街拍草稿在正式表找不到对应记录,请人工处理后重跑", orphans)
|
||||||
}
|
}
|
||||||
|
|
||||||
total, err := migrateRunways(ctx, db, dryRun)
|
// 2) 对称前置校验:待搬迁(pending/rejected)草稿的实体键若在正式表已存在,
|
||||||
|
// 插入必然撞唯一索引;必须在任何写入之前中止(理由见文件头)。
|
||||||
|
if err := ensureNoEntityKeyConflict(ctx, db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 守恒核对基准:搬迁前记下草稿侧待搬条数与图片全量行数。
|
||||||
|
// 计数一律用全量行(含软删行)——搬迁是整行照搬,软删行也会进正式表,
|
||||||
|
// 只有同口径才能与搬迁后正式表的增量对上。
|
||||||
|
want, err := draftSideCounts(ctx, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
streetTotal, err := migrateStreets(ctx, db, dryRun)
|
imgBefore, err := formalImageCounts(ctx, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("搬迁完成:走秀草稿 %d 条,街拍草稿 %d 条\n", total, streetTotal)
|
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,12 +271,14 @@ func migrateRunways(ctx context.Context, db *gorm.DB, dryRun bool) (int, error)
|
|||||||
Order("id ASC").Find(&drafts).Error; err != nil {
|
Order("id ASC").Find(&drafts).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
fmt.Printf("走秀待搬迁草稿:%d 条\n", len(drafts))
|
fmt.Printf("走秀待搬迁草稿:%d 条(图片数为全量行,含软删行)\n", len(drafts))
|
||||||
if dryRun {
|
if dryRun {
|
||||||
for _, d := range drafts {
|
for _, d := range drafts {
|
||||||
var n int64
|
var n int64
|
||||||
db.WithContext(ctx).Model(&model.BrandRunwayDraftImage{}).
|
if cErr := db.WithContext(ctx).Model(&model.BrandRunwayDraftImage{}).
|
||||||
Where("draft_id = ? AND is_deleted = 0", d.ID).Count(&n)
|
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)
|
fmt.Printf(" draft=%d status=%s title=%q 图片=%d\n", d.ID, d.Status, d.TitleEn, n)
|
||||||
}
|
}
|
||||||
return len(drafts), nil
|
return len(drafts), nil
|
||||||
@ -161,12 +336,14 @@ func migrateStreets(ctx context.Context, db *gorm.DB, dryRun bool) (int, error)
|
|||||||
Order("id ASC").Find(&drafts).Error; err != nil {
|
Order("id ASC").Find(&drafts).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
fmt.Printf("街拍待搬迁草稿:%d 条\n", len(drafts))
|
fmt.Printf("街拍待搬迁草稿:%d 条(图片数为全量行,含软删行)\n", len(drafts))
|
||||||
if dryRun {
|
if dryRun {
|
||||||
for _, d := range drafts {
|
for _, d := range drafts {
|
||||||
var n int64
|
var n int64
|
||||||
db.WithContext(ctx).Model(&model.StreetSnapDraftImage{}).
|
if cErr := db.WithContext(ctx).Model(&model.StreetSnapDraftImage{}).
|
||||||
Where("draft_id = ? AND is_deleted = 0", d.ID).Count(&n)
|
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)
|
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
|
return len(drafts), nil
|
||||||
|
|||||||
Reference in New Issue
Block a user