209 lines
7.0 KiB
Go
209 lines
7.0 KiB
Go
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)
|
||
// IDs 返回全部未删除文章的 id(升序),供 SSG 的 getStaticPaths 枚举路径使用。
|
||
// 只回主键,不带回封面/描述等大字段,比拉 size=500 再 .map(id) 省得多。
|
||
IDs(ctx context.Context) ([]uint32, error)
|
||
}
|
||
|
||
type articleRepository struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// NewArticleRepository 创建文章仓储。
|
||
func NewArticleRepository(db *gorm.DB) ArticleRepository {
|
||
return &articleRepository{db: db}
|
||
}
|
||
|
||
// selectColumns 列表查询的投影列:只取对外需要的字段,不做 SELECT *。
|
||
const articleListColumns = `brand_runway.id, brand_runway.brand_id, brand_runway.title,
|
||
brand_runway.title_en, brand_runway.title_cn,
|
||
brand_runway.description, brand_runway.description_en, brand_runway.description_cn,
|
||
brand_runway.cover, brand_runway.year, brand_runway.image_count,
|
||
brand_runway.created_at, brand_runway.collection_type, brand_runway.season,
|
||
brand_runway.season_code, b.name AS brand_name, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||
|
||
const articleDetailColumns = `brand_runway.id, brand_runway.title, brand_runway.title_en, brand_runway.title_cn,
|
||
brand_runway.description, brand_runway.description_en, brand_runway.description_cn,
|
||
brand_runway.cover, brand_runway.year, brand_runway.image_count, brand_runway.created_at,
|
||
brand_runway.source_url, b.name AS brand_name, 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")
|
||
|
||
if q.Keyword != "" {
|
||
kw := "%" + q.Keyword + "%"
|
||
db = db.Where("brand_runway.title_en LIKE ? OR brand_runway.title_cn LIKE ?", kw, kw)
|
||
}
|
||
if q.BrandID != "" && q.BrandID != "0" {
|
||
db = db.Where("brand_runway.brand_id = ?", q.BrandID)
|
||
}
|
||
// 多值筛选统一策略:单值用 = (命中索引更精准),多值用 IN,空集合不加条件。
|
||
db = whereMulti(db, "brand_runway.brand_id", toAnySlice(q.BrandIDs))
|
||
db = whereMulti(db, "brand_runway.collection_type", toAnySlice(q.CollectionTypes))
|
||
db = whereMulti(db, "brand_runway.season", toAnySlice(q.Seasons))
|
||
db = whereMulti(db, "brand_runway.year", toAnySlice(q.Years))
|
||
|
||
if q.SeasonCode != "" {
|
||
db = db.Where("brand_runway.season_code = ?", q.SeasonCode)
|
||
}
|
||
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 注入;
|
||
// 每个分支都以 created_at + id 兜底,保证分页结果稳定不跳行。
|
||
func orderBy(sort string) string {
|
||
switch sort {
|
||
case "year_desc":
|
||
return "brand_runway.year DESC, brand_runway.created_at DESC, brand_runway.id DESC"
|
||
case "year_asc":
|
||
return "brand_runway.year ASC, brand_runway.created_at DESC, brand_runway.id DESC"
|
||
case "image_count":
|
||
return "brand_runway.image_count DESC, brand_runway.created_at DESC, brand_runway.id DESC"
|
||
default: // newest
|
||
return "brand_runway.created_at 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 构建期枚举详情页路径。
|
||
func (r *articleRepository) IDs(ctx context.Context) ([]uint32, error) {
|
||
var ids []uint32
|
||
if err := r.db.WithContext(ctx).
|
||
Model(&model.BrandRunway{}).
|
||
Where("is_deleted = 0").
|
||
Order("id ASC").
|
||
Pluck("id", &ids).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return ids, nil
|
||
}
|