Files
backend_v2/internal/repository/article_repository.go
toom1996 9c3403a903 update
2026-08-30 10:45:34 +08:00

191 lines
6.2 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"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"gorm.io/gorm"
)
// ArticleRepository 走秀档案(文章)数据访问接口。
type ArticleRepository interface {
// List 按筛选条件分页查询文章,同时返回符合条件的总数。
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
// ListImages 查询某篇文章的全部图片,按排序值升序。
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, 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_runway.id, brand_runway.brand_id,
brand_runway.title_en, brand_runway.title_cn,
brand_runway.cover, brand_runway.image_count,
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
const articleDetailColumns = `brand_runway.id, brand_runway.title_en, brand_runway.title_cn,
brand_runway.description_en, brand_runway.description_cn,
brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.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_runway.is_deleted = 0")
// 按品牌过滤来自列表接口的 ?brand_id 查询参数(q.BrandIDs 由 handler 解码注入)。
db = whereMulti(db, "brand_runway.brand_id", toAnySlice(q.BrandIDs))
if q.Collection != "" {
db = db.Where("brand_runway.collection_type = ?", q.Collection)
}
if q.Season != "" {
db = db.Where("brand_runway.season = ?", q.Season)
}
if q.Year != 0 {
db = db.Where("brand_runway.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_runway.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_runway.year ASC, brand_runway.id ASC"
case "year_desc":
return "brand_runway.year DESC, brand_runway.id DESC"
default: // newest
return "brand_runway.year DESC, " + seasonPrio + " DESC, brand_runway.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).
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(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
}
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
var row model.RunwayRow
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select(articleDetailColumns).
Joins(brandJoin).
Where("brand_runway.id = ? AND brand_runway.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
}
func (r *articleRepository) ListImages(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).
Select("runway_id, image, name, sort_order").
Where("runway_id IN ? AND is_deleted = 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
}
// IDs 返回全部未删除文章的 id(升序),供 SSG 构建期枚举详情页路径。