Files
backend_v2/internal/repository/brand_repository.go
toom1996 30c9f21da4 update
2026-08-26 10:45:21 +08:00

293 lines
8.4 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"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"gorm.io/gorm"
)
// BrandRepository 品牌数据访问接口。
type BrandRepository interface {
// List 按字母索引 / 关键词分页查询品牌。
//
// restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内;
// 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。
List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
// FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中,
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error)
// ArticleCountByBrandIDs 批量统计各品牌的走秀档案数,单次分组查询避免 N+1。
ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error)
// PopularWithCover 返回"热门品牌"及其代表走秀的封面/标题/文章 ID。
// 规则:先按品牌总图片数取前 limit 个热门品牌,再为每个品牌取最新一篇走秀。
// 返回的是仓储层裸数据(数字主键),对外编码由 service 层负责。
PopularWithCover(ctx context.Context, limit int) ([]PopularBrandRow, error)
}
// PopularBrandRow 热门品牌裸数据:品牌数字主键 + 中英文名称 + 代表走秀封面/中英文标题/文章 ID。
// 注意:数字主键不出仓储层,service 层经 hashid 编码后才对外暴露;i18n 选列由 service 按 locale 决定。
type PopularBrandRow struct {
ID uint32
ArticleID uint32
BrandEn string
BrandCn string
TitleEn string
TitleCn string
Cover string
}
type brandRepository struct {
db *gorm.DB
}
// NewBrandRepository 创建品牌仓储。
func NewBrandRepository(db *gorm.DB) BrandRepository {
return &brandRepository{db: db}
}
// latinPrefixRegexp 匹配以拉丁字母开头的品牌名。
// A-Z 索引语义要求排除中文 / 符号 / 引号开头的脏数据。
// 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。
const latinPrefixRegexp = "^[A-Za-z]"
// hasArticlesSubQuery 子查询:所有"至少有一篇走秀档案"的品牌 id。
// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
return r.db.Model(&model.BrandRunway{}).
Select("DISTINCT brand_id").
Where("is_deleted = 0")
}
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
db = db.Where("is_deleted = 0")
// 字母索引分桶(基于 name_en)
switch {
case q.Letter == "OTHER":
// 纯中文品牌:name_en 为空
db = db.Where("name_en = ''")
case q.Letter >= "A" && q.Letter <= "Z" && len(q.Letter) == 1:
db = db.Where("UPPER(LEFT(name_en,1)) = ?", q.Letter)
default:
db = db.Where("name_en REGEXP ?", latinPrefixRegexp)
}
// 中文分桶不限档案,直接列出全部中文名品牌,便于检索
if q.OnlyWithArticles && q.Letter != "OTHER" {
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
db = db.Where("name_en LIKE ? OR name_cn LIKE ?", kw, kw)
}
// featured 模式:收敛到精选集合;取不到 id 就收窄为空集
if q.Featured {
if len(restrictIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("id IN (?)", restrictIDs)
}
}
return db
}
}
func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
scope := r.brandFilterScope(q, restrictIDs)
var total int64
if err := r.db.WithContext(ctx).
Model(&model.Brand{}).
Scopes(scope).
Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.Brand{}, 0, nil
}
var items []model.Brand
if err := r.db.WithContext(ctx).
Model(&model.Brand{}).
Scopes(scope).
Order("name_en ASC").
Offset(q.Offset()).
Limit(q.Size).
Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error) {
orderCol := "images"
if metric == "shows" {
orderCol = "shows"
}
var agg []struct {
BrandID uint32 `gorm:"column:brand_id"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
Where("is_deleted = 0").
Group("brand_id").
Order(orderCol + " DESC").
Limit(limit).
Scan(&agg).Error
if err != nil {
return nil, err
}
ids := make([]uint32, 0, len(agg))
for _, a := range agg {
ids = append(ids, a.BrandID)
}
return ids, nil
}
func (r *brandRepository) ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error) {
counts := make(map[uint32]int, len(ids))
if len(ids) == 0 {
return counts, nil
}
var grp []struct {
BrandID uint32 `gorm:"column:brand_id"`
Cnt int `gorm:"column:cnt"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("brand_id, COUNT(*) AS cnt").
Where("brand_id IN ? AND is_deleted = 0", ids).
Group("brand_id").
Scan(&grp).Error
if err != nil {
return nil, err
}
for _, g := range grp {
counts[g.BrandID] = g.Cnt
}
return counts, nil
}
// PopularWithCover 返回"热门品牌"及其最新走秀的封面与标题。
//
// 规则:
// 1. 先按品牌总图片数排名取前 limit 个"热门品牌"(复用 FeaturedIDs("images"))
// 2. 为每个热门品牌取最新一篇走秀(按真实季节年份:year DESC,season 优先级 FW>PF>SS>RES,并列取 id 最大)
// 3. 结果按热门品牌排名顺序返回
//
// 注意:created_at/updated_at 是数据导入时间,不能代表走秀新旧,因此必须用 year + season_code。
func (r *brandRepository) PopularWithCover(ctx context.Context, limit int) ([]PopularBrandRow, error) {
if limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
// 1) 热门品牌 id(按总图片数排名)
ids, err := r.FeaturedIDs(ctx, "images", limit)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return []PopularBrandRow{}, nil
}
// 2) 为这些品牌各自取最新一篇走秀
sql := `
WITH latest_per_brand AS (
SELECT id, brand_id, cover, title_en, title_cn,
ROW_NUMBER() OVER (
PARTITION BY brand_id
ORDER BY
year DESC,
CASE
WHEN season_code LIKE 'FW%' THEN 4
WHEN season_code LIKE 'PF%' THEN 3
WHEN season_code LIKE 'SS%' THEN 2
WHEN season_code LIKE 'RES%' THEN 1
ELSE 0
END DESC,
id DESC
) AS rn
FROM brand_runway
WHERE is_deleted = 0 AND brand_id IN (?)
)
SELECT id, brand_id, cover, title_en, title_cn
FROM latest_per_brand
WHERE rn = 1`
var rows []struct {
ID uint32 `gorm:"column:id"`
BrandID uint32 `gorm:"column:brand_id"`
Cover string `gorm:"column:cover"`
TitleEn string `gorm:"column:title_en"`
TitleCn string `gorm:"column:title_cn"`
}
if err := r.db.WithContext(ctx).Raw(sql, ids).Scan(&rows).Error; err != nil {
return nil, err
}
// 3) 取品牌中英文名称
nameMap := make(map[uint32]struct{ En, Cn string }, len(ids))
{
var brands []model.Brand
if err := r.db.WithContext(ctx).
Where("id IN ? AND is_deleted = 0", ids).Find(&brands).Error; err != nil {
return nil, err
}
for _, b := range brands {
nameMap[b.ID] = struct{ En, Cn string }{b.NameEn, b.NameCn}
}
}
// 4) 按热度排名顺序(ids)组装,保证前端展示顺序与热门排名一致
latestByBrand := make(map[uint32]struct {
ArticleID uint32
Cover string
TitleEn string
TitleCn string
}, len(rows))
for _, rw := range rows {
latestByBrand[rw.BrandID] = struct {
ArticleID uint32
Cover string
TitleEn string
TitleCn string
}{ArticleID: rw.ID, Cover: rw.Cover, TitleEn: rw.TitleEn, TitleCn: rw.TitleCn}
}
list := make([]PopularBrandRow, 0, len(ids))
for _, id := range ids {
l, ok := latestByBrand[id]
if !ok {
continue
}
nm := nameMap[id]
list = append(list, PopularBrandRow{
ID: id,
ArticleID: l.ArticleID,
BrandEn: nm.En,
BrandCn: nm.Cn,
TitleEn: l.TitleEn,
TitleCn: l.TitleCn,
Cover: l.Cover,
})
}
return list, nil
}