Files
backend_v2/internal/service/ingest_image_count_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

118 lines
4.3 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.

package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/storage"
"fashionapi/internal/repository"
)
// captureIngestRepo 捕获 processRunway 交给仓储的「待落库」记录与图片行。
// 只实现被测路径会用到的方法,其余由内嵌 nil 接口兜底(不应被调用)。
type captureIngestRepo struct {
repository.IngestRepository
createdRW *model.BrandRunway
createdImages []model.BrandRunwayImage
}
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
return 0, "", false, nil // 实体键未命中 → 新建分支
}
func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
c.createdRW = rw
c.createdImages = imgs
return 1, nil
}
func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
return 0, false, nil // 近重复不影响本用例
}
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
// fakeBrandRepo 品牌校验恒通过。
type fakeBrandRepo struct{ repository.BrandRepository }
func (fakeBrandRepo) FindByID(ctx context.Context, id uint32) (*model.Brand, error) {
return &model.Brand{}, nil
}
// TestProcessRunwayImageCountEqualsAliveMainRows 回归 image_count 漂移:
//
// 旧实现取 imageCount = len(p.Looks),但 fetchLookImages 会跳过 look.Main == "" 的 look
// 与 sha1 重复的行 —— 实际落库的主图行可能更少。旧流程靠晋升按实际行重算自愈,单表发布后
// 该路径已删除,漂移会永久留在公开卡片的「N 张」角标与热度 SUM(image_count) 上。
//
// 本用例断言:入库时交给仓储的 image_count == 实际存活主图行数(is_detail = 0),
// 且在数据触发跳过时**严格小于** len(Looks)。旧实现下 image_count 会是 4,确定性失败。
func TestProcessRunwayImageCountEqualsAliveMainRows(t *testing.T) {
dir := t.TempDir()
local := storage.NewLocalUploader(dir, "/uploads")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
// 按路径返回不同内容:Content 不同 → sha1 不同;同路径 → sha1 相同(触发批次内去重)。
_, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path))
}))
defer srv.Close()
repo := &captureIngestRepo{}
s := &IngestService{
repo: repo,
brandRepo: fakeBrandRepo{},
uploader: local,
local: local,
httpClient: srv.Client(),
}
p := dto.RunwayIngest{
Kind: dto.IngestKindRunway,
BrandUID: hashid.Encode(1),
TitleEn: "image-count-test",
Year: 2027,
Season: "spring",
CollectionType: "rtw",
Looks: []dto.RunwayLook{
// look 1:主图 a + 细节图 a1 → 1 主图
{Main: srv.URL + "/img/a.jpg", Details: []string{srv.URL + "/img/a1.jpg"}},
// look 2:空主图,只有细节图 b1 → 0 主图(旧实现仍按 1 计)
{Details: []string{srv.URL + "/img/b1.jpg"}},
// look 3:主图与 look 1 内容相同(sha1 重复)→ 被跳过,0 主图(旧实现仍按 1 计)
{Main: srv.URL + "/img/a.jpg"},
// look 4:主图 c → 1 主图
{Main: srv.URL + "/img/c.jpg"},
},
}
job := model.IngestJob{ID: 1, Kind: model.IngestKindCrawl}
s.processRunway(context.Background(), job, p)
if repo.createdRW == nil {
t.Fatal("未写入走秀记录")
}
// 实际落库主图行:a、c → 2 张;细节图 a1、b1 → 2 张。
var actualMain uint16
for _, im := range repo.createdImages {
if im.IsDetail == 0 {
actualMain++
}
}
if actualMain != 2 {
t.Fatalf("前置:实际主图行应为 2,实际 %d(图片行=%+v)", actualMain, repo.createdImages)
}
if got := repo.createdRW.ImageCount; got != actualMain {
t.Fatalf("入库 image_count 应等于存活主图行数 %d,实际 %d", actualMain, got)
}
if repo.createdRW.ImageCount >= uint16(len(p.Looks)) {
t.Fatalf("本用例数据应让 image_count(%d) 严格小于 len(Looks)=%d,否则测不出漂移",
repo.createdRW.ImageCount, len(p.Looks))
}
}