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) // ListByIDs 按 id 集合取品牌行(不保证顺序,调用方按热度自行重排)。 // 供 Hot 等已知 id 列表的场景使用,避免借道 List 的 featured 旗标复用查询。 ListByIDs(ctx context.Context, ids []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 万+ 无档案品牌,索引必须靠它收敛。 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) } // 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌, // 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效。 // 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌, // 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头); // 中文分桶(OTHER)同样列出全部中文名品牌便于检索。 if q.Keyword == "" && 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 } // ListByIDs 按 id 集合取品牌行;MySQL 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 } // 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 }