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 注释写明有意不校验前置状态;规格补两条已知不一致。
This commit is contained in:
toom1996
2026-09-23 16:03:22 +08:00
parent 3fc8b1f924
commit be4db0bf96
19 changed files with 478 additions and 80 deletions

View File

@ -0,0 +1,117 @@
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))
}
}

View File

@ -272,7 +272,11 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
var timing *fetchTiming
if len(p.Looks) > 0 {
cover, images, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
imageCount = uint16(len(p.Looks))
// image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks):
// fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。
// 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks),
// 漂移会永久留在公开卡片的「N 张」角标与 FeaturedIDs 的 SUM(image_count) 热度上。
imageCount = countMainImages(images)
} else {
var fimgs []fetchedImage
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
@ -337,7 +341,8 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
// BrandRunwayImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
// 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。
//
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
@ -415,6 +420,18 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
return cover, rows, keys, failed, timing
}
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。
func countMainImages(images []model.BrandRunwayImage) uint16 {
var n uint16
for i := range images {
if images[i].IsDetail == 0 {
n++
}
}
return n
}
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 实体键查重并决定分支(单表模型,同 runway)。

View File

@ -260,7 +260,6 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
return out
}
// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
rows, err := s.repo.Popular(ctx, limit)