update
This commit is contained in:
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user