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

@ -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(汉明阈值)。

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

View File

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

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

View File

@ -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)
}
// 重放(已软删)+ 跨记录:计数均不得变化。

View File

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