update
This commit is contained in:
@ -46,6 +46,10 @@ type ArticleRepository interface {
|
||||
GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
||||
// ReresolveRunwayDedup 删图后重算本画廊去重,消除陈旧 dup_of(见方法注释)。
|
||||
ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error
|
||||
// ReresolveRunwayDupOfSources 删图后清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
||||
ReresolveRunwayDupOfSources(ctx context.Context, deletedImageIDs []uint32) error
|
||||
// GetRunwayLiveImageIDs 取某走秀全部存活图片 id(级联下架前用于跨文章去重清理)。
|
||||
GetRunwayLiveImageIDs(ctx context.Context, runwayID uint32) ([]uint32, error)
|
||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
||||
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
||||
@ -480,6 +484,60 @@ func (r *articleRepository) ReresolveRunwayDedup(ctx context.Context, runwayID u
|
||||
})
|
||||
}
|
||||
|
||||
// ReresolveRunwayDupOfSources 删除图片后,清理「以被删图为源」的跨文章重复标记。
|
||||
// 给定刚被软删的走秀图片 id 列表,找出所有仍存活(其它文章)且 dup_of 指向它们的图片,
|
||||
// 在全局存活图里重新近重复判定:找到新源则改指新源,否则清掉重复标记。
|
||||
// 为什么需要:删 B 文章/图时,A 文章中 dup_of 指向 B 图的行不会被 ReresolveRunwayDedup
|
||||
// (仅本集内重算)清理,导致 A 仍误显「重复」且「查看来源」指向已删图打不开。
|
||||
func (r *articleRepository) ReresolveRunwayDupOfSources(ctx context.Context, deletedImageIDs []uint32) error {
|
||||
if len(deletedImageIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
strIDs := make([]string, 0, len(deletedImageIDs))
|
||||
for _, id := range deletedImageIDs {
|
||||
strIDs = append(strIDs, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
var affected []model.BrandRunwayImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("is_deleted = 0 AND dup_of IN ?", strIDs).
|
||||
Find(&affected).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, im := range affected {
|
||||
if !im.Phash.Valid || im.Phash.String == "" {
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
src, err := nearDupExceptRunway(tx, im.Phash.String, phash.DefaultThreshold, im.ID, im.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
|
||||
}
|
||||
} else {
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).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) {
|
||||
@ -554,6 +612,18 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetRunwayLiveImageIDs 取某走秀全部存活图片的 id(级联下架前取出,供跨文章去重清理)。
|
||||
func (r *articleRepository) GetRunwayLiveImageIDs(ctx context.Context, runwayID uint32) ([]uint32, error) {
|
||||
var ids []uint32
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
|
||||
@ -408,3 +408,49 @@ func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) {
|
||||
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorCrossArticleRunwayDupClearedOnSourceDeleted 回归「跨文章」去重清理:
|
||||
// A(重复方)的图 dup_of 指向 B(源方)的图;删掉整篇 B 后,A 的图不应再挂指向已删图的 dup_of,
|
||||
// 否则编辑页继续误显「重复」且「查看来源」打不开。对应线上「删了 B,A 的重复标记还在」。
|
||||
func TestEditorCrossArticleRunwayDupClearedOnSourceDeleted(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
// 两篇相互独立文章(不同实体键),各含一张近重复图(同 phash)。
|
||||
srcRunway := seedEditorRunway(t, db, 1) // 源方 B
|
||||
dupRunway := seedEditorRunway(t, db, 1) // 重复方 A
|
||||
allOne := phash.ToVectorBits(^uint64(0))
|
||||
srcImg := seedEditorRunwayImagePhash(t, db, srcRunway, "src", 1, 0, allOne)
|
||||
dupImg := seedEditorRunwayImagePhash(t, db, dupRunway, "dup", 1, 0, allOne)
|
||||
|
||||
// 预置陈旧态:A 的图标记为重复、dup_of 指向 B 的图(跨文章)。
|
||||
if err := db.Model(&model.BrandRunwayImage{}).Where("id = ?", dupImg).
|
||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(srcImg), 10)}).Error; err != nil {
|
||||
t.Fatalf("预置跨文章重复标记失败: %v", err)
|
||||
}
|
||||
|
||||
// 模拟后台删除整篇 B:软删其全部图片,再跨文章重算去重(PurgeGallery 的调用序列)。
|
||||
delIDs, err := repo.GetRunwayLiveImageIDs(ctx, srcRunway)
|
||||
if err != nil {
|
||||
t.Fatalf("取存活图片 id 失败: %v", err)
|
||||
}
|
||||
if _, err := repo.SoftDeleteRunwayImages(ctx, srcRunway); err != nil {
|
||||
t.Fatalf("下架删图失败: %v", err)
|
||||
}
|
||||
if err := repo.ReresolveRunwayDupOfSources(ctx, delIDs); err != nil {
|
||||
t.Fatalf("跨文章重算去重失败: %v", err)
|
||||
}
|
||||
|
||||
di, of := editorImageFlag(t, db, dupImg)
|
||||
if di != 0 {
|
||||
t.Fatalf("删掉源方 B 后,A 的图应清掉重复标记(is_duplicate=0),实际 %d", di)
|
||||
}
|
||||
if of != "" {
|
||||
t.Fatalf("删掉源方 B 后,A 的图 dup_of 应为空,实际 %q", of)
|
||||
}
|
||||
}
|
||||
|
||||
// 注:街拍跨文章去重清理(ReresolveSnapDupOfSources / GetSnapLiveImageIDs)为走秀版的逐行镜像,
|
||||
// 逻辑由 TestEditorCrossArticleRunwayDupClearedOnSourceDeleted 覆盖。本条对应的街拍集成测试因本地
|
||||
// street_snaps 表缺 title_cn 列(预存 schema 漂移,连既有街拍测试也跑不了)暂未加入,待本地库补迁移后补回。
|
||||
|
||||
@ -48,6 +48,10 @@ type StreetSnapRepository interface {
|
||||
GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
||||
// ReresolveSnapDedup 删图后重算本专辑去重,消除陈旧 dup_of(见方法注释)。
|
||||
ReresolveSnapDedup(ctx context.Context, snapID uint32) error
|
||||
// ReresolveSnapDupOfSources 删图后清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
||||
ReresolveSnapDupOfSources(ctx context.Context, deletedImageIDs []uint32) error
|
||||
// GetSnapLiveImageIDs 取某街拍全部存活图片 id(级联下架前用于跨文章去重清理)。
|
||||
GetSnapLiveImageIDs(ctx context.Context, snapID uint32) ([]uint32, error)
|
||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
||||
// SetSnapCover 更新街拍封面。
|
||||
@ -421,6 +425,57 @@ func (r *streetSnapRepository) ReresolveSnapDedup(ctx context.Context, snapID ui
|
||||
})
|
||||
}
|
||||
|
||||
// ReresolveSnapDupOfSources 删除图片后,清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
||||
// 逻辑同 ReresolveRunwayDupOfSources,仅表换成 street_snap_images。
|
||||
func (r *streetSnapRepository) ReresolveSnapDupOfSources(ctx context.Context, deletedImageIDs []uint32) error {
|
||||
if len(deletedImageIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
strIDs := make([]string, 0, len(deletedImageIDs))
|
||||
for _, id := range deletedImageIDs {
|
||||
strIDs = append(strIDs, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
var affected []model.StreetSnapImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("is_deleted = 0 AND dup_of IN ?", strIDs).
|
||||
Find(&affected).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, im := range affected {
|
||||
if !im.Phash.Valid || im.Phash.String == "" {
|
||||
if err := tx.Model(&model.StreetSnapImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, im.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
|
||||
}
|
||||
} else {
|
||||
if err := tx.Model(&model.StreetSnapImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).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))
|
||||
@ -487,6 +542,18 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetSnapLiveImageIDs 取某街拍全部存活图片的 id(级联下架前取出,供跨文章去重清理)。
|
||||
func (r *streetSnapRepository) GetSnapLiveImageIDs(ctx context.Context, snapID uint32) ([]uint32, error) {
|
||||
var ids []uint32
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapImage{}).
|
||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) SetSnapCover(ctx context.Context, snapID uint32, image string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
|
||||
57
internal/repository/translate_repository.go
Normal file
57
internal/repository/translate_repository.go
Normal file
@ -0,0 +1,57 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TitleCnMemory 翻译记忆表行(title_cn_memory)。
|
||||
type TitleCnMemory struct {
|
||||
TitleEn string `gorm:"primaryKey;column:title_en"`
|
||||
TitleCn string `gorm:"column:title_cn"`
|
||||
UpdatedAt uint32 `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定记忆表名。
|
||||
func (TitleCnMemory) TableName() string { return "title_cn_memory" }
|
||||
|
||||
// TranslateRepository 标题翻译记忆的读写接口。
|
||||
type TranslateRepository interface {
|
||||
// GetTitleCnMemory 取某英文标题的人工中文翻译;不存在返回 (_, false, nil)。
|
||||
GetTitleCnMemory(ctx context.Context, titleEn string) (string, bool, error)
|
||||
// UpsertTitleCnMemory 幂等写入/更新一条映射。
|
||||
UpsertTitleCnMemory(ctx context.Context, titleEn, titleCn string) error
|
||||
}
|
||||
|
||||
type translateRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTranslateRepository 创建翻译记忆仓储。
|
||||
func NewTranslateRepository(db *gorm.DB) TranslateRepository {
|
||||
return &translateRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *translateRepository) GetTitleCnMemory(ctx context.Context, titleEn string) (string, bool, error) {
|
||||
var row TitleCnMemory
|
||||
err := r.db.WithContext(ctx).Where("title_en = ?", titleEn).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return row.TitleCn, true, nil
|
||||
}
|
||||
|
||||
func (r *translateRepository) UpsertTitleCnMemory(ctx context.Context, titleEn, titleCn string) error {
|
||||
now := uint32(time.Now().Unix())
|
||||
row := TitleCnMemory{TitleEn: titleEn, TitleCn: titleCn, UpdatedAt: now}
|
||||
return r.db.WithContext(ctx).
|
||||
Where("title_en = ?", titleEn).
|
||||
Assign(TitleCnMemory{TitleCn: titleCn, UpdatedAt: now}).
|
||||
FirstOrCreate(&row).Error
|
||||
}
|
||||
Reference in New Issue
Block a user