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:
19
db/migrations/2026-10-25-01-crawl-progress.sql
Normal file
19
db/migrations/2026-10-25-01-crawl-progress.sql
Normal file
@ -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 秒;用于判断任务是否卡死';
|
||||
@ -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; }
|
||||
|
||||
@ -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=<hashid>
|
||||
// 响应:{"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 —— 密钥只留在后端,
|
||||
|
||||
66
internal/handler/crawl_render_test.go
Normal file
66
internal/handler/crawl_render_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -16,15 +16,16 @@
|
||||
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>ID</th><th>品牌</th><th>状态</th><th>场次上限</th><th>已提交</th><th>尝试</th><th>Worker</th><th>更新时间</th><th>错误</th><th>操作</th>
|
||||
<th>ID</th><th>品牌</th><th>状态</th><th>进度</th><th>场次上限</th><th>已提交</th><th>尝试</th><th>Worker</th><th>更新时间</th><th>错误</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Rows}}<tr>
|
||||
<td><code>{{.ID}}</code></td>
|
||||
<td><a class="view" href="/admin/brands/{{.BrandUID}}">{{.BrandName}}</a></td>
|
||||
<td><span class="badge">{{.StatusLabel}}</span></td>
|
||||
<td><span class="badge" data-live-status="{{.ID}}">{{.StatusLabel}}</span></td>
|
||||
<td class="progress{{if .Stale}} stale{{end}}" data-live-progress="{{.ID}}">{{if .ProgressLabel}}{{.ProgressLabel}}{{else}}—{{end}}</td>
|
||||
<td>{{if eq .MaxShows 0}}<strong>全量</strong>{{else}}{{.MaxShows}}{{end}}</td>
|
||||
<td>{{.Shows}}</td>
|
||||
<td data-live-shows="{{.ID}}">{{.Shows}}</td>
|
||||
<td>{{.Attempts}}</td>
|
||||
<td class="muted">{{if .Worker}}{{.Worker}}{{else}}—{{end}}</td>
|
||||
<td class="muted">{{.UpdatedAt}}</td>
|
||||
@ -41,4 +42,42 @@
|
||||
</table>
|
||||
{{template "pager" .Pager}}
|
||||
</div>
|
||||
<script>
|
||||
// 抓取进度轮询:每 4 秒只刷新「状态 / 进度 / 已提交」三列。
|
||||
//
|
||||
// 为什么不用 WebSocket:进度是每场秀一次的低频事件,轮询足够;
|
||||
// 而且局部更新避免了整页刷新——整页刷会打断滚动位置和正在填的表单。
|
||||
// 为什么不用整页 meta refresh:同上,且会把「失败原因」的展开状态一起冲掉。
|
||||
(function () {
|
||||
var cells = document.querySelectorAll('[data-live-progress]');
|
||||
if (!cells.length) return;
|
||||
|
||||
function paint(items) {
|
||||
var byId = {};
|
||||
for (var i = 0; i < items.length; i++) byId[String(items[i].id)] = items[i];
|
||||
for (var j = 0; j < cells.length; j++) {
|
||||
var cell = cells[j];
|
||||
var id = cell.getAttribute('data-live-progress');
|
||||
var it = byId[id];
|
||||
if (!it) continue;
|
||||
cell.textContent = it.progress || '—';
|
||||
if (it.stale) cell.classList.add('stale'); else cell.classList.remove('stale');
|
||||
var st = document.querySelector('[data-live-status="' + id + '"]');
|
||||
if (st) st.textContent = it.status_label;
|
||||
var sh = document.querySelector('[data-live-shows="' + id + '"]');
|
||||
if (sh) sh.textContent = it.shows;
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
fetch('/admin/crawl/live', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { if (d && d.items) paint(d.items); })
|
||||
.catch(function () { /* 网络抖动忽略,下一轮再拉 */ });
|
||||
}
|
||||
|
||||
setInterval(tick, 4000);
|
||||
tick();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@ -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 指定表名。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user