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
|
||||
|
||||
Reference in New Issue
Block a user