chore(publish): 删除草稿表与草稿模型
This commit is contained in:
9
db/migrations/2026-09-22-02-drop-draft-tables.sql
Normal file
9
db/migrations/2026-09-22-02-drop-draft-tables.sql
Normal file
@ -0,0 +1,9 @@
|
||||
-- 单表发布模型收尾:删除 4 张草稿表。
|
||||
--
|
||||
-- ⚠️ 执行前置条件:scripts/migrate_single_table 已跑完且校验通过(否则草稿数据会丢失)。
|
||||
-- 本迁移不可逆:执行前请确认 db/backups 有可用备份。
|
||||
|
||||
DROP TABLE IF EXISTS brand_runway_draft_images;
|
||||
DROP TABLE IF EXISTS brand_runway_drafts;
|
||||
DROP TABLE IF EXISTS street_snap_draft_images;
|
||||
DROP TABLE IF EXISTS street_snap_drafts;
|
||||
2431
db_dump.sql
2431
db_dump.sql
File diff suppressed because it is too large
Load Diff
@ -1,62 +0,0 @@
|
||||
package model
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// BrandRunwayDraft 走秀草稿(爬虫入库待审核)。
|
||||
//
|
||||
// 爬虫 worker 处理完 ingest_jobs 后不直写正式表,而是写入本表并置为 pending;
|
||||
// 后台审核通过(Approve)后再晋升(SaveRunwayFromDraft)到 brand_runways 正式表。
|
||||
// 字段与 brand_runways 对齐(含 review 专用字段),便于审核页直接预览/微调。
|
||||
type BrandRunwayDraft struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
JobID uint32 `gorm:"column:job_id" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
||||
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` // 品牌数字主键
|
||||
TitleEn string `gorm:"column:title_en" json:"title_en"`
|
||||
TitleCn string `gorm:"column:title_cn" json:"title_cn"`
|
||||
DescriptionEn string `gorm:"column:description_en" json:"description_en"`
|
||||
DescriptionCn string `gorm:"column:description_cn" json:"description_cn"`
|
||||
Year uint16 `gorm:"column:year" json:"year"`
|
||||
Season string `gorm:"column:season" json:"season"`
|
||||
CollectionType string `gorm:"column:collection_type" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
||||
SeasonCode string `gorm:"column:season_code" json:"season_code"` // SS26/FW25/RES26/PF25
|
||||
Cover string `gorm:"column:cover" json:"cover"`
|
||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
||||
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
|
||||
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
|
||||
RejectReason string `gorm:"column:reject_reason" json:"reject_reason"`
|
||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定草稿主表名。
|
||||
func (BrandRunwayDraft) TableName() string { return "brand_runway_drafts" }
|
||||
|
||||
// BrandRunwayDraftImage 草稿图片(对应 brand_runway_draft_images)。
|
||||
type BrandRunwayDraftImage struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
DraftID uint32 `gorm:"column:draft_id" json:"draft_id"`
|
||||
Image string `gorm:"column:image" json:"image"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
|
||||
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号
|
||||
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图 1=细节图
|
||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
}
|
||||
|
||||
// TableName 指定草稿图片表名。
|
||||
func (BrandRunwayDraftImage) TableName() string { return "brand_runway_draft_images" }
|
||||
|
||||
// 草稿状态常量。
|
||||
const (
|
||||
DraftStatusPending = "pending"
|
||||
DraftStatusApproved = "approved"
|
||||
DraftStatusRejected = "rejected"
|
||||
)
|
||||
@ -1,55 +0,0 @@
|
||||
package model
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// StreetSnapDraft 街拍草稿(爬虫入库待审核)。
|
||||
//
|
||||
// 与 brand_runway_drafts 同构的审核流程:爬虫 worker 处理完 ingest_jobs 后写入本表并置
|
||||
// pending;后台审核通过(Approve)后再晋升(SaveStreetSnapFromDraft)到 street_snaps 正式表。
|
||||
//
|
||||
// 字段对齐 street_snaps 正式表(单 Title、有 City、无 brand_id、无 season 系列列),
|
||||
// 外加审核专用字段,便于审核页直接预览/微调。
|
||||
type StreetSnapDraft struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
JobID uint32 `gorm:"column:job_id" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
||||
Title string `gorm:"column:title" json:"title"` // 单标题(街拍无双语)
|
||||
Year uint16 `gorm:"column:year" json:"year"`
|
||||
City string `gorm:"column:city" json:"city"` // 地区/城市,'' 表示未归类
|
||||
Cover string `gorm:"column:cover" json:"cover"`
|
||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
||||
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
|
||||
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
|
||||
RejectReason string `gorm:"column:reject_reason" json:"reject_reason"`
|
||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定草稿主表名。
|
||||
func (StreetSnapDraft) TableName() string { return "street_snap_drafts" }
|
||||
|
||||
// StreetSnapDraftImage 街拍草稿图片(对应 street_snap_draft_images)。
|
||||
type StreetSnapDraftImage struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
DraftID uint32 `gorm:"column:draft_id" json:"draft_id"`
|
||||
Image string `gorm:"column:image" json:"image"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
|
||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
|
||||
// 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加):
|
||||
// is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同草稿内主图的行 id。
|
||||
// 存行 id 而非序号:序号会因重排 / 插入 / 删除而失效。
|
||||
IsDetail uint8 `gorm:"column:is_detail;not null;default:0" json:"is_detail"`
|
||||
ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"`
|
||||
}
|
||||
|
||||
// TableName 指定草稿图片表名。
|
||||
func (StreetSnapDraftImage) TableName() string { return "street_snap_draft_images" }
|
||||
@ -26,16 +26,14 @@ func NewMediaRepository(db *gorm.DB) MediaRepository {
|
||||
return &mediaRepository{db: db}
|
||||
}
|
||||
|
||||
// imageTables 保存S4 key 的全部图片明细表(已晋升的正式表 + 待审草稿表)。
|
||||
// 引用计数必须覆盖这 4 张,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。
|
||||
// imageTables 保存S4 key 的全部图片明细表(单表发布模型下即两张正式图片表)。
|
||||
// 引用计数必须覆盖全部图片表,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。
|
||||
var imageTables = []string{
|
||||
"brand_runway_images",
|
||||
"brand_runway_draft_images",
|
||||
"street_snap_images",
|
||||
"street_snap_draft_images",
|
||||
}
|
||||
|
||||
// CountImageKeyRefs 跨 4 张表统计 key 的存活引用数(is_deleted=0)。
|
||||
// CountImageKeyRefs 跨全部图片明细表统计 key 的存活引用数(is_deleted=0)。
|
||||
func (r *mediaRepository) CountImageKeyRefs(ctx context.Context, key string) (int, error) {
|
||||
if key == "" {
|
||||
return 0, nil
|
||||
|
||||
@ -1,432 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user