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:
@ -466,8 +466,9 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
||||
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
|
||||
// phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
|
||||
//
|
||||
// 只比对未软删的行(is_deleted = 0):复用驳回行时去重先于软删旧图执行,
|
||||
// 否则重爬到的同一张图会命中「即将被软删的旧行」,留痕 dup_of 指向一条公开不可见的记录。
|
||||
// 只比对未软删的行(is_deleted = 0):已被软删(审核剔除 / 复用重审)的旧图不应再作为
|
||||
// 「重复」的参照。注意复用驳回行的时序 —— 去重发生在软删旧图**之前**,那一刻旧行仍是
|
||||
// is_deleted = 0、会被正常命中;dup_of 只是人工留痕,不承担可见性语义。
|
||||
// 本方法被 runway / street 两条入库路径共用,过滤对两者语义一致。
|
||||
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
||||
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
|
||||
|
||||
@ -181,12 +181,14 @@ func TestIngestReuseRejectedRefusesPublished(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupIgnoresSoftDeletedImage 去重不得命中已软删的行:复用驳回行时去重先于软删旧图执行,
|
||||
// 若把「即将被软删的旧图」算进比对,重爬到的同一张图会被打上 dup_of=<旧行 id>,
|
||||
// 留痕指向一条随后公开不可见的记录。
|
||||
// TestDedupIgnoresSoftDeletedImage 去重只比对**未软删**的行(is_deleted = 0)。
|
||||
//
|
||||
// 用与探测向量完全相同的 phash(距离 0)插入软删行,保证修复前它必然是「最近一条」被返回,
|
||||
// 因此本断言在修复前确定性失败、修复后确定性通过,与库中其它数据无关。
|
||||
// 别把它误读成「复用驳回行时不会自我命中」:复用路径里去重发生在软删旧图**之前**,那一刻
|
||||
// 旧行仍是 is_deleted = 0、仍会被命中——dup_of 只是给人工看的留痕,不参与任何可见性判定。
|
||||
// 本用例锁的是另一条独立保证:**已经**软删的行不再被当作重复参照。
|
||||
//
|
||||
// 用与探测向量完全相同的 phash(距离 0)插入软删行,保证它若被纳入就必然是「最近一条」,
|
||||
// 从而确定性地验证 is_deleted = 0 过滤生效,与库中其它数据无关。
|
||||
func TestDedupIgnoresSoftDeletedImage(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
@ -213,3 +215,46 @@ func TestDedupIgnoresSoftDeletedImage(t *testing.T) {
|
||||
t.Fatalf("已软删的行(id=%d)不应参与近重复比对", row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestImageCountMatchesAliveMainRows 入库后 image_count 必须等于该记录存活主图行数(is_detail = 0)。
|
||||
//
|
||||
// 与 service.processRunway 的 imageCount = countMainImages(images) 同口径,且与审核期
|
||||
// SoftDeleteRunwayImage 的重算口径一致:三处必须同源,否则公开卡片「N 张」角标与
|
||||
// FeaturedIDs 的 SUM(image_count) 热度会漂移(单表发布删掉了晋升重算的自愈路径)。
|
||||
func TestIngestImageCountMatchesAliveMainRows(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
repo := NewIngestRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
const season = "IC71"
|
||||
rw := newRunwayForIngest(70, 1, season, "rtw", "ic-main-a.jpg")
|
||||
rw.ImageCount = 2 // 2 张主图
|
||||
imgs := []model.BrandRunwayImage{
|
||||
{BrandID: 1, Image: "ic-main-a.jpg", Name: "Look 1", SortOrder: 1, IsDetail: 0},
|
||||
{BrandID: 1, Image: "ic-detail-a1.jpg", Name: "Look 1 — Detail 1", SortOrder: 2, IsDetail: 1},
|
||||
{BrandID: 1, Image: "ic-main-b.jpg", Name: "Look 2", SortOrder: 3, IsDetail: 0},
|
||||
}
|
||||
id, err := repo.CreateRunwayWithImages(ctx, rw, imgs)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRunwayWithImages 出错: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
|
||||
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
|
||||
})
|
||||
|
||||
var got struct {
|
||||
ImageCount uint16 `gorm:"column:image_count"`
|
||||
AliveMain int64 `gorm:"column:alive_main"`
|
||||
}
|
||||
if err := db.Raw(`SELECT r.image_count,
|
||||
(SELECT COUNT(*) FROM brand_runway_images i
|
||||
WHERE i.runway_id = r.id AND i.is_deleted = 0 AND i.is_detail = 0) AS alive_main
|
||||
FROM brand_runways r WHERE r.id = ?`, id).Scan(&got).Error; err != nil {
|
||||
t.Fatalf("读回计数失败: %v", err)
|
||||
}
|
||||
if int64(got.ImageCount) != got.AliveMain {
|
||||
t.Fatalf("入库后 image_count=%d 必须等于存活主图行数 %d", got.ImageCount, got.AliveMain)
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,6 +61,21 @@ func NewReviewRepository(db *gorm.DB) ReviewRepository {
|
||||
return &reviewRepository{db: db}
|
||||
}
|
||||
|
||||
// reviewListScope 把「未软删 + 可选状态过滤」编译为 GORM Scope。
|
||||
//
|
||||
// 用 Scope 而非在 Count 之后复用同一个 *gorm.DB:GORM v2 中终结方法(Count 等)之后再复用
|
||||
// 同一实例会带上残留的 Statement 状态,Scope 每次作用于全新查询即可杜绝该隐患
|
||||
//(与 article_repository.filterScope 同一理由)。
|
||||
func reviewListScope(status string) func(*gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
db = db.Where("is_deleted = 0")
|
||||
if status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
return db
|
||||
}
|
||||
}
|
||||
|
||||
func (r *reviewRepository) ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@ -68,19 +83,17 @@ func (r *reviewRepository) ListRecords(ctx context.Context, status string, page,
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
q := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Where("is_deleted = 0")
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
scope := reviewListScope(status)
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.BrandRunway{}, 0, nil
|
||||
}
|
||||
var rows []model.BrandRunway
|
||||
if err := q.
|
||||
if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).
|
||||
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||||
Offset((page - 1) * size).
|
||||
Limit(size).
|
||||
@ -143,6 +156,11 @@ func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields m
|
||||
// SetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound。
|
||||
// 否则「对不存在的 id 通过 / 驳回」会被当作成功:handler 只校验 id != 0,id 又来自 URL,
|
||||
// 竞态窗口(记录刚被删除)下会给出假成功 302。
|
||||
//
|
||||
// 有意**不校验前置状态**:允许把 rejected 直接置 published(或反向),不做状态机约束。
|
||||
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
|
||||
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
|
||||
// 而不是悄悄在这个仓储方法里加限制。
|
||||
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
@ -183,19 +201,17 @@ func (r *reviewRepository) ListStreetRecords(ctx context.Context, status string,
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
q := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Where("is_deleted = 0")
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
scope := reviewListScope(status)
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.StreetSnap{}, 0, nil
|
||||
}
|
||||
var rows []model.StreetSnap
|
||||
if err := q.
|
||||
if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).
|
||||
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||||
Offset((page - 1) * size).
|
||||
Limit(size).
|
||||
@ -250,6 +266,7 @@ func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fi
|
||||
}
|
||||
|
||||
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
|
||||
// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
|
||||
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
|
||||
103
internal/repository/review_status_transition_integration_test.go
Normal file
103
internal/repository/review_status_transition_integration_test.go
Normal file
@ -0,0 +1,103 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:「通过 → 公开可见」这条单表发布主链路的**仓储级**证据。
|
||||
//
|
||||
// 背景:公开视图测试(TestPublicViewsHideUnpublished*)都是直接 `db.Model(...).Update("status", ...)`
|
||||
// 改库、绕过了 `Approve` 走的 SetRecordStatus / SetStreetRecordStatus,这两个仓储方法在全仓零直接引用。
|
||||
// 一旦它们改错列 / 漏写 status,那些测试仍会全绿、线上却无法通过审核。本文件补上这一段。
|
||||
//
|
||||
// 运行:go test -tags integration ./internal/repository/ -run TestSetRecordStatus -v
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
)
|
||||
|
||||
// TestSetRecordStatusDrivesPublicVisibility 走秀:pending 经仓储置 published 后在公开视图可见;
|
||||
// 再置 rejected 后不可见;对不存在的 id 返回 ErrNotFound。
|
||||
func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
repo := NewReviewRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
id := seedRunway(t, db, model.StatusPending, 1991)
|
||||
publicCount := func() int64 {
|
||||
var n int64
|
||||
db.Table("public_brand_runways").Where("id = ?", id).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
if got := publicCount(); got != 0 {
|
||||
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
||||
}
|
||||
|
||||
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
||||
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
||||
}
|
||||
if got := publicCount(); got != 1 {
|
||||
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
||||
}
|
||||
|
||||
if err := repo.SetRecordStatus(ctx, id, model.StatusRejected, "admin", "图片不对"); err != nil {
|
||||
t.Fatalf("SetRecordStatus(rejected) 出错: %v", err)
|
||||
}
|
||||
if got := publicCount(); got != 0 {
|
||||
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
|
||||
}
|
||||
|
||||
// 审核痕迹应落库,便于列表页展示。
|
||||
var rw model.BrandRunway
|
||||
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
||||
t.Fatalf("读回记录失败: %v", err)
|
||||
}
|
||||
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" || rw.RejectReason != "图片不对" {
|
||||
t.Fatalf("审核态与痕迹应落库,实际 status=%s reviewer=%s reason=%s", rw.Status, rw.Reviewer, rw.RejectReason)
|
||||
}
|
||||
|
||||
// 不存在的 id:必须返回 ErrNotFound,而不是假成功。
|
||||
if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetStreetRecordStatusDrivesPublicVisibility 街拍:与走秀同形,覆盖另一组方法与视图。
|
||||
func TestSetStreetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
repo := NewReviewRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
id := seedStreetSnap(t, db, model.StatusPending, 1990)
|
||||
publicCount := func() int64 {
|
||||
var n int64
|
||||
db.Table("public_street_snaps").Where("id = ?", id).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
if got := publicCount(); got != 0 {
|
||||
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
||||
}
|
||||
|
||||
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
||||
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
||||
}
|
||||
if got := publicCount(); got != 1 {
|
||||
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
||||
}
|
||||
|
||||
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, "admin", "重复"); err != nil {
|
||||
t.Fatalf("SetStreetRecordStatus(rejected) 出错: %v", err)
|
||||
}
|
||||
if got := publicCount(); got != 0 {
|
||||
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
|
||||
}
|
||||
|
||||
if err := repo.SetStreetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
||||
}
|
||||
}
|
||||
@ -271,8 +271,10 @@ func TestSoftDeleteStreetImageRecomputesCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSoftDeleteRunwayImage 走秀 image_count 口径是「主图数」(与入库 imageCount = len(Looks) 一致):
|
||||
// 软删细节图不改计数;软删主图才减,且同样是重算(重放 / 跨记录不漂移)。
|
||||
// TestSoftDeleteRunwayImage 走秀 image_count 口径是「存活主图行数」(与入库 countMainImages 一致)。
|
||||
//
|
||||
// 关键断言在「软删一张主图、让细节图**仍存活**」这一步:重算必须排除 is_detail=1 的行。
|
||||
// 若先删细节图再删主图,过滤与否结果相同(旧写法就锁不住口径),故这里把顺序倒过来。
|
||||
func TestSoftDeleteRunwayImage(t *testing.T) {
|
||||
repo := NewReviewRepository(testDB(t))
|
||||
ctx := context.Background()
|
||||
@ -282,20 +284,21 @@ func TestSoftDeleteRunwayImage(t *testing.T) {
|
||||
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。
|
||||
// 先软删一张主图,细节图此刻仍存活:只计主图 → 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("软删一张主图后应为 1,实际 %d", got)
|
||||
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)
|
||||
}
|
||||
|
||||
// 重放(已软删)+ 跨记录:计数均不得变化。
|
||||
|
||||
@ -205,7 +205,6 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IDs 返回全部未删除街拍的 id(升序),供 SSG 构建期枚举详情页路径。
|
||||
// Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。
|
||||
func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) {
|
||||
var rows []model.StreetSnap
|
||||
|
||||
117
internal/service/ingest_image_count_test.go
Normal file
117
internal/service/ingest_image_count_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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)。
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user