feat: 抓取进度可见 + 已入库秀预检(避免重复下载)
一、防重复下载 图片 key 是内容 sha1,不下载无法判断是否已存在;又没存 source_url, 于是每次重爬都把几百张已下过的图再下一遍(线上实测 Celine 62 分钟、0 新增)。 新增 GET /admin/internal/shows/covered?brand_uid=,按 (年份,季节,类型) 聚合已入库图片数,供 spider 抓前整场跳过。 二、进度可见 一次抓取可能跑几十分钟,此前只有 running/done,看不出「在干什么」, 也分不清「慢」和「卡死」。 - crawl_tasks 增加 progress_done/total/text/at - POST /admin/internal/crawl/tasks/:id/progress(spider 每场 + 每批图片上报) - GET /admin/crawl/live(页面 4 秒轮询,局部刷新状态/进度/已提交) - progress_at 超过 10 分钟没更新标黄「可能卡住」 - 渲染测试守住进度列与轮询锚点
This commit is contained in:
@ -35,6 +35,11 @@ type CrawlRepository interface {
|
||||
Delete(ctx context.Context, id uint32) error
|
||||
// Counts 各状态任务数,供导航角标使用。
|
||||
Counts(ctx context.Context) (map[string]int64, error)
|
||||
// UpdateProgress 记录执行进度(spider 每处理完一场秀上报一次)。
|
||||
// 只对 running 生效:任务已被取消/回收时,迟到的进度不应把状态写回去。
|
||||
UpdateProgress(ctx context.Context, id uint32, done, total int, text string) error
|
||||
// ListActive 取 running + queued 的任务(后台进度轮询用)。
|
||||
ListActive(ctx context.Context) ([]model.CrawlTaskRow, error)
|
||||
// BrandCrawlStates 返回这些品牌在抓取队列里的「最近状态 + 最近时间」。
|
||||
// 品牌列表用它展示「抓取状态 / 上次抓取」;没有任务记录的品牌不会出现在 map 里。
|
||||
BrandCrawlStates(ctx context.Context, brandIDs []uint32) (map[uint32]BrandCrawlState, error)
|
||||
@ -42,6 +47,19 @@ type CrawlRepository interface {
|
||||
//
|
||||
// crawl_tasks 每品牌只有一行且会被覆盖,历史只存在 brand_crawl_logs 里。
|
||||
ListCrawlLogs(ctx context.Context, brandID uint32, limit int) ([]model.CrawlLog, error)
|
||||
// ShowCoverage 返回某品牌「已入库的秀 → 已有图片张数」,键是实体键(年份+季节+类型)。
|
||||
//
|
||||
// 用途:spider 抓取前预检,**整场跳过**已入库的秀。
|
||||
// 没有它,重爬会把几百张已经下过的图再下一遍——图片 key 是内容 sha1,
|
||||
// 不下载就无法知道它其实已经存在,带宽全白烧(线上实测 62 分钟 0 新增)。
|
||||
ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]int, error)
|
||||
}
|
||||
|
||||
// ShowKey 一场秀的业务主键,与 ingest 的实体键一致(品牌 + 年份 + 季节 + 类型)。
|
||||
type ShowKey struct {
|
||||
Year uint16
|
||||
Season string
|
||||
CollectionType string
|
||||
}
|
||||
|
||||
// BrandCrawlState 品牌最近一次抓取任务的状态快照。
|
||||
@ -60,7 +78,9 @@ func NewCrawlRepository(db *gorm.DB) CrawlRepository {
|
||||
}
|
||||
|
||||
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,
|
||||
t.locked_at, t.attempts, t.shows_submitted, t.last_error,
|
||||
t.progress_done, t.progress_total, t.progress_text, t.progress_at,
|
||||
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) {
|
||||
@ -307,6 +327,46 @@ func (r *crawlRepository) List(ctx context.Context, offset, limit int) ([]model.
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// UpdateProgress 写入进度心跳。
|
||||
//
|
||||
// 用 `AND status = 'running'` 而不是无条件更新:
|
||||
// 后台可能刚把任务取消,此时迟到的进度不该把它写活,
|
||||
// 也不该刷新 updated_at 掩盖「已经被取消」的事实。
|
||||
func (r *crawlRepository) UpdateProgress(ctx context.Context, id uint32, done, total int, text string) error {
|
||||
if done < 0 {
|
||||
done = 0
|
||||
}
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
if len(text) > 160 {
|
||||
text = text[:160]
|
||||
}
|
||||
now := uint32(time.Now().Unix())
|
||||
return r.db.WithContext(ctx).Exec(
|
||||
`UPDATE crawl_tasks
|
||||
SET progress_done = ?, progress_total = ?, progress_text = ?,
|
||||
progress_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'running'`,
|
||||
done, total, text, now, now, id).Error
|
||||
}
|
||||
|
||||
// ListActive 取正在跑与排队中的任务,供后台进度轮询(全量返回,不分页)。
|
||||
// 只取 runnning/queued:已完成的任务进度不会再变,没必要每 4 秒重传一遍。
|
||||
func (r *crawlRepository) ListActive(ctx context.Context) ([]model.CrawlTaskRow, error) {
|
||||
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
|
||||
WHERE t.status IN ('running', 'queued')
|
||||
ORDER BY CASE t.status WHEN 'running' THEN 0 ELSE 1 END, t.id DESC
|
||||
LIMIT 200`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, 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{
|
||||
@ -382,3 +442,32 @@ func (r *crawlRepository) Counts(ctx context.Context) (map[string]int64, error)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ShowCoverage 统计每个「(年份, 季节, 类型)」已入库的图片张数。
|
||||
//
|
||||
// 只统计未软删的图:一张被删过的秀如果只剩几张,说明当初没抓全,
|
||||
// 调用方会按阈值判断是否值得重抓,而不是一律跳过。
|
||||
// JOIN 保证 image_count > 0,没有图的秀不会出现在结果里。
|
||||
func (r *crawlRepository) ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]int, error) {
|
||||
var rows []struct {
|
||||
Year uint16 `gorm:"column:year"`
|
||||
Season string `gorm:"column:season"`
|
||||
CollectionType string `gorm:"column:collection_type"`
|
||||
Images int `gorm:"column:images"`
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Raw(
|
||||
`SELECT r.year, coalesce(r.season, '') AS season, coalesce(r.collection_type, '') AS collection_type,
|
||||
count(i.id) AS images
|
||||
FROM brand_runways r
|
||||
JOIN brand_runway_images i ON i.runway_id = r.id AND i.is_deleted = 0
|
||||
WHERE r.brand_id = ? AND r.is_deleted = 0
|
||||
GROUP BY r.year, r.season, r.collection_type`,
|
||||
brandID).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[ShowKey]int, len(rows))
|
||||
for _, row := range rows {
|
||||
out[ShowKey{Year: row.Year, Season: row.Season, CollectionType: row.CollectionType}] = row.Images
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user