Files
backend_v2/internal/service/ingest_image_count_test.go
toom1996 c8a70760cc feat: 入库结果落库 + 品牌详情展示更新记录
背景:任务只有 status=done,分不清「新建」「已存在跳过」「已删除跳过」,
品牌更新历史只能在进程日志里翻,日志一滚就没了。

- ingest_jobs 新增 outcome / note / brand_id(迁移 2026-10-25-01)
  outcome 记结果类型,note 记人话细节(新建 N 张图 / 已存在(status=published))
- 提交任务时解出 brand_id 落库,品牌详情页按 (brand_id, id DESC) 直接查
- 处理时优先用 job.brand_id,payload 里的 hashid 只作老任务兜底:
  避免「别的实例盐不同 → 解出错品牌 → 整场 brand not found」再发生
- 品牌编辑页新增「更新记录」区块:时间 / 场次 / 结果 / 明细(含失败原因与尝试次数)
- 测试:fake 仓储补齐新接口,capture 仓储记录 outcome 供断言
2026-10-25 11:06:16 +08:00

177 lines
6.5 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
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
doneOutcome string // 最近一次 MarkDone 的结果类型(断言「跳过」是否标清楚)
doneNote string
}
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
if c.deletedEntity {
return 1, model.StatusPending, true, true, nil // 命中但已软删
}
return 0, "", false, 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) MarkDone(ctx context.Context, id uint32, outcome, note string) error {
// 记下 outcome/note,供「跳过的场次也要标清楚原因」这类断言使用
c.doneOutcome = outcome
c.doneNote = note
return nil
}
// ListByBrand 捕获型仓储不需要品牌维度查询。
func (c *captureIngestRepo) ListByBrand(ctx context.Context, brandID uint32, limit int) ([]repository.BrandIngestRecord, error) {
return nil, nil
}
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID 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))
}
}
// TestProcessRunwaySkipsSoftDeletedEntity 回归「删除即永久」:
// 实体键已软删(is_deleted=1)的走秀,重爬时不应被重新写入正式表,仅置任务 done。
func TestProcessRunwaySkipsSoftDeletedEntity(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")
_, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path))
}))
defer srv.Close()
// deletedEntity=true:模拟实体键已软删,processRunway 应跳过重爬。
repo := &captureIngestRepo{deletedEntity: true}
s := &IngestService{
repo: repo,
brandRepo: fakeBrandRepo{},
uploader: local,
local: local,
httpClient: srv.Client(),
}
p := dto.RunwayIngest{
Kind: dto.IngestKindRunway,
BrandUID: hashid.Encode(1),
TitleEn: "deleted-entity-test",
Year: 2027,
Season: "spring",
CollectionType: "rtw",
Looks: []dto.RunwayLook{{Main: srv.URL + "/img/a.jpg"}},
}
job := model.IngestJob{ID: 2, Kind: model.IngestKindCrawl}
s.processRunway(context.Background(), job, p)
if repo.createdRW != nil {
t.Fatal("已软删的实体不应被重新写入正式表")
}
if len(repo.createdImages) != 0 {
t.Fatalf("已软删的实体不应写入任何图片行,实际 %d 张", len(repo.createdImages))
}
}