feat(street): 草稿图支持主副分组(并入/并入上一张/拆出)
This commit is contained in:
@ -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 指定草稿图片表名。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
161
internal/repository/street_main_detail_integration_test.go
Normal file
161
internal/repository/street_main_detail_integration_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user