Files
backend_v2/internal/repository/brand_repository.go

384 lines
13 KiB
Go
Raw Permalink 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"
"time"
"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)
// ListAdmin 后台列表:与 List 同构,但字母索引不要求走秀已发布,
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开侧保护不受影响)。
ListAdmin(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)
// ListByIDs 按 id 集合取品牌行(不保证顺序,调用方按热度自行重排)。
// 供 Hot 等已知 id 列表的场景使用,避免借道 List 的 featured 旗标复用查询。
ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error)
// FindByID 按数字主键取单个品牌;不存在返回 ErrNotFound。
FindByID(ctx context.Context, id uint32) (*model.Brand, error)
// Create 插入品牌,返回自增主键。
Create(ctx context.Context, b *model.Brand) (uint32, error)
// Update 按数字主键更新品牌中英文名。
Update(ctx context.Context, id uint32, nameEn, nameCn string) error
// CrawlBrands 返回所有可被爬虫抓取的品牌(is_deleted=0 且 name_en 非空)。
// 供爬虫取任务接口使用:不限制"是否已有走秀档案",因为爬虫本就要去补全尚无档案的品牌。
// brandID>0 时仅返回该品牌(对应爬虫 --brand 单品牌调试)。
CrawlBrands(ctx context.Context, brandID uint32) ([]model.Brand, 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 万+ 无档案品牌,索引必须靠它收敛。
// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
return r.db.Table(publicRunways).
Select("DISTINCT brand_id")
}
// hasArticlesSubQueryBase 子查询(后台侧):所有「至少有一篇未删除走秀档案」的品牌 id。
// 读基表且不看 status:后台品牌列表必须仍能看到只有待审 / 驳回走秀的品牌,
// 否则它们会从后台「消失」,审核员无处下手(与公开侧的保护并不冲突:公开走视图)。
func (r *brandRepository) hasArticlesSubQueryBase() *gorm.DB {
return r.db.Model(&model.BrandRunway{}).
Select("DISTINCT brand_id").
Where("is_deleted = 0")
}
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
//
// admin 为 true 时(后台列表)「必须有走秀档案」的子查询读基表且不过滤 status,
// 使只有待审 / 驳回走秀的品牌仍出现在后台;公开列表保持读视图(保护未发布内容)。
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32, admin bool) 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 ~ ?", latinPrefixRegexp)
}
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效;
// 唯一区别是"档案"的口径:公开=已发布(视图),后台=未删除(基表,含待审)。
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
if q.Keyword == "" && q.Letter != "OTHER" {
if admin {
// 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。
db = db.Where("id IN (?)", r.hasArticlesSubQueryBase())
} else {
// 公开:只认已发布走秀(视图),未发布内容不得出现在前台 A-Z 索引。
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
}
}
// List 公开品牌列表:字母索引只保留「至少有一篇已发布走秀档案」的品牌。
func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
return r.list(ctx, q, restrictIDs, false)
}
// ListAdmin 后台品牌列表:与 List 同构,但字母索引不要求走秀已发布,
// 只有待审 / 驳回走秀的品牌仍能看到(否则它们从后台列表消失,无处审核/编辑)。
func (r *brandRepository) ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
return r.list(ctx, q, restrictIDs, true)
}
func (r *brandRepository) list(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32, admin bool) ([]model.Brand, int64, error) {
scope := r.brandFilterScope(q, restrictIDs, admin)
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).
Table(publicRunways).
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
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
}
// ListByIDs 按 id 集合取品牌行;IN 查询不保证返回顺序,调用方自行重排。
func (r *brandRepository) ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error) {
if len(ids) == 0 {
return []model.Brand{}, nil
}
var items []model.Brand
if err := r.db.WithContext(ctx).
Where("is_deleted = 0 AND id IN ?", ids).
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// FindByID 按数字主键取单个品牌;不存在返回 ErrNotFound。
func (r *brandRepository) FindByID(ctx context.Context, id uint32) (*model.Brand, error) {
var b model.Brand
err := r.db.WithContext(ctx).
Where("id = ? AND is_deleted = 0", id).
First(&b).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &b, nil
}
// Create 插入品牌,返回自增主键。
func (r *brandRepository) Create(ctx context.Context, b *model.Brand) (uint32, error) {
if err := r.db.WithContext(ctx).Create(b).Error; err != nil {
return 0, err
}
return b.ID, nil
}
// Update 按数字主键更新品牌中英文名(同时刷新 updated_at)。
func (r *brandRepository) Update(ctx context.Context, id uint32, nameEn, nameCn string) error {
return r.db.WithContext(ctx).
Model(&model.Brand{}).
Where("id = ? AND is_deleted = 0", id).
Updates(map[string]any{
"name_en": nameEn,
"name_cn": nameCn,
"updated_at": uint32(time.Now().Unix()),
}).Error
}
// CrawlBrands 返回所有可被爬虫抓取的品牌:is_deleted=0 且 name_en 非空(无英文名则无法拼 vogue 抓取 URL)。
// 不限制"是否已有走秀档案"——爬虫的职责正是补全尚无档案的品牌。
func (r *brandRepository) CrawlBrands(ctx context.Context, brandID uint32) ([]model.Brand, error) {
q := r.db.WithContext(ctx).Where("is_deleted = 0 AND name_en <> ''")
if brandID > 0 {
q = q.Where("id = ?", brandID)
}
var items []model.Brand
if err := q.Order("id ASC").Find(&items).Error; err != nil {
return nil, err
}
return items, 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 public_brand_runways brand_runways
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
}