Files
backend_v2/internal/repository/article_repository.go
toom1996 d15d2a4701 update
2026-09-28 10:52:50 +08:00

568 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}