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

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