diff --git a/cmd/dbtool/main.go b/cmd/dbtool/main.go index 9db278d..42af2b9 100644 --- a/cmd/dbtool/main.go +++ b/cmd/dbtool/main.go @@ -4,7 +4,7 @@ // **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。 // // dbtool dump [-config ] [-out ] [-with-extension] [-clean] 导出 -// dbtool import [-config ] [-in ] 导入 +// dbtool import [-config ] [-in ] [-clean] 导入 // // 导入(目标机器,二选一): // @@ -30,9 +30,17 @@ // 目标库为空还是已有数据: // // 默认只 CREATE TABLE IF NOT EXISTS + INSERT,**不清表**,因此要求目标库为空 -// (对已有数据的库会主键冲突)。想让同一份文件直接覆盖一个已有数据的库, -// 导出时加 -clean —— 它会在结构段前输出 DROP TABLE IF EXISTS ... CASCADE, -// 且只针对本次 dump 里的表(与 pg_dump --clean 口径一致,目标库多出来的表不动)。 +// (对已有数据的库会主键冲突)。要覆盖一个已有数据的库,用 -clean: +// +// dump -clean 在结构段前输出 DROP TABLE IF EXISTS ... CASCADE,只针对本次 +// dump 里的表(与 pg_dump --clean 口径一致),清理语义写在文件里, +// 因此 psql -f 同样能灌进脏库。 +// import -clean 导入前 DROP public 下全部表,不必重新导出文件;适合「手上只有 +// 一份旧文件、目标库状态不明」的情况。 +// +// 约束段另有一层幂等保护:先 DROP CONSTRAINT IF EXISTS 再 ADD。这样即使目标库里 +// 已存在同名主键(例如早先被 AutoMigrate 建过、或上一次导入中断留下的), +// 也不会报 relation "" already exists。 // // 前置条件:目标库必须已启用 pgvector 扩展,否则 vector(64) 列建不出来。 // 默认**不**导出扩展语句(可用 -with-extension 带上)。若目标容器由 scripts/pgvector 的 @@ -105,15 +113,17 @@ func usage() { 用法: dbtool dump [-config ] [-out ] [-with-extension] [-clean] 导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件 - dbtool import [-config ] [-in ] + dbtool import [-config ] [-in ] [-clean] 把 dump 出来的 SQL 文件灌入目标库 说明: 连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。 - -with-extension 在文件开头加 CREATE EXTENSION IF NOT EXISTS vector; + -with-extension(dump)在文件开头加 CREATE EXTENSION IF NOT EXISTS vector; 默认不加,此时目标库须已启用 pgvector,否则 vector 列建不出来。 - -clean 在结构段前加 DROP TABLE IF EXISTS,使脚本可灌进**已有数据的库** - (目标库中这些表的既有数据会丢失)。不加时目标库必须是空库,否则主键冲突。 + -clean 有两个位置,都会丢弃目标库中对应表的既有数据: + dump -clean 在结构段前写 DROP TABLE IF EXISTS,使文件可灌进已有数据的库 + import -clean 导入前先 DROP public 下全部表,适合「只有旧文件、目标库状态不明」 + 不加 -clean 时要求目标库为空(或至少同名表为空),否则会主键冲突。 import 等价于 psql -v ON_ERROR_STOP=1 -f;整个脚本在一个隐式事务里执行,出错整体回滚。`) } @@ -231,6 +241,7 @@ func runImport(args []string) { fs := flag.NewFlagSet("import", flag.ExitOnError) in := fs.String("in", "db_dump.sql", "导入文件路径") cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)") + clean := fs.Bool("clean", false, "导入前先 DROP public 下全部表(覆盖式还原;目标库这些数据会丢失)") _ = fs.Parse(args) script, err := os.ReadFile(*in) @@ -244,6 +255,12 @@ func runImport(args []string) { } defer db.Close() + if *clean { + // 与脚本拼成一段再执行:两者落在同一个隐式事务里,要么都成功、要么都回滚。 + script = append([]byte(dropAllTablesSQL+"\n"), script...) + log.Printf("→ 导入前先清空 public 下全部表(-clean)") + } + log.Printf("→ 导入 %s(%d KB)-> %s ...", *in, len(script)/1024, dcfg.Addr()) if err := execScript(context.Background(), db, string(script)); err != nil { log.Fatalf("✗ 导入失败(已整体回滚): %v", err) @@ -251,6 +268,24 @@ func runImport(args []string) { log.Printf("✓ 导入完成(%d KB)", len(script)/1024) } +// dropAllTablesSQL 删除 public 下全部基表(CASCADE 连带索引 / 约束 / 归属该表的序列)。 +// +// 只删表,不动扩展:pgvector 的 vector 类型是 extension 对象,不属于任何表,因此 +// DROP TABLE 之后扩展仍然可用,无需重新 CREATE EXTENSION。 +const dropAllTablesSQL = ` +DO $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT c.relname FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind = 'r' + LOOP + EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.relname); + END LOOP; +END $$;` + // execScript 用 pgx 简单查询协议执行整段脚本。 // // 必须走底层 *pgx.Conn:database/sql 的 Exec 用扩展协议,不支持一次发多条语句。 @@ -479,6 +514,10 @@ func writeConstraints(w *bufio.Writer, db *sql.DB, table string) error { if err := rows.Scan(&name, &def); err != nil { return err } + // 先 DROP CONSTRAINT IF EXISTS 再 ADD,让这一段幂等: + // 目标库若已存在同名约束(例如早先被 AutoMigrate 建过、或导入中断留下的), + // 不先删就会报 relation "" already exists —— 约束底层的索引名在 schema 内唯一。 + fmt.Fprintf(w, "ALTER TABLE ONLY %s DROP CONSTRAINT IF EXISTS %s;\n", qname(table), qi(name)) fmt.Fprintf(w, "ALTER TABLE ONLY %s ADD CONSTRAINT %s %s;\n", qname(table), qi(name), def) } fmt.Fprintln(w) diff --git a/internal/model/street_snap_draft.go b/internal/model/street_snap_draft.go index 8912d98..ee999a0 100644 --- a/internal/model/street_snap_draft.go +++ b/internal/model/street_snap_draft.go @@ -43,6 +43,12 @@ type StreetSnapDraftImage struct { 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 指定草稿图片表名。 diff --git a/internal/repository/review_repository.go b/internal/repository/review_repository.go index bc5a68a..35f1743 100644 --- a/internal/repository/review_repository.go +++ b/internal/repository/review_repository.go @@ -37,6 +37,18 @@ type ReviewRepository interface { SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error // SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error + // AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 + // mainImgID 必须属于同一草稿、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。 + // 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— + // 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。 + // 幂等:已是 mainImgID 副图的行重复并入不报错。 + AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error + // AttachPrevStreetDraftImage 把 imgID 并入「同草稿内 sort_order 紧邻的前一行所属组的主图」。 + // imgID 是首行(没有上一行)时返回 ErrNotFound。 + AttachPrevStreetDraftImage(ctx context.Context, draftID, imgID uint32) error + // DetachStreetDraftImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。 + // 对主图调用是幂等成功(无副作用)。 + DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error // GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。 GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error @@ -599,3 +611,103 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr return "", ErrNotFound } } + +// ── 街拍草稿图片:主副图分组 ────────────────────────────────────────────── + +// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 +// +// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图 +// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。 +func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error { + ids := make([]uint32, 0, len(imgIDs)) + for _, id := range imgIDs { + if id != 0 && id != mainImgID { + ids = append(ids, id) + } + } + if mainImgID == 0 || len(imgIDs) == 0 { + // 主图无效,或传入集合本身就是空的(调用方连一张待并入图都没给)。 + return ErrInvalidImageGroup + } + if len(ids) == 0 { + // 集合里只有主图自身:视为无操作成功(忽略),不报错也不改动主图。 + return nil + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var main model.StreetSnapDraftImage + if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, draftID). + First(&main).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrInvalidImageGroup + } + return err + } + + now := uint32(time.Now().Unix()) + if err := tx.Model(&model.StreetSnapDraftImage{}). + Where("draft_id = ? AND is_deleted = 0 AND parent_image_id IN ?", draftID, ids). + Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil { + return err + } + return tx.Model(&model.StreetSnapDraftImage{}). + Where("draft_id = ? AND is_deleted = 0 AND id IN ?", draftID, ids). + Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error + }) +} + +// AttachPrevStreetDraftImage 把 imgID 并入「同草稿内 sort_order 紧邻的前一行所属组的主图」。 +// 折回组主图是刻意的:连续「并入上一张」应链式归并到同一主图,而不是造出副图的副图。 +func (r *reviewRepository) AttachPrevStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { + var target uint32 + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var cur model.StreetSnapDraftImage + if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).First(&cur).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + + var prev model.StreetSnapDraftImage + err := tx.Where("draft_id = ? AND is_deleted = 0 AND (sort_order < ? OR (sort_order = ? AND id < ?))", + draftID, cur.SortOrder, cur.SortOrder, cur.ID). + Order("sort_order DESC, id DESC").First(&prev).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + + if prev.IsDetail == 1 && prev.ParentImageID != 0 { + target = prev.ParentImageID + } else { + target = prev.ID + } + if target == imgID { + return ErrInvalidImageGroup + } + + now := uint32(time.Now().Unix()) + if err := tx.Model(&model.StreetSnapDraftImage{}). + Where("draft_id = ? AND is_deleted = 0 AND parent_image_id = ?", draftID, imgID). + Updates(map[string]any{"parent_image_id": target, "updated_at": now}).Error; err != nil { + return err + } + return tx.Model(&model.StreetSnapDraftImage{}). + Where("id = ? AND draft_id = ?", imgID, draftID). + Updates(map[string]any{"is_detail": 1, "parent_image_id": target, "updated_at": now}).Error + }) + return err +} + +// DetachStreetDraftImage 把副图恢复为主图。对主图调用为幂等成功。 +// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。 +func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { + return r.db.WithContext(ctx). + Model(&model.StreetSnapDraftImage{}). + Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, draftID). + Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}). + Error +} diff --git a/internal/repository/street_main_detail_integration_test.go b/internal/repository/street_main_detail_integration_test.go new file mode 100644 index 0000000..7b69871 --- /dev/null +++ b/internal/repository/street_main_detail_integration_test.go @@ -0,0 +1,161 @@ +//go:build integration + +// 集成测试:街拍草稿主副图分组需要真实 PostgreSQL。 +// +// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节): +// +// docker compose -f scripts/pgvector/docker-compose.yml up -d +// go test -tags integration ./internal/repository/ -run TestStreetMainDetail -v +// +// 默认 `go test ./...` 不会编译本文件(build tag 隔离)。 +package repository + +import ( + "context" + "errors" + "testing" + + "fashionapi/internal/model" +) + +// seedStreetDraft 插入一条街拍草稿并返回其 id。 +func seedStreetDraft(t *testing.T, repo ReviewRepository) uint32 { + t.Helper() + ctx := context.Background() + db := repo.(*reviewRepository).db + d := model.StreetSnapDraft{Title: "主副图测试", Year: 2026, City: "TestCity", Status: model.DraftStatusPending} + if err := db.WithContext(ctx).Create(&d).Error; err != nil { + t.Fatalf("插入草稿失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM street_snap_draft_images WHERE draft_id = ?", d.ID) + db.Exec("DELETE FROM street_snap_drafts WHERE id = ?", d.ID) + }) + return d.ID +} + +// seedStreetDraftImage 插入一张草稿图并返回其行 id。 +func seedStreetDraftImage(t *testing.T, repo ReviewRepository, draftID uint32, name string, order uint32) uint32 { + t.Helper() + img := model.StreetSnapDraftImage{DraftID: draftID, Image: name + ".jpg", Name: name, SortOrder: order} + if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil { + t.Fatalf("插入草稿图失败: %v", err) + } + return img.ID +} + +// reloadStreetImages 取草稿的全部图(按 sort_order, id 升序),便于断言。 +func reloadStreetImages(t *testing.T, repo ReviewRepository, draftID uint32) []model.StreetSnapDraftImage { + t.Helper() + imgs, err := repo.StreetDraftImages(context.Background(), draftID) + if err != nil { + t.Fatalf("读草稿图失败: %v", err) + } + return imgs +} + +// TestStreetMainDetail 覆盖批量并入 / 并入上一张 / 拆出 / 跨草稿拦截四条主线。 +func TestStreetMainDetail(t *testing.T) { + repo := NewReviewRepository(testDB(t)) + ctx := context.Background() + + draftID := seedStreetDraft(t, repo) + m1 := seedStreetDraftImage(t, repo, draftID, "a", 1) + d2 := seedStreetDraftImage(t, repo, draftID, "b", 2) + d3 := seedStreetDraftImage(t, repo, draftID, "c", 3) + + if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { + t.Fatalf("并入失败: %v", err) + } + for _, im := range reloadStreetImages(t, repo, draftID) { + switch im.ID { + case m1: + if im.IsDetail != 0 || im.ParentImageID != 0 { + t.Fatalf("主图应保持 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID) + } + case d2, d3: + if im.IsDetail != 1 || im.ParentImageID != m1 { + t.Fatalf("副图应指向 %d,实际 is_detail=%d parent=%d", m1, im.IsDetail, im.ParentImageID) + } + } + } + + // 幂等:重复并入同一批不报错,结果不变。 + if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { + t.Fatalf("重复并入应幂等,实际报错: %v", err) + } + + // 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。 + if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m1}); err != nil { + t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err) + } + for _, im := range reloadStreetImages(t, repo, draftID) { + if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) { + t.Fatalf("主图被自身并入后不应变化") + } + } + + // 拆出:把 d3 恢复为主图。 + if err := repo.DetachStreetDraftImage(ctx, draftID, d3); err != nil { + t.Fatalf("拆出失败: %v", err) + } + for _, im := range reloadStreetImages(t, repo, draftID) { + if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) { + t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID) + } + } + if err := repo.DetachStreetDraftImage(ctx, draftID, m1); err != nil { // 对主图幂等成功 + t.Fatalf("对主图拆出应幂等成功: %v", err) + } + + // 并入上一张:d3 的前一行是 d2(d2 已是 m1 的副图)→ 目标应折回 m1。 + if err := repo.AttachPrevStreetDraftImage(ctx, draftID, d3); err != nil { + t.Fatalf("并入上一张失败: %v", err) + } + for _, im := range reloadStreetImages(t, repo, draftID) { + if im.ID == d3 && (im.IsDetail != 1 || im.ParentImageID != m1) { + t.Fatalf("并入上一张应折回主图 %d,实际 is_detail=%d parent=%d", m1, im.IsDetail, im.ParentImageID) + } + } + + // 首行「并入上一张」应返回 ErrNotFound。 + first := seedStreetDraftImage(t, repo, draftID, "z", 0) + if err := repo.AttachPrevStreetDraftImage(ctx, draftID, first); !errors.Is(err, ErrNotFound) { + t.Fatalf("首行并入上一张应返回 ErrNotFound,实际 %v", err) + } + + // 跨草稿拦截:空集合 + 跨草稿主图。 + if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) { + t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err) + } + otherMain := seedStreetDraftImage(t, repo, seedStreetDraft(t, repo), "x", 1) + if err := repo.AttachStreetDraftImages(ctx, draftID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) { + t.Fatalf("跨草稿主图应被拦截,实际 %v", err) + } +} + +// TestStreetMainDetailReparentGrandchildren 并入一张自带副图的主图时,其原副图应改挂到新主图。 +func TestStreetMainDetailReparentGrandchildren(t *testing.T) { + repo := NewReviewRepository(testDB(t)) + ctx := context.Background() + + draftID := seedStreetDraft(t, repo) + m1 := seedStreetDraftImage(t, repo, draftID, "m1", 1) + m2 := seedStreetDraftImage(t, repo, draftID, "m2", 2) + child := seedStreetDraftImage(t, repo, draftID, "child", 3) + + if err := repo.AttachStreetDraftImages(ctx, draftID, m2, []uint32{child}); err != nil { + t.Fatalf("预置副图失败: %v", err) + } + if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m2}); err != nil { + t.Fatalf("并入整组失败: %v", err) + } + for _, im := range reloadStreetImages(t, repo, draftID) { + if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) { + t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID) + } + if im.ID == child && (im.IsDetail != 1 || im.ParentImageID != m1) { + t.Fatalf("child 应改挂到 m1,实际 %d/%d", im.IsDetail, im.ParentImageID) + } + } +}