This commit is contained in:
toom1996
2026-09-28 10:52:50 +08:00
parent aaeab41d67
commit d15d2a4701
42 changed files with 19884 additions and 337 deletions

View File

@ -3,11 +3,13 @@ package repository
import (
"context"
"errors"
"math"
"strconv"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
@ -37,6 +39,13 @@ type ArticleRepository interface {
DeleteRunwayImage(ctx context.Context, imageID uint32) error
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
// GetRunwayDupSources 批量反查重复源:给定一组走秀图片 id(即 dup_of 指向的源图 id),
// 返回每张源图及其所属走秀(标题、品牌)的上下文,供编辑页标记重复图时展示来源。
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
// ReresolveRunwayDedup 删图后重算本画廊去重,消除陈旧 dup_of(见方法注释)。
ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
@ -389,6 +398,128 @@ func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint3
return img.Image, nil
}
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,
// 供编辑页删图后重算去重时定位所属画廊)。
func (r *articleRepository) GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
var img model.BrandRunwayImage
err := r.db.WithContext(ctx).Select("runway_id").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return img.RunwayID, nil
}
// nearDupExceptRunway 在 brand_runway_images 内做全局近重复检索(is_deleted=0),
// 但排除自身与「同 runway 且 sort_order 在后的图」——后者防止重算时后入者反成源导致互指环。
// 命中返回源图 id,无则 0。
func nearDupExceptRunway(db *gorm.DB, phashBits string, threshold int, excludeID, runwayID, sortOrder uint32) (uint32, error) {
l2Limit := math.Sqrt(float64(threshold))
var row struct {
ID uint32 `gorm:"column:id"`
}
if err := db.Table("brand_runway_images").
Select("id, (phash <-> ?::vector) AS dist", phashBits).
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
Where("is_deleted = 0").
Where("id <> ?", excludeID).
Where("(runway_id <> ? OR sort_order < ?)", runwayID, sortOrder).
Order("dist ASC").
Limit(1).
Scan(&row).Error; err != nil {
return 0, err
}
return row.ID, nil
}
// ReresolveRunwayDedup 删除图片后重解本画廊去重:先清空全部存活图的 is_duplicate/dup_of,
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
//
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;
// 删除「源图」后,剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」,且「查看重复来源」指向已删
// 源图(其 S4 文件可能已被清理)而打不开。重算后,被删源图不再作为参照,剩余近重复图重新确立
// 「先入者为准」的源/副本关系,使「重复」标记始终反映当前真实图片。
func (r *articleRepository) ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error {
var imgs []model.BrandRunwayImage
if err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error; err != nil {
return err
}
if len(imgs) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
return err
}
for _, im := range imgs {
if !im.Phash.Valid || im.Phash.String == "" {
continue
}
src, err := nearDupExceptRunway(tx, im.Phash.String, phash.DefaultThreshold, im.ID, runwayID, im.SortOrder)
if err != nil {
return err
}
if src != 0 && src != im.ID {
if err := tx.Model(&model.BrandRunwayImage{}).
Where("id = ? AND is_deleted = 0", im.ID).
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
return err
}
}
}
return nil
})
}
// GetRunwayDupSources 批量反查重复源(见接口注释)。一次 IN 查询 + JOIN 走秀与品牌,
// 避免逐图 N+1;源图可能已被删(查不到则跳过,不报错)。
func (r *articleRepository) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
out := make(map[uint32]dto.DupSource, len(imageIDs))
if len(imageIDs) == 0 {
return out, nil
}
type srcRow struct {
ImageID uint32
Image string
RunwayID uint32
TitleEn string
TitleCn string
BrandEn string
BrandCn string
}
var rows []srcRow
err := r.db.WithContext(ctx).
Table("brand_runway_images bri").
Select("bri.id AS image_id, bri.image AS image, bri.runway_id AS runway_id, br.title_en AS title_en, br.title_cn AS title_cn, b.name_en AS brand_en, b.name_cn AS brand_cn").
Joins("JOIN brand_runways br ON br.id = bri.runway_id").
Joins("LEFT JOIN brands b ON b.id = br.brand_id").
Where("bri.id IN ?", imageIDs).
Where("bri.is_deleted = 0").
Find(&rows).Error
if err != nil {
return nil, err
}
for _, rw := range rows {
title := rw.TitleEn
if title == "" {
title = rw.TitleCn
}
brand := rw.BrandEn
if brand == "" {
brand = rw.BrandCn
}
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.RunwayID, Title: title, Brand: brand}
}
return out, nil
}
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {

View File

@ -13,11 +13,14 @@ package repository
import (
"context"
"database/sql"
"fmt"
"strconv"
"testing"
"time"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
@ -91,6 +94,85 @@ func seedEditorSnapImage(t *testing.T, db *gorm.DB, snapID uint32, name string,
return img.ID
}
// seedEditorRunwayImagePhash 插入一张带 phash 的走秀图(isDetail 表示主/细节),返回行 id。
func seedEditorRunwayImagePhash(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8, phashBits string) uint32 {
t.Helper()
var ph sql.NullString
if phashBits != "" {
ph = sql.NullString{String: phashBits, Valid: true}
}
img := model.BrandRunwayImage{
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
SortOrder: order, IsDetail: isDetail, Phash: ph,
}
if err := db.Create(&img).Error; err != nil {
t.Fatalf("插入带 phash 走秀图片失败: %v", err)
}
return img.ID
}
// editorImageFlag 读某图片的 is_duplicate / dup_of(验证去重重算结果)。
func editorImageFlag(t *testing.T, db *gorm.DB, id uint32) (uint8, string) {
t.Helper()
var row struct {
IsDuplicate uint8 `gorm:"column:is_duplicate"`
DupOf string `gorm:"column:dup_of"`
}
if err := db.Table("brand_runway_images").Select("is_duplicate, dup_of").Where("id = ?", id).Scan(&row).Error; err != nil {
t.Fatalf("读去重标记失败: %v", err)
}
return row.IsDuplicate, row.DupOf
}
// TestEditorReresolveRunwayDedupAfterSourceDeleted 模拟「删掉源图后」重算去重应消除悬空 dup_of:
// A 为源(被删),B、C 曾 dup_of=A;重算后 A 退出参照,B 成为新源(dup=0),C 重指 B(dup=1),
// 不再有任何图指向已删的 A —— 对应线上「删了源图后仍显示重复、源图查看重复来源打不开」。
func TestEditorReresolveRunwayDedupAfterSourceDeleted(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 3)
// A=全1 向量;B 翻转 1 位(距 A=1);C 翻转 2 位(距 A=2)—— 三者互为近重复。
allOne := phash.ToVectorBits(^uint64(0))
bVec := phash.ToVectorBits(^uint64(0) &^ (uint64(1) << 0))
cVec := phash.ToVectorBits(^uint64(0) &^ ((uint64(1) << 0) | (uint64(1) << 1)))
a := seedEditorRunwayImagePhash(t, db, id, "A", 1, 0, allOne)
b := seedEditorRunwayImagePhash(t, db, id, "B", 2, 0, bVec)
c := seedEditorRunwayImagePhash(t, db, id, "C", 3, 0, cVec)
// 预置入库去重后的陈旧态:B、C 都 dup_of=A。
if err := db.Model(&model.BrandRunwayImage{}).Where("id IN ?", []uint32{b, c}).
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(a), 10)}).Error; err != nil {
t.Fatalf("预置陈旧去重标记失败: %v", err)
}
// 删掉源图 A(软删,模拟编辑页删除)。
if err := repo.DeleteRunwayImage(ctx, a); err != nil {
t.Fatalf("删源图失败: %v", err)
}
// 重算去重(编辑页删图会自动调用,这里直接验证仓储方法)。
if err := repo.ReresolveRunwayDedup(ctx, id); err != nil {
t.Fatalf("重算去重失败: %v", err)
}
diB, ofB := editorImageFlag(t, db, b)
diC, ofC := editorImageFlag(t, db, c)
if diB != 0 {
t.Fatalf("B 应成为新源(is_duplicate=0),实际 %d", diB)
}
if ofB != "" {
t.Fatalf("B 的 dup_of 应为空,实际 %q", ofB)
}
if diC != 1 {
t.Fatalf("C 应标记为重复(is_duplicate=1),实际 %d", diC)
}
if ofC != strconv.FormatUint(uint64(b), 10) {
t.Fatalf("C 的 dup_of 应指向 B(%d),实际 %q", b, ofC)
}
}
// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
t.Helper()

View File

@ -1,7 +1,10 @@
//go:build integration
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行」,
// 同时不阻止「实体键相同但已软删」的行(与 RunwayEntityState 的 is_deleted = 0 口径一致)。
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行活着」。
//
// 注意:部分索引(WHERE is_deleted = 0)在 DB 层仍允许「软删后再插入同键」;
// 但入库服务层(processRunway / processStreet)现已在查重阶段跳过重爬已软删的实体,
// 从而达成「删除即永久」。本测试只验证索引这一层的行为。
package repository
import (
@ -35,14 +38,15 @@ func TestRunwayEntityKeyUnique(t *testing.T) {
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
}
// 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。
// 软删首行后,部分索引(WHERE is_deleted = 0)不再占用该键,DB 层仍允许再插入。
// 「删除即永久」由入库服务层(RunwayEntityState 命中 isDeleted 跳过重爬)保证,而非本索引。
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
t.Fatalf("软删失败: %v", err)
}
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
if err != nil {
t.Fatalf("软删后应可再插入同实体键: %v", err)
t.Fatalf("部分索引下软删后仍可再插入同实体键(服务层才拦截): %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID)
@ -137,14 +141,14 @@ func TestStreetEntityStateMatchesIndexKey(t *testing.T) {
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) })
// ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。
if _, _, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil {
if _, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil {
t.Fatalf("查重出错: %v", err)
} else if found {
t.Fatalf("不同 title 不应命中既有行,实际命中")
}
// ② 同城同年**同 title** → 命中,且带出正确 id / status。
hitID, status, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2")
hitID, status, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2")
if err != nil {
t.Fatalf("查重出错: %v", err)
}
@ -166,7 +170,7 @@ func TestStreetEntityStateMatchesIndexKey(t *testing.T) {
}
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) })
nid, _, nfound, err := repo.StreetSnapEntityState(ctx, city, year, "")
nid, _, nfound, _, err := repo.StreetSnapEntityState(ctx, city, year, "")
if err != nil {
t.Fatalf("查重出错: %v", err)
}

View File

@ -72,7 +72,7 @@ func TestApproveKeepsImageRowIDs(t *testing.T) {
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
}
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
}
@ -122,7 +122,7 @@ func TestApproveKeepsSnapImageRowIDs(t *testing.T) {
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
}
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
}

View File

@ -35,19 +35,30 @@ type IngestRepository interface {
MarkFailed(ctx context.Context, id uint32, errMsg string) error
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,返回其状态。
// worker 据此分三支:pending/published → 直接放弃本次入库;rejected → 复用该行重审;未命中 → 新建。
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, err error)
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,
// 返回其状态与是否已被软删(isDeleted)。worker 据此分四支:
// - 命中 pending/published → 直接放弃本次入库(ingestSkip)
// - 命中 rejected → 复用该行重审(ingestReuseRejected)
// - 命中但已软删 → 跳过重爬(删除即永久,不再因重爬复活)
// - 未命中 → 新建(ingestCreate)
// 注意:本查询不再以 is_deleted = 0 为过滤条件(旧版口径),否则软删行会被当成「未命中」
// 而重新入库,违背「删除即永久」。优先取活着行(ORDER BY is_deleted ASC, id DESC)。
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, isDeleted bool, err error)
// CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
// BackfillRunwayPublishedAt 临时回填:给已存在走秀补 published_at,并在标题缺年份时
// 用发布日期年份补全 year / season_code(不重下图片)。回填为一次性操作,按主键 id 更新。
// publishedAt 为 Unix 时间戳(秒),0 表示未抽取。
BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
// 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error)
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, isDeleted bool, err error)
// CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。
CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
@ -248,24 +259,29 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool
// ---- 入库直写正式表(单表发布模型)----
func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行。
// 不再过滤 is_deleted:软删行也会被查到,由调用方据此跳过重爬(删除即永久)。
// 优先取活着行(is_deleted ASC),保证「活着行 + 软删行同键共存」的存量数据下也返回活行。
func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
var row struct {
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
IsDeleted uint8 `gorm:"column:is_deleted"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("id, status").
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
Select("id, status, is_deleted").
Where("brand_id = ? AND season_code = ? AND collection_type = ?", brandID, seasonCode, collectionType).
Order("is_deleted ASC, id DESC").
Limit(1).
Scan(&row).Error
if err != nil {
return 0, "", false, err
return 0, "", false, false, err
}
if row.ID == 0 {
return 0, "", false, nil
return 0, "", false, false, nil
}
return row.ID, row.Status, true, nil
return row.ID, row.Status, true, row.IsDeleted == 1, nil
}
func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
@ -372,32 +388,72 @@ func (r *ingestRepository) RepairRunwayDetailParents(ctx context.Context, runway
return nil
}
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// BackfillRunwayPublishedAt 临时回填(一次性):按主键 id 给已存在走秀补 published_at;
// 并在标题缺年份(year=0)时,用发布日期年份补全 year / season_code。
//
// 设计要点:
// - published_at 总是写入(按 id,幂等,不会触犯唯一索引)。
// - year / season_code 仅在「当前 year=0」且「目标 season_code 不与同品牌其它行冲突」时补全,
// 避免触犯 (brand_id, season_code, collection_type) 唯一索引;冲突时只补 published_at,留待人工处理。
// - 不重下图片。
// - publishedAt 为 Unix 时间戳(秒),0 表示未抽取。
func (r *ingestRepository) BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error {
now := time.Now().Unix()
if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ? AND is_deleted = 0", runwayID).
Updates(map[string]any{"published_at": publishedAt, "updated_at": now}).Error; err != nil {
return err
}
if year == 0 {
return nil
}
// 仅当目标 season_code 不与同品牌其它行冲突时才补全 year / season_code。
return r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ? AND year = 0 AND is_deleted = 0 "+
"AND NOT EXISTS (SELECT 1 FROM brand_runways b "+
"WHERE b.brand_id = brand_runways.brand_id AND b.season_code = ? "+
"AND b.collection_type = brand_runways.collection_type AND b.id <> brand_runways.id AND b.is_deleted = 0)",
targetSeasonCode).
Updates(map[string]any{
"year": year,
"season_code": targetSeasonCode,
"updated_at": now,
}).Error
}
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
// 进而插入一行注定被索引拒绝的记录。
//
// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致,
// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) {
//
// 不再过滤 is_deleted:软删行也会被查到,由调用方据此跳过重爬(删除即永久)。
// 与 RunwayEntityState 同源,优先取活着行(ORDER BY is_deleted ASC, id DESC)。
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, bool, error) {
title = NormalizeStreetTitle(title)
var row struct {
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
IsDeleted uint8 `gorm:"column:is_deleted"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Select("id, status").
Where("city = ? AND year = ? AND COALESCE(title, '') = ? AND is_deleted = 0", city, year, title).
Select("id, status, is_deleted").
Where("city = ? AND year = ? AND COALESCE(title, '') = ?", city, year, title).
Order("is_deleted ASC, id DESC").
Limit(1).
Scan(&row).Error
if err != nil {
return 0, "", false, err
return 0, "", false, false, err
}
if row.ID == 0 {
return 0, "", false, nil
return 0, "", false, false, nil
}
return row.ID, row.Status, true, nil
return row.ID, row.Status, true, row.IsDeleted == 1, nil
}
// NormalizeStreetTitle 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)

View File

@ -33,7 +33,7 @@ func TestIngestCreatesPendingRecord(t *testing.T) {
ctx := context.Background()
const season = "SS97"
_, _, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
_, _, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil {
t.Fatalf("RunwayEntityState 出错: %v", err)
}
@ -58,7 +58,7 @@ func TestIngestCreatesPendingRecord(t *testing.T) {
t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status)
}
gotID, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
gotID, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil {
t.Fatalf("RunwayEntityState 出错: %v", err)
}
@ -90,11 +90,11 @@ func TestIngestReusesRejectedRecord(t *testing.T) {
db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID)
})
if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID).
Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin", "reject_reason": "图片缺失"}).Error; err != nil {
Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin"}).Error; err != nil {
t.Fatalf("置为已驳回失败: %v", err)
}
_, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
_, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil || !found || status != model.StatusRejected {
t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err)
}
@ -115,8 +115,8 @@ func TestIngestReusesRejectedRecord(t *testing.T) {
if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" {
t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
}
if rw.Reviewer != "" || rw.RejectReason != "" {
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s reason=%s", rw.Reviewer, rw.RejectReason)
if rw.Reviewer != "" {
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s", rw.Reviewer)
}
var alive []model.BrandRunwayImage

View File

@ -24,8 +24,8 @@ type ReviewRepository interface {
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
// SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由;记录不存在返回 ErrNotFound。
SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// SetRecordStatus 设置状态(通过/驳回)与审核人;记录不存在返回 ErrNotFound。
SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
@ -33,7 +33,7 @@ type ReviewRepository interface {
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
// SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
@ -166,15 +166,14 @@ func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields m
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
// 而不是悄悄在这个仓储方法里加限制。
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
res := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"updated_at": uint32(time.Now().Unix()),
"status": status,
"reviewer": reviewer,
"updated_at": uint32(time.Now().Unix()),
})
if res.Error != nil {
return res.Error
@ -272,15 +271,14 @@ 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 {
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
res := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"updated_at": uint32(time.Now().Unix()),
"status": status,
"reviewer": reviewer,
"updated_at": uint32(time.Now().Unix()),
})
if res.Error != nil {
return res.Error

View File

@ -36,14 +36,14 @@ func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
}
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
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 {
if err := repo.SetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil {
t.Fatalf("SetRecordStatus(rejected) 出错: %v", err)
}
if got := publicCount(); got != 0 {
@ -55,12 +55,12 @@ func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
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)
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" {
t.Fatalf("审核态与审核人应落库,实际 status=%s reviewer=%s", rw.Status, rw.Reviewer)
}
// 不存在的 id:必须返回 ErrNotFound,而不是假成功。
if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin", ""); !errors.Is(err, ErrNotFound) {
if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
}
}
@ -83,21 +83,21 @@ func TestSetStreetRecordStatusDrivesPublicVisibility(t *testing.T) {
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
}
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
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 {
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) {
if err := repo.SetStreetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
}
}

View File

@ -3,11 +3,13 @@ package repository
import (
"context"
"errors"
"math"
"strconv"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
@ -39,6 +41,13 @@ type StreetSnapRepository interface {
DeleteSnapImage(ctx context.Context, imageID uint32) error
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
// GetStreetSnapDupSources 批量反查重复源:给定一组街拍图片 id(dup_of 指向的源图 id),
// 返回每张源图及其所属街拍的上下文,供编辑页标记重复图时展示来源。
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
// ReresolveSnapDedup 删图后重算本专辑去重,消除陈旧 dup_of(见方法注释)。
ReresolveSnapDedup(ctx context.Context, snapID uint32) error
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
// SetSnapCover 更新街拍封面。
@ -333,6 +342,117 @@ func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint
return img.Image, nil
}
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供编辑页删图后重算去重)。
func (r *streetSnapRepository) GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
var img model.StreetSnapImage
err := r.db.WithContext(ctx).Select("snap_id").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return img.SnapID, nil
}
// nearDupExceptSnap 在 street_snap_images 内做全局近重复检索(is_deleted=0),但排除自身与
// 「同 snap 且 sort_order 在后的图」——防止重算时后入者反成源导致互指环。命中返回源图 id,无则 0。
func nearDupExceptSnap(db *gorm.DB, phashBits string, threshold int, excludeID, snapID, sortOrder uint32) (uint32, error) {
l2Limit := math.Sqrt(float64(threshold))
var row struct {
ID uint32 `gorm:"column:id"`
}
if err := db.Table("street_snap_images").
Select("id, (phash <-> ?::vector) AS dist", phashBits).
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
Where("is_deleted = 0").
Where("id <> ?", excludeID).
Where("(snap_id <> ? OR sort_order < ?)", snapID, sortOrder).
Order("dist ASC").
Limit(1).
Scan(&row).Error; err != nil {
return 0, err
}
return row.ID, nil
}
// ReresolveSnapDedup 删除图片后重解本专辑去重:先清空全部存活图的 is_duplicate/dup_of,
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
//
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;删除「源图」
// 后剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」且「查看重复来源」指向已删源图而打不开。重算后
// 被删源图不再作为参照,剩余近重复图重新确立「先入者为准」的源/副本关系。
func (r *streetSnapRepository) ReresolveSnapDedup(ctx context.Context, snapID uint32) error {
var imgs []model.StreetSnapImage
if err := r.db.WithContext(ctx).
Where("snap_id = ? AND is_deleted = 0", snapID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error; err != nil {
return err
}
if len(imgs) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.StreetSnapImage{}).
Where("snap_id = ? AND is_deleted = 0", snapID).
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
return err
}
for _, im := range imgs {
if !im.Phash.Valid || im.Phash.String == "" {
continue
}
src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, snapID, im.SortOrder)
if err != nil {
return err
}
if src != 0 && src != im.ID {
if err := tx.Model(&model.StreetSnapImage{}).
Where("id = ? AND is_deleted = 0", im.ID).
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
return err
}
}
}
return nil
})
}
// GetStreetSnapDupSources 批量反查重复源(见接口注释)。街拍无品牌关联;一次 IN 查询 + JOIN。
func (r *streetSnapRepository) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
out := make(map[uint32]dto.DupSource, len(imageIDs))
if len(imageIDs) == 0 {
return out, nil
}
type srcRow struct {
ImageID uint32
Image string
SnapID uint32
Title string
TitleCn string
}
var rows []srcRow
err := r.db.WithContext(ctx).
Table("street_snap_images ssi").
Select("ssi.id AS image_id, ssi.image AS image, ssi.snap_id AS snap_id, ss.title AS title, ss.title_cn AS title_cn").
Joins("JOIN street_snaps ss ON ss.id = ssi.snap_id").
Where("ssi.id IN ?", imageIDs).
Where("ssi.is_deleted = 0").
Find(&rows).Error
if err != nil {
return nil, err
}
for _, rw := range rows {
title := rw.Title
if title == "" {
title = rw.TitleCn
}
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.SnapID, Title: title}
}
return out, nil
}
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {

View File

@ -38,7 +38,7 @@ func TestStreetTitleNormalizationUnifiesEntity(t *testing.T) {
})
// 查重口径:用未归一化的原始标题(多空格)→ 必须命中已落库行。
hid, _, found, err := repo.StreetSnapEntityState(ctx, city, year, "Norm Show 2026")
hid, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "Norm Show 2026")
if err != nil {
t.Fatalf("查重出错: %v", err)
}