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

@ -0,0 +1,338 @@
package repository
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"fashionapi/internal/model"
)
// CrawlRepository 品牌抓取任务(crawl_tasks)的数据访问。
//
// 任务表同时被两个方向使用:
// - 后台(cookie 会话)往里加任务 / 取消 / 重试;
// - spider 服务(HMAC 验签)来领取 / 回报结果。
//
// 领取用 `FOR UPDATE SKIP LOCKED`,多个 spider 实例并发领取不会抢到同一条。
type CrawlRepository interface {
// Enqueue 把单个品牌加入队列。
// 返回 true 表示新入队或把已完成/失败的任务重置为待领取;false 表示该品牌已在队列中/正在跑。
Enqueue(ctx context.Context, brandID uint32, maxShows int, priority int) (bool, error)
// EnqueueBulk 按「范围 + 前缀」批量入队,返回本次实际新入队(或重置)的数量。
EnqueueBulk(ctx context.Context, f BulkFilter) (int, error)
// Claim 领取一个待执行任务并置为 running;无任务时返回 nil, nil。
Claim(ctx context.Context, worker string) (*model.CrawlTaskRow, error)
// Report spider 回报结果。failed 且未达重试上限时自动回到 queued。
Report(ctx context.Context, id uint32, status string, shows int, errMsg string) error
// List 后台列表(按 running → queued → 其它 排序)。
List(ctx context.Context, offset, limit int) ([]model.CrawlTaskRow, int64, error)
// SetStatus 后台手动改状态(取消 / 重试)。
SetStatus(ctx context.Context, id uint32, status string) error
// Delete 删除任务记录(不影响已入库的数据)。
Delete(ctx context.Context, id uint32) error
// Counts 各状态任务数,供导航角标使用。
Counts(ctx context.Context) (map[string]int64, error)
// BrandCrawlStates 返回这些品牌在抓取队列里的「最近状态 + 最近时间」。
// 品牌列表用它展示「抓取状态 / 上次抓取」;没有任务记录的品牌不会出现在 map 里。
BrandCrawlStates(ctx context.Context, brandIDs []uint32) (map[uint32]BrandCrawlState, error)
}
// BrandCrawlState 品牌最近一次抓取任务的状态快照。
type BrandCrawlState struct {
Status string // queued / running / done / failed / canceled
UpdatedAt time.Time // 该任务最后一次更新时间
}
type crawlRepository struct {
db *gorm.DB
}
// NewCrawlRepository 创建爬取任务仓储。
func NewCrawlRepository(db *gorm.DB) CrawlRepository {
return &crawlRepository{db: db}
}
const crawlColumns = `t.id, t.brand_id, t.status, t.priority, t.max_shows, t.worker,
t.locked_at, t.attempts, t.shows_submitted, t.last_error, t.created_at, t.updated_at,
coalesce(b.name_en, '') AS brand_name`
func (r *crawlRepository) Enqueue(ctx context.Context, brandID uint32, maxShows int, priority int) (bool, error) {
now := uint32(time.Now().Unix())
// INSERT ... SELECT 保证品牌不存在(或已软删)时不会凭空建任务;
// ON CONFLICT 的 WHERE 让「已在排队/正在跑」的任务不被重置,避免打断正在执行的工作。
res := r.db.WithContext(ctx).Exec(`
INSERT INTO crawl_tasks (brand_id, status, priority, max_shows, created_at, updated_at)
SELECT b.id, ?, ?, ?, ?, ?
FROM brands b
WHERE b.id = ? AND b.is_deleted = 0
ON CONFLICT (brand_id) DO UPDATE
SET status = 'queued',
priority = EXCLUDED.priority,
max_shows = EXCLUDED.max_shows,
attempts = 0,
locked_at = 0,
worker = '',
shows_submitted = 0,
last_error = '',
updated_at = EXCLUDED.updated_at
WHERE crawl_tasks.status IN ('done', 'failed', 'canceled')`,
model.CrawlQueued, priority, maxShows, now, now, brandID)
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected > 0, nil
}
// BulkScope 批量入队的品牌范围。
type BulkScope string
const (
// ScopeAll 所有品牌(首次全站导入用,量大)。
ScopeAll BulkScope = "all"
// ScopeWithData 只挑「已有图集」的品牌 —— 用于增量更新:只补最新场次。
ScopeWithData BulkScope = "with-data"
// ScopeWithoutData 只挑「还没有任何图集」的品牌 —— 用于首次补全。
ScopeWithoutData BulkScope = "without-data"
)
// BulkFilter 批量入队条件。
type BulkFilter struct {
Prefix string // 品牌英文名前缀,空 = 不限
Scope BulkScope // 范围(见上方常量)
MaxShows int // 0 = 不限场次(全量),>0 = 只抓最近 N 场
Limit int // 本次最多入队多少个品牌(防止一次灌爆)
Priority int16 // 越大越先被领取
}
func (r *crawlRepository) EnqueueBulk(ctx context.Context, f BulkFilter) (int, error) {
now := uint32(time.Now().Unix())
if f.Limit < 1 || f.Limit > 5000 {
f.Limit = 500
}
if f.Scope == "" {
f.Scope = ScopeAll
}
where := []string{"b.is_deleted = 0", "b.name_en <> ''"}
// 注意:参数顺序必须与 SQL 里 ? 出现的顺序严格一致(INSERT 的 4 个 + 每个 WHERE 条件 + LIMIT)。
args := []any{model.CrawlQueued, f.Priority, f.MaxShows, now, now}
if f.Prefix != "" {
where = append(where, `b.name_en ILIKE ? ESCAPE '\'`)
args = append(args, escapeLike(f.Prefix)+"%")
}
switch f.Scope {
case ScopeWithData:
where = append(where, "EXISTS (SELECT 1 FROM brand_runways r WHERE r.brand_id = b.id AND r.is_deleted = 0)")
case ScopeWithoutData:
where = append(where, "NOT EXISTS (SELECT 1 FROM brand_runways r WHERE r.brand_id = b.id AND r.is_deleted = 0)")
}
args = append(args, f.Limit)
sql := `
INSERT INTO crawl_tasks (brand_id, status, priority, max_shows, created_at, updated_at)
SELECT b.id, ?, ?, ?, ?, ?
FROM brands b
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY EXISTS (SELECT 1 FROM crawl_tasks t WHERE t.brand_id = b.id) ASC, b.id ASC
LIMIT ?
ON CONFLICT (brand_id) DO UPDATE
SET status = 'queued',
priority = EXCLUDED.priority,
max_shows = EXCLUDED.max_shows,
attempts = 0,
locked_at = 0,
worker = '',
shows_submitted = 0,
last_error = '',
updated_at = EXCLUDED.updated_at
WHERE crawl_tasks.status IN ('done', 'failed', 'canceled')`
res := r.db.WithContext(ctx).Exec(sql, args...)
if res.Error != nil {
return 0, res.Error
}
return int(res.RowsAffected), nil
}
// escapeLike 转义 LIKE 的通配符,让用户输入按字面量匹配。
func escapeLike(s string) string {
out := make([]rune, 0, len(s))
for _, c := range s {
switch c {
case '%', '_', '\\':
out = append(out, '\\')
}
out = append(out, c)
}
return string(out)
}
func (r *crawlRepository) Claim(ctx context.Context, worker string) (*model.CrawlTaskRow, error) {
now := uint32(time.Now().Unix())
tx := r.db.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
defer func() {
if tx.Error != nil {
tx.Rollback()
}
}()
// 回收租约过期的 running(worker 崩溃 / 断电),否则任务永远卡着。
if now > model.CrawlStuckTimeoutSec {
if err := tx.Exec(`
UPDATE crawl_tasks
SET status = 'queued', locked_at = 0, worker = '', updated_at = ?
WHERE status = 'running' AND locked_at > 0 AND locked_at < ?`,
now, now-model.CrawlStuckTimeoutSec).Error; err != nil {
return nil, err
}
}
var id uint32
if err := tx.Raw(`
SELECT id FROM crawl_tasks
WHERE status = 'queued'
ORDER BY priority DESC, id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED`).Scan(&id).Error; err != nil {
return nil, err
}
if id == 0 {
if err := tx.Commit().Error; err != nil {
return nil, err
}
return nil, nil
}
if err := tx.Exec(`
UPDATE crawl_tasks
SET status = 'running', worker = ?, locked_at = ?, attempts = attempts + 1, updated_at = ?
WHERE id = ?`, worker, now, now, id).Error; err != nil {
return nil, err
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
var row model.CrawlTaskRow
if err := r.db.WithContext(ctx).Raw(`
SELECT `+crawlColumns+`
FROM crawl_tasks t LEFT JOIN brands b ON b.id = t.brand_id
WHERE t.id = ?`, id).Scan(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (r *crawlRepository) Report(ctx context.Context, id uint32, status string, shows int, errMsg string) error {
now := uint32(time.Now().Unix())
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var cur model.CrawlTask
if err := tx.Where("id = ?", id).First(&cur).Error; err != nil {
return err
}
next := status
// 失败但还有重试额度 → 回到队列,由任意 worker 重试(网络抖动不必人工介入)。
if status == model.CrawlFailed && cur.Attempts < model.CrawlMaxAttempts {
next = model.CrawlQueued
}
return tx.Model(&model.CrawlTask{}).Where("id = ?", id).
Updates(map[string]any{
"status": next,
"shows_submitted": shows,
"last_error": errMsg,
"locked_at": 0,
"worker": "",
"updated_at": now,
}).Error
})
}
func (r *crawlRepository) List(ctx context.Context, offset, limit int) ([]model.CrawlTaskRow, int64, error) {
if limit < 1 {
limit = 30
}
var total int64
if err := r.db.WithContext(ctx).Table("crawl_tasks").Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.CrawlTaskRow
err := r.db.WithContext(ctx).Raw(`
SELECT `+crawlColumns+`
FROM crawl_tasks t LEFT JOIN brands b ON b.id = t.brand_id
ORDER BY CASE t.status WHEN 'running' THEN 0 WHEN 'queued' THEN 1 ELSE 2 END,
t.priority DESC, t.id DESC
LIMIT ? OFFSET ?`, limit, offset).Scan(&rows).Error
if err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (r *crawlRepository) SetStatus(ctx context.Context, id uint32, status string) error {
return r.db.WithContext(ctx).Model(&model.CrawlTask{}).Where("id = ?", id).
Updates(map[string]any{
"status": status,
"locked_at": 0,
"worker": "",
"attempts": 0,
"last_error": "",
"updated_at": uint32(time.Now().Unix()),
}).Error
}
func (r *crawlRepository) Delete(ctx context.Context, id uint32) error {
return r.db.WithContext(ctx).Exec(`DELETE FROM crawl_tasks WHERE id = ?`, id).Error
}
func (r *crawlRepository) BrandCrawlStates(ctx context.Context, brandIDs []uint32) (map[uint32]BrandCrawlState, error) {
out := make(map[uint32]BrandCrawlState, len(brandIDs))
if len(brandIDs) == 0 {
return out, nil
}
var rows []struct {
BrandID uint32
Status string
UpdatedAt int64
}
// DISTINCT ON 取每个品牌最近一条任务(按 updated_at 倒序),一次查询拿全状态与时间。
if err := r.db.WithContext(ctx).Raw(`
SELECT DISTINCT ON (brand_id) brand_id, status, updated_at
FROM crawl_tasks
WHERE brand_id IN ?
ORDER BY brand_id, updated_at DESC`, brandIDs).Scan(&rows).Error; err != nil {
return out, err
}
for _, row := range rows {
st := BrandCrawlState{Status: row.Status}
if row.UpdatedAt > 0 {
st.UpdatedAt = time.Unix(row.UpdatedAt, 0)
}
out[row.BrandID] = st
}
return out, nil
}
func (r *crawlRepository) Counts(ctx context.Context) (map[string]int64, error) {
var rows []struct {
Status string
N int64
}
if err := r.db.WithContext(ctx).Raw(
`SELECT status, count(*) AS n FROM crawl_tasks GROUP BY status`).Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, r := range rows {
out[r.Status] = r.N
}
return out, nil
}