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、字节数、回滚清单、向量占位
This commit is contained in:
toom1996
2026-10-25 00:37:13 +08:00
parent e18c5a0815
commit 151e09ab30
22 changed files with 1535 additions and 28 deletions

View File

@ -52,6 +52,16 @@ type BrandRepository interface {
// 规则:先按品牌总图片数取前 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。
@ -174,11 +184,30 @@ func (r *brandRepository) list(ctx context.Context, q dto.BrandQuery, restrictID
return []model.Brand{}, 0, nil
}
var items []model.Brand
if err := r.db.WithContext(ctx).
// 后台可按「最近有动静」排序:走秀图集的 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).
Order("name_en ASC").
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 {
@ -319,6 +348,36 @@ func (r *brandRepository) CrawlBrands(ctx context.Context, brandID uint32) ([]mo
// 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