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:
338
internal/repository/crawl_repository.go
Normal file
338
internal/repository/crawl_repository.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user