From 51e3d13383c0cc75faca1380403fb2a6824e7192 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Sun, 25 Oct 2026 21:22:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=93=E5=8F=96=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=20+=20=E5=B7=B2=E5=85=A5=E5=BA=93=E7=A7=80?= =?UTF-8?q?=E9=A2=84=E6=A3=80=EF=BC=88=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、防重复下载 图片 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 分钟没更新标黄「可能卡住」 - 渲染测试守住进度列与轮询锚点 --- .../2026-10-25-01-crawl-progress.sql | 19 ++ internal/handler/assets/admin.css | 5 + internal/handler/backstage_crawl.go | 187 ++++++++++++++++-- internal/handler/crawl_render_test.go | 66 +++++++ internal/handler/templates/pages/crawl.html | 45 ++++- internal/model/crawl_task.go | 10 +- internal/repository/crawl_repository.go | 91 ++++++++- internal/router/backstage.go | 6 + 8 files changed, 408 insertions(+), 21 deletions(-) create mode 100644 db/migrations/2026-10-25-01-crawl-progress.sql create mode 100644 internal/handler/crawl_render_test.go diff --git a/db/migrations/2026-10-25-01-crawl-progress.sql b/db/migrations/2026-10-25-01-crawl-progress.sql new file mode 100644 index 0000000..2fc7dbc --- /dev/null +++ b/db/migrations/2026-10-25-01-crawl-progress.sql @@ -0,0 +1,19 @@ +-- 抓取任务的执行进度。 +-- +-- 为什么需要:spider 一次抓取的品牌可能有几十场秀、每场几百张图, +-- 跑起来几分钟到几小时。此前只有 queued/running/done 三个状态, +-- 后台看到「执行中」就再无信息,既判断不了「在干什么」, +-- 也判断不了「是慢还是卡死」。 +-- +-- progress_at 与 updated_at 分开记:updated_at 会在领取/回报时变, +-- 而 progress_at 只有 spider 真的推进了才变——用它判卡死更准。 +ALTER TABLE crawl_tasks + ADD COLUMN IF NOT EXISTS progress_done smallint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS progress_total smallint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS progress_text varchar(160) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS progress_at bigint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN crawl_tasks.progress_done IS '已处理的秀数量(spider 上报)'; +COMMENT ON COLUMN crawl_tasks.progress_total IS '本任务计划处理的秀数量(预检过滤后)'; +COMMENT ON COLUMN crawl_tasks.progress_text IS '当前/最近一场秀的标题,供后台显示「正在抓什么」'; +COMMENT ON COLUMN crawl_tasks.progress_at IS '最后一次进度心跳的 Unix 秒;用于判断任务是否卡死'; diff --git a/internal/handler/assets/admin.css b/internal/handler/assets/admin.css index e1f1c1a..c186fb0 100644 --- a/internal/handler/assets/admin.css +++ b/internal/handler/assets/admin.css @@ -540,3 +540,8 @@ tbody tr:last-child td { border-bottom: 0; } } /* 近邻若落在默认隐藏的副图(.cell.det)上,锚点定位后必须可见,否则跳过去是一片空白。 */ .cell.det:target { display: flex; } + +/* 抓取进度:正常更新的进度用灰字;心跳停了(可能卡住)才用警示色。 + 用颜色而不是弹窗,是因为轮询每 4 秒一次,弹窗会变成骚扰。 */ +.progress { color: #666; font-size: 12px; } +.progress.stale { color: #b45309; font-weight: 600; } diff --git a/internal/handler/backstage_crawl.go b/internal/handler/backstage_crawl.go index 56e0c67..64a6da9 100644 --- a/internal/handler/backstage_crawl.go +++ b/internal/handler/backstage_crawl.go @@ -35,8 +35,53 @@ type crawlRowView struct { Worker string UpdatedAt string LastError string - CanRetry bool - CanCancel bool + // ProgressLabel 「3/25 场 · Fall 2020 Ready-to-Wear」,空表示还没有进度。 + ProgressLabel string + // Stale 执行中但进度长时间没推进(多半是卡住,而不是慢)。 + Stale bool + CanRetry bool + CanCancel bool +} + +// progressStaleSec 进度心跳超过这个时长没更新,就认为可能卡住了。 +// 一场秀的图片下载在正常情况下是分钟级;10 分钟没有心跳基本可以断定卡住。 +const progressStaleSec = 600 + +// progressLabel 拼出「3/25 场 · 秀标题」。 +// +// 只显示数字没意义(不知道在处理哪一场),只显示标题也没意义(不知道还剩多少), +// 所以两者拼在一起。 +func progressLabel(t model.CrawlTask) string { + out := "" + switch { + case t.ProgressTotal > 0: + out = fmt.Sprintf("%d/%d 场", t.ProgressDone, t.ProgressTotal) + case t.ProgressDone > 0: + out = fmt.Sprintf("%d 场", t.ProgressDone) + } + if t.ProgressText != "" { + if out != "" { + out += " · " + } + out += t.ProgressText + } + return out +} + +// progressStale 判断「执行中但心跳停了」。 +// +// 用 progress_at 而不是 updated_at:updated_at 在领取、回报时都会变, +// 用它判断会把「刚领取、还没开始抓」误判成卡死。 +// 还没上报过进度时退化为 locked_at(领取时间)。 +func progressStale(t model.CrawlTask, now int64) bool { + if t.Status != model.CrawlRunning { + return false + } + beat := int64(t.ProgressAt) + if beat == 0 { + beat = int64(t.LockedAt) + } + return beat > 0 && now-beat > progressStaleSec } // crawlStatusClass 把抓取状态映射到后台已有的徽标配色类(admin.css 只有 @@ -91,19 +136,21 @@ func (h *BackstageHandler) CrawlList(c *gin.Context) { ua = time.Unix(int64(r.UpdatedAt), 0).Format("2006-01-02 15:04") } views = append(views, crawlRowView{ - ID: r.ID, - BrandUID: hashid.Encode(r.BrandID), - BrandName: r.BrandName, - Status: r.Status, - StatusLabel: crawlStatusLabel(r.Status), - MaxShows: r.MaxShows, - Shows: r.ShowsSubmitted, - Attempts: r.Attempts, - Worker: r.Worker, - UpdatedAt: ua, - LastError: r.LastError, - CanRetry: r.Status == model.CrawlFailed || r.Status == model.CrawlDone || r.Status == model.CrawlCanceled, - CanCancel: r.Status == model.CrawlQueued || r.Status == model.CrawlRunning, + ID: r.ID, + BrandUID: hashid.Encode(r.BrandID), + BrandName: r.BrandName, + Status: r.Status, + StatusLabel: crawlStatusLabel(r.Status), + MaxShows: r.MaxShows, + Shows: r.ShowsSubmitted, + Attempts: r.Attempts, + Worker: r.Worker, + UpdatedAt: ua, + LastError: r.LastError, + ProgressLabel: progressLabel(r.CrawlTask), + Stale: progressStale(r.CrawlTask, time.Now().Unix()), + CanRetry: r.Status == model.CrawlFailed || r.Status == model.CrawlDone || r.Status == model.CrawlCanceled, + CanCancel: r.Status == model.CrawlQueued || r.Status == model.CrawlRunning, }) } @@ -128,6 +175,47 @@ func (h *BackstageHandler) CrawlList(c *gin.Context) { }) } +// crawlLiveItem 进度轮询的返回项(只带页面需要刷新的几个字段)。 +type crawlLiveItem struct { + ID uint32 `json:"id"` + Status string `json:"status"` + StatusLabel string `json:"status_label"` + Shows int32 `json:"shows"` + Progress string `json:"progress"` + Stale bool `json:"stale"` +} + +// CrawlLive 给抓取列表页做轻量轮询:只返回 running/queued 的行。 +// +// 为什么是轮询而不是 WebSocket: +// - 进度事件低频(每场秀一次,几十秒到几分钟),长连接的优势用不上; +// - spider 是「拉任务」的短连接模型,为进度再维持一条长连接不划算; +// - 轮询断线自愈,前端只需一个 setInterval。 +func (h *BackstageHandler) CrawlLive(c *gin.Context) { + if h.crawl == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crawl repository not configured"}) + return + } + rows, err := h.crawl.ListActive(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"}) + return + } + now := time.Now().Unix() + items := make([]crawlLiveItem, 0, len(rows)) + for _, r := range rows { + items = append(items, crawlLiveItem{ + ID: r.ID, + Status: r.Status, + StatusLabel: crawlStatusLabel(r.Status), + Shows: r.ShowsSubmitted, + Progress: progressLabel(r.CrawlTask), + Stale: progressStale(r.CrawlTask, now), + }) + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + // CrawlEnqueue 把单个品牌加入抓取队列(品牌列表页的行内按钮)。 // 品牌 id 用 hashid(与品牌页其它动作一致),避免暴露自增主键。 func (h *BackstageHandler) CrawlEnqueue(c *gin.Context) { @@ -263,6 +351,75 @@ func (h *BackstageHandler) WorkerTask(c *gin.Context) { }}) } +// WorkerShowsCovered 返回某品牌「已入库的秀 → 已有图片张数」,供 spider 抓取前预检。 +// +// 为什么需要它:图片 key 是内容 sha1,**不下载就不知道一张图是否已存在**。 +// 于是重爬会把几百张已下过的图再下一遍(线上实测 Celine 62 分钟、约 600MB,0 新增)。 +// 这个接口让 spider 在抓之前就知道「这场我已经有了」,整场跳过,一张都不下。 +// +// 请求:GET /admin/internal/shows/covered?brand_uid= +// 响应:{"items":[{"year":2020,"season":"fall","collection_type":"rtw","images":415}]} +func (h *BackstageHandler) WorkerShowsCovered(c *gin.Context) { + if h.crawl == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crawl repository not configured"}) + return + } + uid := strings.TrimSpace(c.Query("brand_uid")) + brandID, err := hashid.Decode(uid) + if err != nil || brandID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "brand_uid 非法"}) + return + } + coverage, err := h.crawl.ShowCoverage(c.Request.Context(), brandID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query coverage failed"}) + return + } + items := make([]gin.H, 0, len(coverage)) + for k, n := range coverage { + items = append(items, gin.H{ + "year": k.Year, + "season": k.Season, + "collection_type": k.CollectionType, + "images": n, + }) + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +// WorkerProgress 接收 spider 的执行进度(每处理完一场秀上报一次)。 +// +// 请求:POST /admin/internal/crawl/tasks/:id/progress +// +// {"done": 3, "total": 25, "text": "Fall 2020 Ready-to-Wear"} +// +// 回报失败不影响抓取:进度是观测信息,丢了只是后台显示旧一点。 +func (h *BackstageHandler) WorkerProgress(c *gin.Context) { + if h.crawl == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crawl repository not configured"}) + return + } + id := atoiDefault(c.Param("id"), 0) + if id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "任务 ID 非法"}) + return + } + var body struct { + Done int `json:"done"` + Total int `json:"total"` + Text string `json:"text"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()}) + return + } + if err := h.crawl.UpdateProgress(c.Request.Context(), uint32(id), body.Done, body.Total, body.Text); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "update progress failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + // WorkerUploadURL 给媒体机签发 S4 直传 URL(PUT 预签名)。 // // 设计取舍:不在 spider 里配 AK/SK、也不引入 S3 SDK —— 密钥只留在后端, diff --git a/internal/handler/crawl_render_test.go b/internal/handler/crawl_render_test.go new file mode 100644 index 0000000..7b7a090 --- /dev/null +++ b/internal/handler/crawl_render_test.go @@ -0,0 +1,66 @@ +package handler + +import ( + "bytes" + "strings" + "testing" + + "fashionapi/internal/model" + "fashionapi/internal/pkg/pagination" + + "github.com/gin-gonic/gin" +) + +// TestCrawlPageRendersProgress 守住抓取列表页的「进度」列。 +// +// 这一列由模板 + 前端轮询共同驱动,出错时不会编译失败、只会在打开页面时 500 或空白, +// 所以用一个渲染测试把「运行中 + 卡住 + 有进度文案」这几种情况固定下来。 +func TestCrawlPageRendersProgress(t *testing.T) { + tpl, ok := pageTemplates["crawl"] + if !ok { + t.Fatal("crawl 模板未注册") + } + + data := gin.H{ + "NavKey": "crawl", + "PendingReviews": 0, + "Rows": []crawlRowView{{ + ID: 7, + BrandUID: "abc123", + BrandName: "Celine", + Status: model.CrawlRunning, + StatusLabel: "执行中", + Shows: 3, + Worker: "vm-1-100-0", + UpdatedAt: "2026-10-25 21:00", + ProgressLabel: "3/13 场 · Fall 2021 Menswear(24/38 张)", + Stale: true, + CanCancel: true, + }}, + "Total": 1, + "Queued": 0, + "Running": 1, + "Done": 0, + "Failed": 0, + "Added": 0, + "Pager": pagination.Pager{Label: "第 1 / 1 页"}, + } + + var buf bytes.Buffer + if err := tpl.ExecuteTemplate(&buf, "layout", data); err != nil { + t.Fatalf("渲染抓取列表页失败: %v", err) + } + out := buf.String() + + for _, want := range []string{ + `data-live-progress="7"`, // 轮询锚点:JS 靠它定位单元格 + `data-live-status="7"`, // 状态徽标也要能刷新 + "3/13 场 · Fall 2021 Menswear(24/38 张)", // 进度文案原样输出 + "progress stale", // 心跳停了要有警示样式 + "/admin/crawl/live", // 轮询地址 + } { + if !strings.Contains(out, want) { + t.Errorf("页面缺少 %q", want) + } + } +} diff --git a/internal/handler/templates/pages/crawl.html b/internal/handler/templates/pages/crawl.html index eb263ee..5d2e00b 100644 --- a/internal/handler/templates/pages/crawl.html +++ b/internal/handler/templates/pages/crawl.html @@ -16,15 +16,16 @@ - + {{range .Rows}} - + + - + @@ -41,4 +42,42 @@
ID品牌状态场次上限已提交尝试Worker更新时间错误操作ID品牌状态进度场次上限已提交尝试Worker更新时间错误操作
{{.ID}} {{.BrandName}}{{.StatusLabel}}{{.StatusLabel}}{{if .ProgressLabel}}{{.ProgressLabel}}{{else}}—{{end}} {{if eq .MaxShows 0}}全量{{else}}{{.MaxShows}}{{end}}{{.Shows}}{{.Shows}} {{.Attempts}} {{if .Worker}}{{.Worker}}{{else}}—{{end}} {{.UpdatedAt}}
{{template "pager" .Pager}} + {{end}} diff --git a/internal/model/crawl_task.go b/internal/model/crawl_task.go index 52882d3..58c9b66 100644 --- a/internal/model/crawl_task.go +++ b/internal/model/crawl_task.go @@ -43,8 +43,14 @@ type CrawlTask struct { // ShowsSubmitted 本任务向 ingest 提交了多少场走秀(spider 回报)。 ShowsSubmitted int32 `gorm:"column:shows_submitted;not null;default:0" json:"shows_submitted"` LastError string `gorm:"column:last_error;type:text" json:"last_error"` - CreatedAt uint32 `gorm:"column:created_at;not null;default:0" json:"created_at"` - UpdatedAt uint32 `gorm:"column:updated_at;not null;default:0" json:"updated_at"` + // 进度:spider 每处理完一场秀上报一次。 + // 一次抓取可能跑几十分钟,没有这四项后台只能看到「执行中」,分不清慢和卡死。 + ProgressDone int16 `gorm:"column:progress_done;not null;default:0" json:"progress_done"` + ProgressTotal int16 `gorm:"column:progress_total;not null;default:0" json:"progress_total"` + ProgressText string `gorm:"column:progress_text;size:160;not null;default:''" json:"progress_text"` + ProgressAt uint32 `gorm:"column:progress_at;not null;default:0" json:"progress_at"` + CreatedAt uint32 `gorm:"column:created_at;not null;default:0" json:"created_at"` + UpdatedAt uint32 `gorm:"column:updated_at;not null;default:0" json:"updated_at"` } // TableName 指定表名。 diff --git a/internal/repository/crawl_repository.go b/internal/repository/crawl_repository.go index a27ef39..62c47b2 100644 --- a/internal/repository/crawl_repository.go +++ b/internal/repository/crawl_repository.go @@ -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 +} diff --git a/internal/router/backstage.go b/internal/router/backstage.go index 19202aa..3751c33 100644 --- a/internal/router/backstage.go +++ b/internal/router/backstage.go @@ -65,6 +65,10 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { internal.Use(middleware.IngestAuth(*opt.IngestAuth)) internal.POST("/ingest", opt.Ingest.Submit) internal.GET("/crawl/brands", opt.Ingest.CrawlBrands) + // 已入库的秀(按 年份+季节+类型 聚合):spider 抓前预检,整场跳过,避免重复下载。 + internal.GET("/shows/covered", opt.Backstage.WorkerShowsCovered) + // 执行进度:spider 每处理完一场秀上报一次,后台列表页轮询 /admin/crawl/live 读取。 + internal.POST("/crawl/tasks/:id/progress", opt.Backstage.WorkerProgress) // spider 服务化:领取品牌抓取任务 / 回报结果(与 ingest 共用同一套 HMAC 验签)。 internal.GET("/crawl/tasks", opt.Backstage.WorkerTask) internal.POST("/crawl/tasks/:id/result", opt.Backstage.WorkerReport) @@ -131,6 +135,8 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { // 品牌抓取任务控制面:后台决定爬哪些品牌,spider 服务来领取执行。 // 列表 + 批量按前缀入队 + 单品牌入队 + 取消/重试/删除。 admin.GET("/crawl", opt.Backstage.CrawlList) + // 进度轮询(JSON):页面每 4 秒拉一次,只刷新状态/已提交/进度三列。 + admin.GET("/crawl/live", opt.Backstage.CrawlLive) admin.POST("/crawl/bulk", opt.Backstage.CrawlBulk) admin.POST("/crawl/brands/:uid", opt.Backstage.CrawlEnqueue) admin.POST("/crawl/tasks/:id/retry", opt.Backstage.CrawlRetry)