From f9068f530ba2d7f045b30fcbfd7a45555464400e Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Wed, 23 Sep 2026 11:21:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(publish):=20=E8=BD=AF=E5=88=A0=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=8C=89=E5=AE=9E=E9=99=85=E8=A1=8C=E9=87=8D=E7=AE=97?= =?UTF-8?q?=20image=5Fcount=EF=BC=8C=E7=8A=B6=E6=80=81=E5=86=99=E5=85=A5?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E4=B8=8D=E5=AD=98=E5=9C=A8=E7=9A=84=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/review_repository.go | 58 +++++-- .../street_main_detail_integration_test.go | 159 ++++++++++++++++++ internal/service/review_service.go | 2 +- internal/service/review_street_group_test.go | 2 +- 4 files changed, 208 insertions(+), 13 deletions(-) diff --git a/internal/repository/review_repository.go b/internal/repository/review_repository.go index e6a7703..3cb72ea 100644 --- a/internal/repository/review_repository.go +++ b/internal/repository/review_repository.go @@ -24,7 +24,7 @@ type ReviewRepository interface { RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) // UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。 UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error - // SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由。 + // SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由;记录不存在返回 ErrNotFound。 SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error // 街拍镜像方法(对象为 street_snaps / street_snap_images)。 @@ -32,11 +32,12 @@ type ReviewRepository interface { StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error + // SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。 SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error - // SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并同步 image_count。 + // SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。 SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error - // SoftDeleteStreetImage 软删单张街拍图片,并同步 image_count。 + // SoftDeleteStreetImage 软删单张街拍图片,并按实际存活行数重算 image_count。 SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error // AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 // mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。 @@ -139,8 +140,11 @@ func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields m Updates(clean).Error } +// SetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound。 +// 否则「对不存在的 id 通过 / 驳回」会被当作成功:handler 只校验 id != 0,id 又来自 URL, +// 竞态窗口(记录刚被删除)下会给出假成功 302。 func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { - return r.db.WithContext(ctx). + res := r.db.WithContext(ctx). Model(&model.BrandRunway{}). Where("id = ?", id). Updates(map[string]any{ @@ -148,7 +152,14 @@ func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, statu "reviewer": reviewer, "reject_reason": reason, "updated_at": uint32(time.Now().Unix()), - }).Error + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil } // CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。 @@ -238,8 +249,9 @@ func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fi Updates(clean).Error } +// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。 func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { - return r.db.WithContext(ctx). + res := r.db.WithContext(ctx). Model(&model.StreetSnap{}). Where("id = ?", id). Updates(map[string]any{ @@ -247,13 +259,29 @@ func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, "reviewer": reviewer, "reject_reason": reason, "updated_at": uint32(time.Now().Unix()), - }).Error + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil } // ── 图片软删 ──────────────────────────────────────────────────────────────── -// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并同步记录 image_count。 +// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),按实际存活主图行数重算 image_count。 // 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。 +// +// 为什么是「重算」而不是「递减 image_count - 1」:递减的前提是「本次调用必然删掉一行」, +// 而 imgID 完全来自 URL 路径、没有归属校验 —— 过期页面 / 重复提交 / 跨记录的重放都会命中 0 行, +// GORM 不报错,递减却照常执行,于是只减计数不删图(GREATEST 只钳到 0、永不回补)。 +// 旧流程靠审核通过时按实际图片行重算能抹平这种漂移;单表发布模型删掉了晋升重建, +// 漂移会永久固化在已发布记录上,并直接显示在公开列表卡片的「N 张」角标里。 +// +// 口径与入库一致:只计主图(is_detail = 0)——见 ingest_service.processRunway 的 imageCount = len(p.Looks), +// 以及旧 SaveRunwayFromDraft 的 mainCount。细节图不计入。 func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error { if err := r.db.WithContext(ctx). Model(&model.BrandRunwayImage{}). @@ -262,10 +290,16 @@ func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, return err } return r.db.WithContext(ctx). - Exec("UPDATE brand_runways SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", recordID).Error + Exec(`UPDATE brand_runways SET image_count = + (SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0) + WHERE id = ?`, recordID, recordID).Error } -// SoftDeleteStreetImage 软删单张街拍图片,并同步记录 image_count。 +// SoftDeleteStreetImage 软删单张街拍图片,按实际存活行数重算 image_count(理由同 SoftDeleteRunwayImage)。 +// +// 口径与街拍正式表的历史行为一致:计全部存活图片行(主图 + 副图), +// 见旧 SaveStreetSnapFromDraft 的 image_count = len(imgs);街拍副图是审核期人工并入的, +// 与走秀「is_detail 是入库时的细节图」语义不同。 func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error { if err := r.db.WithContext(ctx). Model(&model.StreetSnapImage{}). @@ -274,7 +308,9 @@ func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, return err } return r.db.WithContext(ctx). - Exec("UPDATE street_snaps SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", recordID).Error + Exec(`UPDATE street_snaps SET image_count = + (SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0) + WHERE id = ?`, recordID, recordID).Error } // GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。 diff --git a/internal/repository/street_main_detail_integration_test.go b/internal/repository/street_main_detail_integration_test.go index a64546e..9bbb209 100644 --- a/internal/repository/street_main_detail_integration_test.go +++ b/internal/repository/street_main_detail_integration_test.go @@ -159,3 +159,162 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) { } } } + +// ── image_count 重算(软删不漂移) ───────────────────────────────────────────── + +// snapImageCount 读某街拍记录当前的 image_count。 +func snapImageCount(t *testing.T, repo ReviewRepository, snapID uint32) uint16 { + t.Helper() + var row struct { + ImageCount uint16 `gorm:"column:image_count"` + } + if err := repo.(*reviewRepository).db.WithContext(context.Background()). + Table("street_snaps").Select("image_count").Where("id = ?", snapID).Scan(&row).Error; err != nil { + t.Fatalf("读街拍 image_count 失败: %v", err) + } + return row.ImageCount +} + +// runwayImageCount 读某走秀记录当前的 image_count。 +func runwayImageCount(t *testing.T, repo ReviewRepository, runwayID uint32) uint16 { + t.Helper() + var row struct { + ImageCount uint16 `gorm:"column:image_count"` + } + if err := repo.(*reviewRepository).db.WithContext(context.Background()). + Table("brand_runways").Select("image_count").Where("id = ?", runwayID).Scan(&row).Error; err != nil { + t.Fatalf("读走秀 image_count 失败: %v", err) + } + return row.ImageCount +} + +// seedMainDetailRunway 插入一条走秀正式记录(status=pending)并返回其 id。 +// SeasonCode 用递增序号,避开 uq_br_entity (brand_id, season_code, collection_type) 部分唯一索引。 +func seedMainDetailRunway(t *testing.T, repo ReviewRepository) uint32 { + t.Helper() + db := repo.(*reviewRepository).db + mainDetailSeedSeq++ + rw := model.BrandRunway{ + BrandID: 1, + TitleEn: "主副图计数测试", + Year: 2026, + SeasonCode: fmt.Sprintf("MD%d", mainDetailSeedSeq), + CollectionType: "rtw", + Status: model.StatusPending, + } + if err := db.WithContext(context.Background()).Create(&rw).Error; err != nil { + t.Fatalf("插入走秀记录失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID) + db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID) + }) + return rw.ID +} + +// seedMainDetailRunwayImage 插入一张走秀图(isDetail=1 表示细节图)并返回其行 id。 +func seedMainDetailRunwayImage(t *testing.T, repo ReviewRepository, runwayID uint32, name string, order uint32, isDetail uint8) uint32 { + t.Helper() + img := model.BrandRunwayImage{ + RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name, + SortOrder: order, IsDetail: isDetail, + } + if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil { + t.Fatalf("插入走秀图失败: %v", err) + } + return img.ID +} + +// TestSoftDeleteStreetImageRecomputesCount 软删街拍图片后 image_count 必须按实际存活行**重算**。 +// +// 对「已被软删」或「不属于该记录」的 imgID 重放一次,计数必须不变: +// 旧实现是 `image_count - 1`(GREATEST 钳到 0),命中 0 行时仍会递减且永不回补; +// 单表发布模型删掉了晋升时按实际行重算的自愈路径,漂移会永久留在公开列表卡片的「N 张」上。 +func TestSoftDeleteStreetImageRecomputesCount(t *testing.T) { + repo := NewReviewRepository(testDB(t)) + ctx := context.Background() + + snapID := seedMainDetailSnap(t, repo) + keep := seedMainDetailImage(t, repo, snapID, "keep", 1) + drop := seedMainDetailImage(t, repo, snapID, "drop", 2) + + if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil { + t.Fatalf("软删失败: %v", err) + } + if got := snapImageCount(t, repo, snapID); got != 1 { + t.Fatalf("软删一张后 image_count 应为 1,实际 %d", got) + } + + // 重放同一张(已被软删):命中 0 行,计数不得变化。 + if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil { + t.Fatalf("重复软删应幂等,实际报错: %v", err) + } + if got := snapImageCount(t, repo, snapID); got != 1 { + t.Fatalf("重复软删不应改变 image_count,实际 %d(旧递减实现会掉到 0)", got) + } + + // 不属于该记录的 imgID:同样不得影响本记录计数。 + otherImg := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "other", 1) + if err := repo.SoftDeleteStreetImage(ctx, snapID, otherImg); err != nil { + t.Fatalf("跨记录软删不应报错: %v", err) + } + if got := snapImageCount(t, repo, snapID); got != 1 { + t.Fatalf("跨记录 imgID 不应改变 image_count,实际 %d", got) + } + + imgs, err := repo.StreetRecordImages(ctx, snapID) + if err != nil { + t.Fatalf("读存活图失败: %v", err) + } + if len(imgs) != 1 || imgs[0].ID != keep { + t.Fatalf("应只剩 keep 一行,实际 %+v", imgs) + } +} + +// TestSoftDeleteRunwayImage 走秀 image_count 口径是「主图数」(与入库 imageCount = len(Looks) 一致): +// 软删细节图不改计数;软删主图才减,且同样是重算(重放 / 跨记录不漂移)。 +func TestSoftDeleteRunwayImage(t *testing.T) { + repo := NewReviewRepository(testDB(t)) + ctx := context.Background() + + runwayID := seedMainDetailRunway(t, repo) + main1 := seedMainDetailRunwayImage(t, repo, runwayID, "main1", 1, 0) + main2 := seedMainDetailRunwayImage(t, repo, runwayID, "main2", 2, 0) + detail := seedMainDetailRunwayImage(t, repo, runwayID, "detail1", 3, 1) + + // 首次软删细节图:主图数仍为 2(若用 COUNT(*) 不排除 is_detail,会错算成 3)。 + if err := repo.SoftDeleteRunwayImage(ctx, runwayID, detail); err != nil { + t.Fatalf("软删细节图失败: %v", err) + } + if got := runwayImageCount(t, repo, runwayID); got != 2 { + t.Fatalf("image_count 只计主图,应为 2,实际 %d", got) + } + + // 软删一张主图 → 1。 + if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil { + t.Fatalf("软删主图失败: %v", err) + } + if got := runwayImageCount(t, repo, runwayID); got != 1 { + t.Fatalf("软删一张主图后应为 1,实际 %d", got) + } + + // 重放(已软删)+ 跨记录:计数均不得变化。 + if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil { + t.Fatalf("重复软删应幂等: %v", err) + } + otherMain := seedMainDetailRunwayImage(t, repo, seedMainDetailRunway(t, repo), "x", 1, 0) + if err := repo.SoftDeleteRunwayImage(ctx, runwayID, otherMain); err != nil { + t.Fatalf("跨记录软删不应报错: %v", err) + } + if got := runwayImageCount(t, repo, runwayID); got != 1 { + t.Fatalf("重放 / 跨记录不应改变 image_count,实际 %d", got) + } + + imgs, err := repo.RecordImages(ctx, runwayID) + if err != nil { + t.Fatalf("读存活主图失败: %v", err) + } + if len(imgs) != 1 || imgs[0].ID != main1 { + t.Fatalf("应只剩 main1 一行,实际 %+v", imgs) + } +} diff --git a/internal/service/review_service.go b/internal/service/review_service.go index 3184e51..65d8be4 100644 --- a/internal/service/review_service.go +++ b/internal/service/review_service.go @@ -83,7 +83,7 @@ type ImageGroup struct { // ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该记录 / 自身不是主图 / 空集合。 // 这里是给用户看的文案,handler 直接渲染 err.Error()。 -var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图") +var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本条记录、未被删除,且自身是主图") // KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。 type KindTab struct { diff --git a/internal/service/review_street_group_test.go b/internal/service/review_street_group_test.go index 7640b0a..39e6ac6 100644 --- a/internal/service/review_street_group_test.go +++ b/internal/service/review_street_group_test.go @@ -121,7 +121,7 @@ func TestStreetGroupPreventCrossRecord(t *testing.T) { if err == nil { t.Fatal("跨记录应报错") } - if err.Error() != "主图无效:它必须属于本草稿、未被删除,且自身是主图" { + if err.Error() != "主图无效:它必须属于本条记录、未被删除,且自身是主图" { t.Fatalf("错误文案不符: %q", err.Error()) } }