Files
backend_v2/internal/repository/street_main_detail_integration_test.go
toom1996 be4db0bf96 fix(publish): 最终审查修复波——迁移索引口径 / 入库计数 / 仓储证据
C1(关键):2026-09-22-03 的街拍实体键索引改用最终表达式
(city, year, COALESCE(title, '')),与体检及 StreetSnapEntityState 口径一致。
此前索引按 (city, year) 比体检更窄,同城同年多专题的合法数据会通过体检、
再由索引抛原生 23505;04 退化为幂等兜底并在注释说明真正常态由 03 承载。
新增 db/migrations/README.md,写清迁移链执行顺序与各步前提/不可逆点。

I1:processRunway 的 image_count 改为按实际落库主图行数(countMainImages),
不再用 len(p.Looks)(空主图 / sha1 重复会被跳过,导致计数偏大且不再自愈)。
补 service 单测与「入库后 image_count == 存活主图行数」的 DB 断言。

I2:三个迁移文件不再把已删除的一次性搬迁脚本写成硬前置,改为写明取回方式
(git show c4bafc5:scripts/migrate_single_table/main.go,并须在旧代码树上运行)。

I3:新增 SetRecordStatus / SetStreetRecordStatus 仓储集成测试:
pending 经仓储通过后在公开视图可见、驳回后不可见、不存在的 id 返回 ErrNotFound。

Minor:修正锁不住口径的走秀 image_count 测试(改为删主图、留细节图);
修正去重注释与事实不符(含 FindNearDuplicateImage 注释);
ListRecords / ListStreetRecords 改用 Scope 杜绝 Count 后复用 *gorm.DB;
dbtool 视图改 CREATE OR REPLACE 并重跑 dump(仍 4 视图、无草稿表);
删除挂在 Popular 上的「IDs 返回…」注释;README 改为单表 + status 现状;
SetRecordStatus 注释写明有意不校验前置状态;规格补两条已知不一致。
2026-09-23 16:03:22 +08:00

324 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build integration
// 集成测试:街拍主副图分组需要真实 PostgreSQL。
//
// 单表发布模型下操作对象是正式表本身(street_snaps / street_snap_images),
// 不再有草稿表;审核通过只改状态、不重建图片,故本文件不再覆盖「晋升重建父引用」。
//
// 运行方式(先起库并确保结构已就绪,见 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"
"fmt"
"testing"
"fashionapi/internal/model"
)
// mainDetailSeedSeq 让每次 seed 出来的实体键(city + year + title)互不相同。
// 正式表上有部分唯一索引 uq_ss_entity (city, year, COALESCE(title, 空串)) WHERE is_deleted = 0,
// 若测试沿用同一 city/year/title,第二条记录会被唯一索引拒绝。
var mainDetailSeedSeq int
// seedMainDetailSnap 插入一条街拍正式记录(status=pending)并返回其 id。
// 命名加 MainDetail 前缀以避免与 publish_status_integration_test.go 的 seedStreetSnap 冲突。
func seedMainDetailSnap(t *testing.T, repo ReviewRepository) uint32 {
t.Helper()
ctx := context.Background()
db := repo.(*reviewRepository).db
mainDetailSeedSeq++
snap := model.StreetSnap{
Title: "主副图测试",
Year: 2026,
City: fmt.Sprintf("TestCity-%d", mainDetailSeedSeq),
Status: model.StatusPending,
}
if err := db.WithContext(ctx).Create(&snap).Error; err != nil {
t.Fatalf("插入街拍记录失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
})
return snap.ID
}
// seedMainDetailImage 插入一张正式街拍图并返回其行 id。
func seedMainDetailImage(t *testing.T, repo ReviewRepository, snapID uint32, name string, order uint32) uint32 {
t.Helper()
img := model.StreetSnapImage{SnapID: snapID, 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
}
// reloadSnapImages 取记录的全部图(按 sort_order, id 升序),便于断言。
func reloadSnapImages(t *testing.T, repo ReviewRepository, snapID uint32) []model.StreetSnapImage {
t.Helper()
imgs, err := repo.StreetRecordImages(context.Background(), snapID)
if err != nil {
t.Fatalf("读街拍图失败: %v", err)
}
return imgs
}
// TestStreetMainDetail 覆盖批量并入 / 拆出 / 跨记录拦截三条主线。
func TestStreetMainDetail(t *testing.T) {
repo := NewReviewRepository(testDB(t))
ctx := context.Background()
snapID := seedMainDetailSnap(t, repo)
m1 := seedMainDetailImage(t, repo, snapID, "a", 1)
d2 := seedMainDetailImage(t, repo, snapID, "b", 2)
d3 := seedMainDetailImage(t, repo, snapID, "c", 3)
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("并入失败: %v", err)
}
for _, im := range reloadSnapImages(t, repo, snapID) {
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.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("重复并入应幂等,实际报错: %v", err)
}
// 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m1}); err != nil {
t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err)
}
for _, im := range reloadSnapImages(t, repo, snapID) {
if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
t.Fatalf("主图被自身并入后不应变化")
}
}
// 拆出:把 d3 恢复为主图。
if err := repo.DetachStreetSnapImage(ctx, snapID, d3); err != nil {
t.Fatalf("拆出失败: %v", err)
}
for _, im := range reloadSnapImages(t, repo, snapID) {
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.DetachStreetSnapImage(ctx, snapID, m1); err != nil { // 对主图幂等成功
t.Fatalf("对主图拆出应幂等成功: %v", err)
}
// 跨记录拦截:空集合 + 跨记录主图。
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)
}
otherMain := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "x", 1)
if err := repo.AttachStreetSnapImages(ctx, snapID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("跨记录主图应被拦截,实际 %v", err)
}
}
// TestStreetMainDetailReparentGrandchildren 并入一张自带副图的主图时,其原副图应改挂到新主图。
func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
repo := NewReviewRepository(testDB(t))
ctx := context.Background()
snapID := seedMainDetailSnap(t, repo)
m1 := seedMainDetailImage(t, repo, snapID, "m1", 1)
m2 := seedMainDetailImage(t, repo, snapID, "m2", 2)
child := seedMainDetailImage(t, repo, snapID, "child", 3)
if err := repo.AttachStreetSnapImages(ctx, snapID, m2, []uint32{child}); err != nil {
t.Fatalf("预置副图失败: %v", err)
}
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m2}); err != nil {
t.Fatalf("并入整组失败: %v", err)
}
for _, im := range reloadSnapImages(t, repo, snapID) {
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)
}
}
}
// ── 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 口径是「存活主图行数」(与入库 countMainImages 一致)。
//
// 关键断言在「软删一张主图、让细节图**仍存活**」这一步:重算必须排除 is_detail=1 的行。
// 若先删细节图再删主图,过滤与否结果相同(旧写法就锁不住口径),故这里把顺序倒过来。
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)
// 先软删一张主图,细节图此刻仍存活:只计主图 → 1(仅 main1)。
// 若重算用 COUNT(*) 不排除 is_detail,会把存活的 detail 一并算进来得 2 —— 本断言锁得住。
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil {
t.Fatalf("软删主图失败: %v", err)
}
if got := runwayImageCount(t, repo, runwayID); got != 1 {
t.Fatalf("image_count 只计存活主图,软删一张主图后应为 1(须排除仍存活的细节图),实际 %d", got)
}
// 再软删细节图:主图数不变(细节图本就不计入)。
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, detail); err != nil {
t.Fatalf("软删细节图失败: %v", err)
}
if got := runwayImageCount(t, repo, runwayID); got != 1 {
t.Fatalf("软删细节图不应改变主图计数,实际 %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)
}
}