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

@ -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)
}
}