crawl(品牌抓取任务队列) - /admin/internal/crawl/tasks 领取 / :id/result 回报,spider 可常驻拉任务 - 后台品牌列表支持「加入抓取队列」,队列页可看状态与重试 - 租约超时的 running 自动回收为 queued,避免 worker 崩溃后任务卡死 media(媒体机直传) - ingest 载荷新增 main_key/detail_keys/main_size/detail_sizes: 有 key 就直接落库,跳过下载与上传;与 URL 形态可逐图混用 - 细节图按 Details/DetailKeys 两者较长者遍历(只给 key 时不再丢图) - 媒体机上传的 key 不进失败回滚清单(内容寻址、可能与他人共享) - /admin/internal/upload-url 签发 S4 PUT 预签名(spider 不持有 AK/SK) embeddings(向量回填) - GET /admin/internal/embeddings/pending 派发缺向量的图(含可直接下载的 url) - POST /admin/internal/embeddings 回写向量并重算近邻;失败项计入 image_embedding_failures,连续失败到上限后退出队列(坏图不占位) - 近邻重算改为异步串行:同步会让 100 张的批次 POST 超时(实测 >60s) - 新增 key 分支单测:混用 key/url、细节图只给 key、字节数、回滚清单、向量占位
481 lines
17 KiB
Go
481 lines
17 KiB
Go
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
|
||
// CountRunwaysByBrand 统计该品牌下「未删除」的走秀档案数(品牌删除的护栏依据)。
|
||
CountRunwaysByBrand(ctx context.Context, brandID uint32) (int64, error)
|
||
// SetDeleted 软删 / 恢复品牌(只改 is_deleted 与 updated_at,不动其他列)。
|
||
// 用软删而非物理删除:品牌可能被历史数据引用,且需要可恢复。不存在返回 ErrNotFound。
|
||
SetDeleted(ctx context.Context, id uint32, deleted uint8) 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)
|
||
|
||
// StatsByIDs 批量统计若干品牌的「图集数 / 图片数」,供后台列表展示数据量。
|
||
// 只查当前页的 id,避免为了两列统计把整张品牌表都聚合一遍。
|
||
StatsByIDs(ctx context.Context, ids []uint32) (map[uint32]BrandStats, error)
|
||
}
|
||
|
||
// BrandStats 品牌数据量统计(后台列表用)。
|
||
type BrandStats struct {
|
||
Runways int64 // 未删除的图集数
|
||
Images int64 // 未删除的图片数
|
||
}
|
||
|
||
// 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 索引)。两个例外:
|
||
// 1) 关键词搜索时不限制(用户按名字找品牌,即使暂无档案也应命中);
|
||
// 2) 中文分桶(OTHER)同样列出全部中文名品牌便于检索;
|
||
// 3) 后台显式开 All(q.All):品牌目录有 2 万+ 条,后台需要能翻到任意品牌
|
||
// (否则导入的品牌在界面上等于"不存在")。公开侧永远忽略 All。
|
||
if q.Keyword == "" && q.Letter != "OTHER" && !(admin && q.All) {
|
||
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
|
||
}
|
||
|
||
// 后台可按「最近有动静」排序:走秀图集的 updated_at 与抓取任务的 updated_at 取大。
|
||
//
|
||
// 性能注意:不要写成逐行的相关子查询(2.3 万品牌 × 2 个子查询实测 2.7 秒)。
|
||
// 这里先把两张小表各自按品牌聚合(brand_runways / crawl_tasks 都只有几百行),
|
||
// 再 LEFT JOIN 到品牌表排序 —— 一次哈希连接 + 一次排序,量级降到百毫秒。
|
||
// 公开侧不传 Sort,保持原有 name_en ASC,前台顺序不变。
|
||
itemsQ := r.db.WithContext(ctx).
|
||
Model(&model.Brand{}).
|
||
Scopes(scope)
|
||
if admin && q.Sort == "updated" {
|
||
itemsQ = itemsQ.
|
||
Joins(`LEFT JOIN (SELECT brand_id, max(updated_at) AS u
|
||
FROM brand_runways WHERE is_deleted = 0 GROUP BY brand_id) br
|
||
ON br.brand_id = brands.id`).
|
||
Joins(`LEFT JOIN (SELECT brand_id, max(updated_at) AS u
|
||
FROM crawl_tasks GROUP BY brand_id) ct
|
||
ON ct.brand_id = brands.id`).
|
||
Order("GREATEST(coalesce(br.u, 0), coalesce(ct.u, 0)) DESC, name_en ASC")
|
||
} else {
|
||
itemsQ = itemsQ.Order("name_en ASC")
|
||
}
|
||
|
||
var items []model.Brand
|
||
if err := itemsQ.
|
||
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
|
||
}
|
||
|
||
// CountRunwaysByBrand 统计该品牌下「未删除」的走秀档案数。
|
||
// 品牌删除的护栏依据:有档案时禁止删除,否则会留下一批 brand_id 指不到品牌的图集
|
||
// (brand_runways.brand_id 无外键级联),前台品牌页 / 热门榜都会异常。
|
||
func (r *brandRepository) CountRunwaysByBrand(ctx context.Context, brandID uint32) (int64, error) {
|
||
var n int64
|
||
if err := r.db.WithContext(ctx).
|
||
Model(&model.BrandRunway{}).
|
||
Where("brand_id = ? AND is_deleted = 0", brandID).
|
||
Count(&n).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// SetDeleted 软删 / 恢复品牌:只改 is_deleted 与 updated_at。
|
||
// 不存在返回 ErrNotFound(而不是静默成功),避免「点了删除但其实没删」。
|
||
func (r *brandRepository) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||
res := r.db.WithContext(ctx).
|
||
Model(&model.Brand{}).
|
||
Where("id = ?", id).
|
||
Updates(map[string]any{
|
||
"is_deleted": deleted,
|
||
"updated_at": uint32(time.Now().Unix()),
|
||
})
|
||
if res.Error != nil {
|
||
return res.Error
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return 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。
|
||
// StatsByIDs 批量统计图集数 / 图片数(只针对传入的 id,通常是一页 30 个)。
|
||
func (r *brandRepository) StatsByIDs(ctx context.Context, ids []uint32) (map[uint32]BrandStats, error) {
|
||
out := make(map[uint32]BrandStats, len(ids))
|
||
if len(ids) == 0 {
|
||
return out, nil
|
||
}
|
||
var rows []struct {
|
||
BrandID uint32
|
||
Runways int64
|
||
Images int64
|
||
}
|
||
// 一次查询同时拿两个计数:按品牌聚合图集,再左连图片聚合,避免 N+1。
|
||
err := r.db.WithContext(ctx).Raw(`
|
||
SELECT r.brand_id,
|
||
count(DISTINCT r.id) AS runways,
|
||
count(i.id) AS images
|
||
FROM brand_runways r
|
||
LEFT JOIN brand_runway_images i
|
||
ON i.runway_id = r.id AND i.is_deleted = 0
|
||
WHERE r.is_deleted = 0 AND r.brand_id IN ?
|
||
GROUP BY r.brand_id`, ids).Scan(&rows).Error
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
for _, row := range rows {
|
||
out[row.BrandID] = BrandStats{Runways: row.Runways, Images: row.Images}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
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
|
||
}
|