Files
backend_v2/internal/repository/brand_repository.go
toom1996 151e09ab30 feat: 抓取任务队列 + 媒体机直传 + 向量回填
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、字节数、回滚清单、向量占位
2026-10-25 00:37:13 +08:00

481 lines
17 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"
"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
}