568 lines
22 KiB
Go
568 lines
22 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"math"
|
||
"strconv"
|
||
"time"
|
||
|
||
"fashionapi/internal/dto"
|
||
"fashionapi/internal/model"
|
||
"fashionapi/internal/pkg/phash"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ArticleRepository 走秀档案(文章)数据访问接口。
|
||
type ArticleRepository interface {
|
||
// List 按筛选条件分页查询文章,同时返回符合条件的总数。
|
||
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
|
||
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||
// FindByID 公开读:查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
||
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
||
// FindByIDBase 后台读基表:与 FindByID 同投影/同 JOIN,但能看到 pending / rejected。
|
||
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
|
||
FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error)
|
||
// ListImages 公开读:查询某篇文章的全部图片,按排序值升序(未发布图片不可见)。
|
||
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
||
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
|
||
ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
||
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
|
||
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
|
||
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
|
||
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||
// UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。
|
||
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||
// DeleteRunwayImage 软删单张走秀图片,并按实际存活主图行数重算 image_count。
|
||
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 更新走秀封面(同时回到图片列表首张)。
|
||
SetRunwayCover(ctx context.Context, runwayID uint32, image string) error
|
||
// SetRunwayDeleted 上下架:deleted=1 下架(对外不可见),0 上架。
|
||
SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||
}
|
||
|
||
type articleRepository struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// NewArticleRepository 创建文章仓储。
|
||
func NewArticleRepository(db *gorm.DB) ArticleRepository {
|
||
return &articleRepository{db: db}
|
||
}
|
||
|
||
// selectColumns 列表查询的投影列:只取对外需要的字段,不做 SELECT *。
|
||
// 注意:year / season / season_code / collection_type / created_at 等仅用于排序,不 SELECT 到响应里
|
||
// (前端按年份/季节筛选是走查询参数,响应无需回带这些元数据)。
|
||
const articleListColumns = `brand_runways.id, brand_runways.brand_id,
|
||
brand_runways.title_en, brand_runways.title_cn,
|
||
brand_runways.cover, brand_runways.image_count,
|
||
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||
|
||
const articleDetailColumns = `brand_runways.id, brand_runways.title_en, brand_runways.title_cn,
|
||
brand_runways.description_en, brand_runways.description_cn,
|
||
brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||
|
||
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
|
||
// collection_type / season_code / status 等管理字段(前端列表不展示这些)。
|
||
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
|
||
brand_runways.title_en, brand_runways.title_cn,
|
||
brand_runways.cover, brand_runways.image_count, brand_runways.year,
|
||
brand_runways.collection_type, brand_runways.season, brand_runways.season_code,
|
||
brand_runways.status,
|
||
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||
|
||
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
||
const brandJoin = "LEFT JOIN brands b ON b.id = brand_runways.brand_id AND b.is_deleted = 0"
|
||
|
||
// filterScope 把查询条件编译为 GORM Scope。
|
||
//
|
||
// 用 Scope 而非复用同一个 *gorm.DB:GORM v2 中在 Count 等终结方法之后复用同一实例
|
||
// 会带上残留的 Statement 状态,Scope 每次作用于全新查询,杜绝这类隐患。
|
||
func filterScope(q dto.ArticleQuery) func(*gorm.DB) *gorm.DB {
|
||
return func(db *gorm.DB) *gorm.DB {
|
||
db = db.Where("brand_runways.is_deleted = 0")
|
||
|
||
// 按品牌过滤来自列表接口的 ?brand_id 查询参数(q.BrandIDs 由 handler 解码注入)。
|
||
db = whereMulti(db, "brand_runways.brand_id", toAnySlice(q.BrandIDs))
|
||
if q.Collection != "" {
|
||
db = db.Where("brand_runways.collection_type = ?", q.Collection)
|
||
}
|
||
if q.Season != "" {
|
||
db = db.Where("brand_runways.season = ?", q.Season)
|
||
}
|
||
if q.Year != 0 {
|
||
db = db.Where("brand_runways.year = ?", q.Year)
|
||
}
|
||
return db
|
||
}
|
||
}
|
||
|
||
// whereMulti 按值数量选择 = 或 IN。
|
||
func whereMulti(db *gorm.DB, column string, values []any) *gorm.DB {
|
||
switch len(values) {
|
||
case 0:
|
||
return db
|
||
case 1:
|
||
return db.Where(column+" = ?", values[0])
|
||
default:
|
||
return db.Where(column+" IN ?", values)
|
||
}
|
||
}
|
||
|
||
func toAnySlice[T any](in []T) []any {
|
||
if len(in) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]any, 0, len(in))
|
||
for _, v := range in {
|
||
out = append(out, v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// orderBy 把 sort 参数映射为 ORDER BY 子句。
|
||
//
|
||
// 白名单映射而非直接拼接用户输入,从根上排除 SQL 注入;
|
||
// 每个分支都以 id 兜底,保证分页结果稳定不跳行。
|
||
//
|
||
// newest(默认):按走秀真实新旧排序 —— created_at 仅是导入时间,不能代表走秀新旧,
|
||
// 故用 year DESC + season_code 优先级(FW>PF>SS>RES)+ id 兜底,与首页「最新走秀」口径一致。
|
||
func orderBy(sort string) string {
|
||
seasonPrio := `CASE brand_runways.season_code WHEN 'FW' THEN 4 WHEN 'PF' THEN 3 WHEN 'SS' THEN 2 WHEN 'RES' THEN 1 ELSE 0 END`
|
||
switch sort {
|
||
case "year_asc":
|
||
return "brand_runways.year ASC, brand_runways.id ASC"
|
||
case "year_desc":
|
||
return "brand_runways.year DESC, brand_runways.id DESC"
|
||
default: // newest
|
||
return "brand_runways.year DESC, " + seasonPrio + " DESC, brand_runways.id DESC"
|
||
}
|
||
}
|
||
|
||
func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) {
|
||
scope := filterScope(q)
|
||
|
||
var total int64
|
||
if err := r.db.WithContext(ctx).
|
||
Table(publicRunways).
|
||
Scopes(scope).
|
||
Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if total == 0 {
|
||
return []model.RunwayRow{}, 0, nil
|
||
}
|
||
|
||
var rows []model.RunwayRow
|
||
if err := r.db.WithContext(ctx).
|
||
Table(publicRunways).
|
||
Scopes(scope).
|
||
Select(articleListColumns).
|
||
Joins(brandJoin).
|
||
Order(orderBy(q.Sort)).
|
||
Offset(q.Offset()).
|
||
Limit(q.Size).
|
||
Scan(&rows).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return rows, total, nil
|
||
}
|
||
|
||
// ListAdmin 后台管理列表:与 List 共用筛选/排序,但投影列含管理字段。
|
||
func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) {
|
||
scope := filterScope(q)
|
||
|
||
var total int64
|
||
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.RunwayRow{}, 0, nil
|
||
}
|
||
|
||
var rows []model.RunwayRow
|
||
if err := r.db.WithContext(ctx).
|
||
Model(&model.BrandRunway{}).
|
||
Scopes(scope).
|
||
Select(articleAdminColumns).
|
||
Joins(brandJoin).
|
||
Order(orderBy(q.Sort)).
|
||
Offset(q.Offset()).
|
||
Limit(q.Size).
|
||
Scan(&rows).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return rows, total, nil
|
||
}
|
||
|
||
// FindByID 公开读:读公开视图,pending / rejected 视同不存在(返回 ErrNotFound)。
|
||
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||
return r.findByID(r.db.WithContext(ctx).Table(publicRunways), id)
|
||
}
|
||
|
||
// FindByIDBase 后台读基表:同投影同 JOIN,但不过滤 status(后台详情需渲染待审内容)。
|
||
func (r *articleRepository) FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||
return r.findByID(r.db.WithContext(ctx).Model(&model.BrandRunway{}), id)
|
||
}
|
||
|
||
// findByID 用给定查询(视图或基表)取单行;投影与 JOIN 两处保持一致,避免公开/后台口径漂移。
|
||
func (r *articleRepository) findByID(q *gorm.DB, id string) (*model.RunwayRow, error) {
|
||
var row model.RunwayRow
|
||
err := q.Select(articleDetailColumns).
|
||
Joins(brandJoin).
|
||
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
|
||
First(&row).Error
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
return &row, nil
|
||
}
|
||
|
||
// ListImages 公开读:读公开视图,未发布走秀的图片不可见(视图已含 is_deleted 过滤)。
|
||
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
||
var imgs []model.BrandRunwayImage
|
||
err := r.db.WithContext(ctx).
|
||
Table(publicRunwayImages).
|
||
Where("runway_id = ?", runwayID).
|
||
Order("sort_order ASC, id ASC").
|
||
Find(&imgs).Error
|
||
return imgs, err
|
||
}
|
||
|
||
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
|
||
func (r *articleRepository) ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
||
var imgs []model.BrandRunwayImage
|
||
err := r.db.WithContext(ctx).
|
||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||
Order("sort_order ASC, id ASC").
|
||
Find(&imgs).Error
|
||
return imgs, err
|
||
}
|
||
|
||
func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error) {
|
||
result := make(map[uint32][]model.BrandRunwayImage, len(ids))
|
||
if len(ids) == 0 {
|
||
return result, nil
|
||
}
|
||
|
||
var imgs []model.BrandRunwayImage
|
||
err := r.db.WithContext(ctx).
|
||
Table(publicRunwayImages).
|
||
Select("runway_id, image, name, sort_order").
|
||
Where("runway_id IN ? AND is_detail = 0", ids).
|
||
Order("sort_order ASC, id ASC").
|
||
Find(&imgs).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, im := range imgs {
|
||
result[im.RunwayID] = append(result[im.RunwayID], im)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||
|
||
// runwayEditable 后台可编辑的走秀字段白名单(键即列名)。
|
||
var runwayEditable = map[string]bool{
|
||
"title_en": true,
|
||
"title_cn": true,
|
||
"description_en": true,
|
||
"description_cn": true,
|
||
"year": true,
|
||
"season": true,
|
||
"collection_type": true,
|
||
"season_code": true,
|
||
}
|
||
|
||
func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
|
||
var rw model.BrandRunway
|
||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&rw).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, nil, ErrNotFound
|
||
}
|
||
return nil, nil, err
|
||
}
|
||
imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return &rw, imgs, nil
|
||
}
|
||
|
||
func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
|
||
clean := map[string]any{}
|
||
for k, v := range fields {
|
||
if runwayEditable[k] {
|
||
clean[k] = v
|
||
}
|
||
}
|
||
if len(clean) == 0 {
|
||
return nil
|
||
}
|
||
clean["updated_at"] = uint32(time.Now().Unix())
|
||
return r.db.WithContext(ctx).
|
||
Model(&model.BrandRunway{}).
|
||
Where("id = ?", id).
|
||
Updates(clean).Error
|
||
}
|
||
|
||
// DeleteRunwayImage 软删单张走秀图片(后台正式编辑页删图),并同步重算 image_count。
|
||
//
|
||
// 与审核侧 SoftDeleteRunwayImage 口径一致:删完按**实际存活主图行数**重算
|
||
// (`is_deleted = 0 AND is_detail = 0`,细节图不计入)。
|
||
//
|
||
// 为什么必须重算而不是「递减 1」:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径,
|
||
// 编辑页删图若只递减,过期页面 / 重复提交 / 跨记录重放(命中 0 行仍递减)造成的漂移会永久固化,
|
||
// 并直接显示在公开列表卡片的「N 张」角标里。
|
||
//
|
||
// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 runway_id → 软删 → 重算。
|
||
// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。
|
||
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
|
||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var img model.BrandRunwayImage
|
||
err := tx.Select("runway_id, image").Where("id = ?", imageID).First(&img).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
|
||
var rw model.BrandRunway
|
||
if err := tx.Select("cover").Where("id = ?", img.RunwayID).First(&rw).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||
Where("id = ? AND is_deleted = 0", imageID).
|
||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec(`UPDATE brand_runways SET image_count =
|
||
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
|
||
WHERE id = ?`, img.RunwayID, img.RunwayID).Error; err != nil {
|
||
return err
|
||
}
|
||
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
|
||
if rw.Cover == img.Image {
|
||
cover := ""
|
||
var survivor struct {
|
||
Image string `gorm:"column:image"`
|
||
}
|
||
serr := tx.Raw(
|
||
"SELECT image FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
|
||
img.RunwayID,
|
||
).Scan(&survivor).Error
|
||
if serr == nil {
|
||
cover = survivor.Image
|
||
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
|
||
return serr
|
||
}
|
||
if err := tx.Model(&model.BrandRunway{}).Where("id = ?", img.RunwayID).
|
||
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||
var img model.BrandRunwayImage
|
||
err := r.db.WithContext(ctx).Where("id = ?", imageID).First(&img).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return "", nil
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
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) {
|
||
var imgs []model.BrandRunwayImage
|
||
if err := r.db.WithContext(ctx).
|
||
Select("image").
|
||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||
Find(&imgs).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
keys := make([]string, 0, len(imgs))
|
||
for _, im := range imgs {
|
||
if im.Image != "" {
|
||
keys = append(keys, im.Image)
|
||
}
|
||
}
|
||
if err := r.db.WithContext(ctx).
|
||
Model(&model.BrandRunwayImage{}).
|
||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
|
||
if err := r.db.WithContext(ctx).
|
||
Model(&model.BrandRunway{}).
|
||
Where("id = ?", runwayID).
|
||
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return keys, nil
|
||
}
|
||
|
||
func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error {
|
||
return r.db.WithContext(ctx).
|
||
Model(&model.BrandRunway{}).
|
||
Where("id = ?", runwayID).
|
||
Updates(map[string]any{"cover": image, "updated_at": uint32(time.Now().Unix())}).Error
|
||
}
|
||
|
||
func (r *articleRepository) SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||
return r.db.WithContext(ctx).
|
||
Model(&model.BrandRunway{}).
|
||
Where("id = ?", id).
|
||
Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error
|
||
}
|