feat: 爬虫页取代抓取任务/入库任务两页,抓取改全量+覆盖率跳过

一、后台只留一个「爬虫」页(/admin/spider)
抓取与入库本就是同一条流水线的上下游,分两页看谁都看不全。
新页三块:状态条(在线/卡住 + 队列数 + 待入库数)、正在抓(含进度与心跳)、
最近入库结果(品牌/场次/结果/说明)。只读,取消与重试一并下线。

二、抓取一律全量
删除品牌页的场次上限下拉与 hidden maxShows=3,入队固定 0(全量)。
crawl_tasks.max_shows 列保留,历史任务照常显示。

三、跳过判定改覆盖率
- brand_runways 增加 expected_images(spider 上报的应有张数)
- /shows/covered 返回 images + expected
- 实际张数 >= 应有 - 5 才跳过;expected=0 时回落 10 张阈值
- 实体已存在时不再直接跳过:按 key 比对把缺的图补进库(repaired)
  否则「判定不完整→重爬→后端跳过」会形成死循环,带宽白花、缺图永远补不上

四、租约 900→300 秒,且心跳算存活
只看 locked_at 会把跑了几小时的全量抓取误判成卡死;
加上 progress_at 后,真卡住 5 分钟才回收。
This commit is contained in:
toom1996
2026-10-25 23:59:54 +08:00
parent b30f18fc20
commit e930ac41c8
22 changed files with 992 additions and 527 deletions

View File

@ -0,0 +1,11 @@
-- 一场秀「应该有多少张图」。
--
-- 用途:spider 重爬前预检,判断这场秀是不是真的抓全了。
-- 此前按「已入库 ≥10 张」就整场跳过,遇到当初只成功抓了十几张的场次,
-- 剩下的图永远补不上(缺的部分没有任何记录能说明它缺)。
--
-- 0 表示未知(改动之前入库的历史数据),判定时退化为按张数阈值。
ALTER TABLE brand_runways
ADD COLUMN IF NOT EXISTS expected_images int NOT NULL DEFAULT 0;
COMMENT ON COLUMN brand_runways.expected_images IS '该场秀应有图片数(spider 上报);0=未知,判定时回落阈值';

View File

@ -0,0 +1,137 @@
# 爬虫管理台(/admin/spider)设计
日期:2026-10-25
状态:已与需求方确认,待实现
## 背景
后台把「抓取任务」和「入库任务」拆成两个页面,但它们其实是同一条流水线的上下游:
```
spider 抓图 → POST /ingest → ingest_jobs 队列 → 后端 worker 下载/写库 → brand_runways + images
↑ 抓取进度写 crawl_tasks ↑ 入库结果写 ingest_jobs.outcome/note
```
分开放导致排障时要在两页之间跳,且任何一页都看不全「现在到底在干什么」。
同时存在两个问题:
1. **小批量模式**(`max_shows=3`)让「入队」这件事语义含糊,实际使用时总是想全量。
2. **跳过判定太粗**:只要某场秀已入库 ≥10 张就整场跳过。如果当初只成功抓到 12 张(其余失败),
剩下的图永远补不上。
## 目标
1. 入队只有一个语义:**全量**。
2. 跳过判定改为**覆盖率**:入库张数接近应有张数才跳过,缺得多就补抓。
3. 用一页只读的「爬虫管理」替代原两个页面,能看清当前在抓什么、队列有多长、最近入库结果。
4. 卡死任务 5 分钟自动回收,不依赖人工点「重试」。
## 决策
| 决策 | 结论 | 理由 |
|---|---|---|
| 页面形态 | 一条流水线看全(状态条 + 正在抓 + 待抓队列 + 最近入库) | 两表本就是一条链路,合并不需要改数据模型 |
| 操作能力 | 纯只读 | 抓取卡死由租约自动回收;入库失败多为不可恢复的数据问题 |
| 场次上限 | 后台入口全部移除,入队恒为全量 | 用户明确只保留全量模式 |
| 跳过判定 | 覆盖率(允许缺 5 张) | 个别图在 Vogue 侧永久 404,要求一张不差会导致整场无限重抓 |
| 抓取租约 | 900s → 300s,且「有心跳就不回收」 | 单纯缩短会误回收长任务;心跳是现成的存活信号 |
## 设计
### 1. 全量抓取
- `brands.html`:删除批量入队的「每品牌场次上限」下拉、删除单品牌按钮里的 `hidden maxShows=3`。
- `CrawlBulk` / `CrawlEnqueue` 不再读取 `maxShows`,入队固定传 0。
- `crawl_tasks.max_shows` 列保留:历史任务仍按原值渲染,新任务恒为 0。
- spider `serve` 用任务里的 `max_shows`(恒 0 = 全量);`vogue` 子命令的 `-s` 仅留作本地调试。
### 2. 覆盖率判定
**数据**
- `brand_runways` 新增 `expected_images int not null default 0`:本场秀应有图片数。
- `RunwayIngest` 新增 `expected_images`(spider 侧已知,日志里的「共 N 张图」)。
- ingest 处理时写入:`expected_images = max(旧值, 载荷值)`,避免重抓变少时退化。
**接口**
`GET /admin/internal/shows/covered` 每项返回 `{year, season, collection_type, images, expected}`。
**判定(spider 侧)**
```
if expected > 0: 跳过条件 = images >= expected - 5
else: 跳过条件 = images >= 10 (历史数据无 expected)
```
- 余量 5:缺 1~5 张视为完整。少了再补抓是整场重下(内容寻址无法只补缺的),代价高。
- 缺超过 5 张才补抓,补抓后 `expected_images` 可能更新,逐步收敛。
### 3. 页面 `/admin/spider`
**① 状态条**
```
爬虫 在线 · 正在抓 1 个 · 队列 3 个 · 待入库 2 条
```
- 在线判定:存在 running 任务且最近心跳 < 10 分钟 → 在线;
running 但心跳 ≥ 10 分钟 → `可能卡住`;无 running → `空闲`。
**② 正在抓**(`crawl_tasks.status = running`)
| 品牌 | 进度 | 当前场次 | 心跳 |
|---|---|---|---|
| Amiri | 12/47 场 + 进度条 | Fall 2021 Menswear(24/38 张) | 8 秒前 |
心跳 > 10 分钟整行标黄。
**③ 待抓队列**(`status = queued`):品牌 / 入队时间 / 已等待。
**④ 最近入库结果**(`ingest_jobs` 最近 50 条,`kind='crawl'`)
| 时间 | 品牌 | 场次 | 结果 |
|---|---|---|---|
| 23:12 | Amiri | Fall 2019 Menswear | 新建 34 张图 |
| 23:10 | Celine | Spring 2020 RTW | 已存在跳过 |
| 23:08 | Gucci | Fall 2020 RTW | 失败:图片下载超时 |
场次标题直接取 `payload::json->>'title_en'`,结果取 `outcome` + `note`,无需改表。
**轮询**:`GET /admin/spider/live` 每 4 秒,返回 ①②③ 三块的结构化数据;④ 不轮询(历史不会变)。
### 4. 租约改 300 秒 + 心跳存活
```sql
WHERE status = 'running' AND locked_at > 0
AND locked_at < ?
AND coalesce(progress_at, 0) < ?
```
两个时间点都取 `now - 300`。心不断 → 永不回收;真卡住 → 5 分钟回收。
`CrawlStuckTimeoutSec` 由 900 改为 300。
### 5. 删除项
- 页面与模板:`/admin/crawl`、`/admin/ingest-jobs`
- 路由:`CrawlList`、`CrawlLive`、`CrawlBulk`、`CrawlRetry`、`CrawlCancel`、`CrawlDelete`、
`IngestJobs`、`IngestJobRetry`
- 导航:两项合并为一个「爬虫」
- 入队接口 `/admin/crawl/brands/:uid` → 改名 `/admin/spider/brands/:uid`(品牌页按钮同步改)
保留:品牌页的「抓取状态 / 上次抓取 / 更新记录」(品牌维度,不是爬虫维度)。
## 测试
- 渲染测试:`/admin/spider` 三区块、轮询锚点、卡住样式。
- 单元测试:
- 覆盖率判定:`expected>0 缺≤5 跳过`、`缺>5 补抓`、`expected=0 回落 10 张`
- 租约回收 SQL:有心跳不回收(集成测试落地时验证)
- 手动验证:Celine 全量重跑应只补抓缺图场次;新品牌全量抓取进度可见。
## 风险
- 全量抓取一个品牌可能数小时,若 spider 进程被杀,已入库的场次会被跳过,只重抓当前场次——可接受。
- 覆盖率依赖 spider 上报;老数据 `expected_images = 0` 时退回 10 张阈值,行为与现在一致。
- 只读页面意味着入库 `failed` 任务无法在界面上恢复,需改库处理(已确认接受)。

View File

@ -29,6 +29,9 @@ type RunwayIngest struct {
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
City string `json:"city"` // 地区/城市(street 用)
PublishedAt uint32 `json:"published_at"` // Vogue 详情页 datePublished 的 Unix 时间戳(秒);0=未抽取。标题缺年份时用于矫正 year
// ExpectedImages 本场秀应有的图片总数(spider 侧已知,含细节图)。
// 用于重爬前的覆盖率判定:实际入库张数接近它才算抓全了;0=未知(旧爬虫不上报)。
ExpectedImages int `json:"expected_images,omitempty"`
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。

View File

@ -10,7 +10,6 @@ import (
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/pagination"
)
// 本文件收口「品牌抓取任务」的控制面:
@ -22,26 +21,10 @@ import (
//
// 与入库任务(ingest_jobs)的分工见 model.CrawlTask 的注释。
// crawlRowView 列表展示结构(时间已格式化为字符串,模板里没有日期函数)。
type crawlRowView struct {
ID uint32
BrandUID string
BrandName string
Status string
StatusLabel string
MaxShows int16
Shows int32
Attempts int16
Worker string
UpdatedAt string
LastError string
// ProgressLabel 「3/25 场 · Fall 2020 Ready-to-Wear」,空表示还没有进度。
ProgressLabel string
// Stale 执行中但进度长时间没推进(多半是卡住,而不是慢)。
Stale bool
CanRetry bool
CanCancel bool
}
// 本文件保留:进度格式化(progressLabel / progressStale)、状态文案(crawlStatusLabel /
// crawlStatusClass,品牌页的「抓取状态」列在用),以及 spider 侧的全部接口。
// 原先的列表页视图 crawlRowView 与 CrawlList 已随「抓取任务」页一起下线,
// 页面改由 backstage_spider.go 的「爬虫」页承担。
// progressStaleSec 进度心跳超过这个时长没更新,就认为可能卡住了。
// 一场秀的图片下载在正常情况下是分钟级;10 分钟没有心跳基本可以断定卡住。
@ -116,201 +99,6 @@ func crawlStatusLabel(s string) string {
return s
}
// CrawlList 渲染抓取任务列表页。
func (h *BackstageHandler) CrawlList(c *gin.Context) {
if h.crawl == nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取任务未装配(crawl repository 为空)"})
return
}
p := pagination.Parse(c, "page", "pageSize", 1, 30, 200)
rows, total, err := h.crawl.List(c.Request.Context(), p.Offset(), p.PageSize)
if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
views := make([]crawlRowView, 0, len(rows))
for _, r := range rows {
ua := "-"
if r.UpdatedAt > 0 {
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,
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,
})
}
counts, _ := h.crawl.Counts(c.Request.Context())
meta := p.NewMeta(total)
renderPage(c, http.StatusOK, "crawl", gin.H{
"Rows": views,
"Total": total,
"Pager": pagination.Pager{
PrevURL: pagination.PageLinkWith(c, meta.PrevPage, "pageSize", p.PageSize),
NextURL: pagination.PageLinkWith(c, meta.NextPage, "pageSize", p.PageSize),
Label: fmt.Sprintf("第 %d / %d 页", meta.Page, meta.TotalPages),
HasPrev: meta.HasPrev,
HasNext: meta.HasNext,
},
"Queued": counts[model.CrawlQueued],
"Running": counts[model.CrawlRunning],
"Done": counts[model.CrawlDone],
"Failed": counts[model.CrawlFailed],
// 批量入队后回显「本次加入了多少个」
"Added": atoiDefault(c.Query("added"), 0),
})
}
// 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) {
if h.crawl == nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取任务未装配"})
return
}
brandID, err := hashid.Decode(c.Param("uid"))
if err != nil {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "品牌 ID 非法"})
return
}
maxShows := atoiDefault(c.PostForm("maxShows"), 0)
if _, err := h.crawl.Enqueue(c.Request.Context(), brandID, maxShows, 10); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, c.PostForm("back"))
}
// CrawlBulk 把「品牌管理页勾选的品牌」批量加入抓取队列。
//
// 这是批量操作的唯一入口(原先的「按品牌名前缀批量」已下线):
// 勾选哪几个就加哪几个,作用域明确,不会因为前缀写错而误抓几千个品牌。
func (h *BackstageHandler) CrawlBulk(c *gin.Context) {
if h.crawl == nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取任务未装配"})
return
}
uids := c.PostFormArray("uid")
if len(uids) == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "没有勾选任何品牌"})
return
}
maxShows := atoiDefault(c.PostForm("maxShows"), 3)
added := 0
for _, uid := range uids {
id, derr := hashid.Decode(strings.TrimSpace(uid))
if derr != nil {
continue
}
ok, err := h.crawl.Enqueue(c.Request.Context(), id, maxShows, 5)
if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
if ok {
added++
}
}
c.Redirect(http.StatusFound, fmt.Sprintf("/admin/brands?added=%d", added))
}
// CrawlRetry 把任务重置为待领取(失败 / 取消 / 已完成后想再跑一遍)。
func (h *BackstageHandler) CrawlRetry(c *gin.Context) {
id := atoiDefault(c.Param("id"), 0)
if id <= 0 || h.crawl == nil {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "任务 ID 非法"})
return
}
if err := h.crawl.SetStatus(c.Request.Context(), uint32(id), model.CrawlQueued); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/crawl")
}
// CrawlCancel 取消任务(执行中的任务无法真正打断 spider,只保证不再被领取)。
func (h *BackstageHandler) CrawlCancel(c *gin.Context) {
id := atoiDefault(c.Param("id"), 0)
if id <= 0 || h.crawl == nil {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "任务 ID 非法"})
return
}
if err := h.crawl.SetStatus(c.Request.Context(), uint32(id), model.CrawlCanceled); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/crawl")
}
// CrawlDelete 删除任务记录(已入库的数据不受影响)。
func (h *BackstageHandler) CrawlDelete(c *gin.Context) {
id := atoiDefault(c.Param("id"), 0)
if id <= 0 || h.crawl == nil {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "任务 ID 非法"})
return
}
if err := h.crawl.Delete(c.Request.Context(), uint32(id)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/crawl")
}
// ---------------------------------------------------------------------------
// spider 服务接口(HMAC 验签,由 router 的 /admin/internal 组挂载)
@ -376,12 +164,14 @@ func (h *BackstageHandler) WorkerShowsCovered(c *gin.Context) {
return
}
items := make([]gin.H, 0, len(coverage))
for k, n := range coverage {
for k, st := range coverage {
items = append(items, gin.H{
"year": k.Year,
"season": k.Season,
"collection_type": k.CollectionType,
"images": n,
"images": st.Images,
// expected=0 表示历史数据没有记录应有张数,spider 会退化为按张数阈值判断。
"expected": st.Expected,
})
}
c.JSON(http.StatusOK, gin.H{"items": items})

View File

@ -776,96 +776,6 @@ func (h *BackstageHandler) SuggestTitleCn(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"title_cn": cn})
}
func (h *BackstageHandler) IngestJobs(c *gin.Context) {
p := pagination.Parse(c, "page", "pageSize", 1, 50, 200)
offset := p.Offset()
jobs, total, err := h.ingest.ListJobs(c.Request.Context(), offset, p.PageSize)
if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
views := make([]ingestJobView, 0, len(jobs))
for _, j := range jobs {
ua := ""
if j.UpdatedAt > 0 {
ua = time.Unix(int64(j.UpdatedAt), 0).Format("2006-01-02 15:04:05")
}
views = append(views, ingestJobView{
ID: j.ID,
Status: j.Status,
Attempts: j.Attempts,
LastError: j.LastError,
UpdatedAt: ua,
})
}
// 越界页码(如手动改 URL 成大数)钳制到最后一页展示,与原有行为一致。
// meta.Clamp 仅调整展示用的 Page/Prev/Next,不改变上面已算好的查询 offset。
meta := p.NewMeta(total)
meta.Clamp()
renderPage(c, http.StatusOK, "ingest-jobs", gin.H{
"Jobs": views,
"Page": meta.Page,
"PageSize": p.PageSize,
"Total": total,
"TotalPages": meta.TotalPages,
"Pager": pagination.Pager{
PrevURL: pagination.PageLinkWith(c, meta.PrevPage, "pageSize", p.PageSize),
NextURL: pagination.PageLinkWith(c, meta.NextPage, "pageSize", p.PageSize),
HasPrev: meta.HasPrev,
HasNext: meta.HasNext,
},
})
}
// IngestJobRetry 把一条 failed 任务重新入队(重置为 pending),worker 会在下一个周期重新处理。
func (h *BackstageHandler) IngestJobRetry(c *gin.Context) {
id := atoiDefault(c.Param("id"), 0)
if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "任务不存在: " + c.Param("id")})
return
}
if err := h.ingest.RetryJob(c.Request.Context(), uint32(id)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/ingest-jobs")
}
// reviewFormFields 从表单收集可编辑字段(runway + street 一并收集,
// 最终白名单在仓储层按 kind 强制,非本 kind 的字段会被忽略)。
func reviewFormFields(c *gin.Context) map[string]any {
f := map[string]any{}
if v := c.PostForm("title_en"); v != "" {
f["title_en"] = v
}
if v := c.PostForm("title_cn"); v != "" {
f["title_cn"] = v
}
f["description_en"] = c.PostForm("description_en")
f["description_cn"] = c.PostForm("description_cn")
if v := c.PostForm("season"); v != "" {
f["season"] = v
}
if v := c.PostForm("collection_type"); v != "" {
f["collection_type"] = v
}
if v := c.PostForm("season_code"); v != "" {
f["season_code"] = v
}
if v := c.PostForm("title"); v != "" {
f["title"] = v
}
if v := c.PostForm("city"); v != "" {
f["city"] = v
}
if v := c.PostForm("year"); v != "" {
if y, err := strconv.Atoi(v); err == nil {
f["year"] = uint16(y)
}
}
return f
}
// usernameString 把中间件注入的 username(any)兜底成字符串。
func usernameString(v any) string {
if s, ok := v.(string); ok {

View File

@ -28,14 +28,13 @@ func TestBackstageTemplatesParsed(t *testing.T) {
"articles",
"brand-form",
"brands",
"crawl",
"dashboard",
"error",
"ingest-jobs",
"login",
"runway-detail",
"runway-edit",
"snap-edit",
"spider",
"street-snap-detail",
"users",
}
@ -74,8 +73,9 @@ func TestBackstageTemplatesRender(t *testing.T) {
"Images": []any{},
// 列表数据
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
// 抓取任务页(crawl.html):列表 + 四个状态计数
"Rows": []any{}, "Queued": int64(0), "Running": int64(0), "Done": int64(0), "Failed": int64(0),
// 爬虫页(spider.html):状态条 + 正在抓 + 待抓队列 + 最近入库结果
"State": "空闲", "StateClass": "done", "RunningCount": 0, "QueuedCount": 0, "PendingIngest": int64(0),
"Running": []spiderRunView{}, "Queued": []spiderQueueView{}, "Results": []spiderResultView{},
// 品牌页的「抓取状态 / 上次抓取」列(按 hashid 索引)
"CrawlStates": map[string]any{}, "Stats": map[string]any{}, "Added": 0,
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)

View File

@ -0,0 +1,296 @@
package handler
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
)
// 本文件收口后台「爬虫」页。
//
// 为什么把两条队列合成一页:
//
// spider 抓图 → POST /ingest → ingest_jobs → 后端 worker 下载/写库
// ↑ 抓取进度写 crawl_tasks ↑ 入库结果写 ingest_jobs.outcome/note
//
// 这两张表描述的是同一条流水线的上下游。此前分成「抓取任务」「入库任务」两页,
// 任何一页都看不全「现在到底在干什么」,排障要在两页之间来回跳。
//
// 页面是**只读**的:抓取卡死由 300 秒租约自动回收(且有心跳续命),
// 入队操作仍在品牌页(勾选 → 加入抓取队列 → 一律全量)。
// spiderRunView 「正在抓」一行。
type spiderRunView struct {
ID uint32
BrandName string
Progress string
// Beat 距最近一次心跳多久(见 progressStale 的说明)。
Beat string
Stale bool
Worker string
}
// spiderQueueView 「待抓队列」一行。
type spiderQueueView struct {
BrandName string
Since string
Waiting string
}
// spiderResultView 「最近入库结果」一行。
type spiderResultView struct {
At string
BrandName string
Title string
Result string
Note string
Bad bool
}
// SpiderBoard 渲染爬虫管理页。
func (h *BackstageHandler) SpiderBoard(c *gin.Context) {
if h.crawl == nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取仓储未装配"})
return
}
ctx := c.Request.Context()
now := time.Now().Unix()
active, err := h.crawl.ListActive(ctx)
if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
runs, queues := splitActive(active, now)
// 入库侧查询失败不该让整页 500:抓取部分仍然有参考价值。
results := make([]spiderResultView, 0, 50)
if raw, rErr := h.ingest.RecentCrawlResults(ctx, 50); rErr == nil {
for _, r := range raw {
label, bad := crawlOutcomeLabel(r.Status, r.Outcome)
results = append(results, spiderResultView{
At: formatUnix(int64(r.UpdatedAt), "01-02 15:04"),
BrandName: r.BrandName,
Title: r.Title,
Result: label,
Note: r.Note,
Bad: bad,
})
}
}
pending, pErr := h.ingest.CountPendingJobs(ctx)
if pErr != nil {
pending = 0
}
state, class := spiderState(active, now)
renderPage(c, http.StatusOK, "spider", gin.H{
"State": state,
"StateClass": class,
"RunningCount": len(runs),
"QueuedCount": len(queues),
"PendingIngest": pending,
"Running": runs,
"Queued": queues,
"Results": results,
})
}
// splitActive 把 running / queued 拆成两种视图,并按页面需要格式化时间。
func splitActive(rows []model.CrawlTaskRow, now int64) ([]spiderRunView, []spiderQueueView) {
runs := make([]spiderRunView, 0, 2)
queues := make([]spiderQueueView, 0, len(rows))
for _, r := range rows {
switch r.Status {
case model.CrawlRunning:
runs = append(runs, spiderRunView{
ID: r.ID,
BrandName: r.BrandName,
Progress: progressLabel(r.CrawlTask),
Beat: humanWait(now - lastBeat(r.CrawlTask)),
Stale: progressStale(r.CrawlTask, now),
Worker: r.Worker,
})
case model.CrawlQueued:
since := int64(r.CreatedAt)
queues = append(queues, spiderQueueView{
BrandName: r.BrandName,
Since: formatUnix(since, "01-02 15:04"),
Waiting: humanWait(now - since),
})
}
}
return runs, queues
}
// lastBeat 任务最近一次「有动静」的时间:优先进度心跳,还没有心跳时退化为领取时间。
func lastBeat(t model.CrawlTask) int64 {
if t.ProgressAt > 0 {
return int64(t.ProgressAt)
}
return int64(t.LockedAt)
}
// spiderState 给出状态条上的一句话和配色。
func spiderState(rows []model.CrawlTaskRow, now int64) (string, string) {
running := make([]model.CrawlTaskRow, 0, 1)
for _, r := range rows {
if r.Status == model.CrawlRunning {
running = append(running, r)
}
}
if len(running) == 0 {
return "空闲", "done"
}
for _, r := range running {
if progressStale(r.CrawlTask, now) {
return "可能卡住", "failed"
}
}
return "在线", "done"
}
// SpiderLive 轮询接口:只返回会变化的部分(状态 + 正在抓 + 队列数量)。
//
// 最近入库结果不参与轮询——那是历史,除了新增行不会变,每 4 秒重传毫无意义。
func (h *BackstageHandler) SpiderLive(c *gin.Context) {
if h.crawl == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "抓取仓储未装配"})
return
}
ctx := c.Request.Context()
now := time.Now().Unix()
active, err := h.crawl.ListActive(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
return
}
runs, queues := splitActive(active, now)
state, class := spiderState(active, now)
items := make([]gin.H, 0, len(runs))
for _, r := range runs {
items = append(items, gin.H{
"id": r.ID,
"brand": r.BrandName,
"progress": r.Progress,
"beat": r.Beat,
"stale": r.Stale,
"worker": r.Worker,
})
}
pending := int64(0)
if n, pErr := h.ingest.CountPendingJobs(ctx); pErr == nil {
pending = n
}
c.JSON(http.StatusOK, gin.H{
"state": state,
"state_class": class,
"running": items,
"queued": len(queues),
"pending_ingest": pending,
})
}
// SpiderEnqueue 把单个品牌加入抓取队列(品牌页行内按钮)。一律全量。
func (h *BackstageHandler) SpiderEnqueue(c *gin.Context) {
if h.crawl == nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取仓储未装配"})
return
}
brandID, err := hashid.Decode(c.Param("uid"))
if err != nil {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "品牌 ID 非法"})
return
}
// maxShows 固定 0(全量):小批量模式已下线,抓多少由「已抓全就跳过」决定。
if _, err := h.crawl.Enqueue(c.Request.Context(), brandID, 0, 10); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, c.PostForm("back"))
}
// SpiderBulk 把「品牌管理页勾选的品牌」批量加入队列。一律全量。
func (h *BackstageHandler) SpiderBulk(c *gin.Context) {
if h.crawl == nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取仓储未装配"})
return
}
uids := c.PostFormArray("uid")
if len(uids) == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "没有勾选任何品牌"})
return
}
added := 0
for _, uid := range uids {
id, derr := hashid.Decode(uid)
if derr != nil {
continue
}
ok, err := h.crawl.Enqueue(c.Request.Context(), id, 0, 5)
if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
if ok {
added++
}
}
c.Redirect(http.StatusFound, fmt.Sprintf("/admin/brands?added=%d", added))
}
// crawlOutcomeLabel 把入库结果翻译成中文,并标出是否为失败。
func crawlOutcomeLabel(status, outcome string) (string, bool) {
if status == model.IngestStatusFailed {
return "失败", true
}
if status == model.IngestStatusPending || status == model.IngestStatusProcessing {
return "处理中", false
}
switch outcome {
case model.IngestOutcomeCreated:
return "新建", false
case model.IngestOutcomeRepaired:
return "补齐缺图", false
case model.IngestOutcomeReused:
return "复用被驳回", false
case model.IngestOutcomeSkippedExisting:
return "已存在跳过", false
case model.IngestOutcomeSkippedDeleted:
return "已删除跳过", false
case model.IngestOutcomeCleaned:
return "清理", false
case model.IngestOutcomeNoop:
return "无需处理", false
}
return "完成", false
}
// formatUnix 把 Unix 秒格式化成指定布局;0 返回占位符。
func formatUnix(sec int64, layout string) string {
if sec <= 0 {
return "—"
}
return time.Unix(sec, 0).Format(layout)
}
// humanWait 把等待秒数说成人话(队列里看「等了多久」比看时间戳直观)。
func humanWait(sec int64) string {
if sec < 0 {
sec = 0
}
switch {
case sec < 60:
return fmt.Sprintf("%d 秒", sec)
case sec < 3600:
return fmt.Sprintf("%d 分钟", sec/60)
default:
return fmt.Sprintf("%d 小时 %d 分", sec/3600, (sec%3600)/60)
}
}

View File

@ -1,66 +0,0 @@
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)
}
}
}

View File

@ -0,0 +1,76 @@
package handler
import (
"bytes"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// TestSpiderPageRenders 守住「爬虫」页的三块内容与轮询锚点。
//
// 这类模板错误不会编译失败,只会在打开页面时 500 或空白,
// 所以用一个渲染测试把「有任务 / 卡住 / 有入库结果」这几种情况固定下来。
func TestSpiderPageRenders(t *testing.T) {
tpl, ok := pageTemplates["spider"]
if !ok {
t.Fatal("spider 模板未注册")
}
data := gin.H{
"NavKey": "spider",
"PendingReviews": 0,
"State": "可能卡住",
"StateClass": "failed",
"RunningCount": 1,
"QueuedCount": 2,
"PendingIngest": 3,
"Running": []spiderRunView{{
ID: 85,
BrandName: "Amiri",
Progress: "12/47 场 · Fall 2021 Menswear(24/38 张)",
Beat: "11 分钟",
Stale: true,
Worker: "vm-1-74218-0",
}},
"Queued": []spiderQueueView{
{BrandName: "Celine", Since: "10-25 23:30", Waiting: "8 分钟"},
{BrandName: "Gucci", Since: "10-25 23:35", Waiting: "3 分钟"},
},
"Results": []spiderResultView{
{At: "10-25 23:12", BrandName: "Amiri", Title: "Fall 2019 Menswear", Result: "新建", Note: "新建 34 张图"},
{At: "10-25 23:08", BrandName: "Gucci", Title: "Fall 2020 RTW", Result: "失败", Note: "图片下载超时", Bad: true},
},
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "layout", data); err != nil {
t.Fatalf("渲染爬虫页失败: %v", err)
}
out := buf.String()
for _, want := range []string{
`id="running-body"`, // 轮询重建的目标容器
"/admin/spider/live", // 轮询地址
"12/47 场 · Fall 2021 Menswear(24/38 张)", // 进度文案原样输出
"Celine", // 待抓队列
"新建 34 张图", // 入库结果说明
} {
if !strings.Contains(out, want) {
t.Errorf("页面缺少 %q", want)
}
}
// 卡住要能看出来:进度单元格带 stale 样式。
if !strings.Contains(out, "progress stale") {
t.Error("疑似卡住的任务没有 stale 样式")
}
// 失败结果要走 failed 徽标,正常结果走 done 徽标。
if !strings.Contains(out, `class="badge failed"`) {
t.Error("失败结果没有 failed 徽标")
}
if !strings.Contains(out, `class="badge done"`) {
t.Error("正常结果没有 done 徽标")
}
}

View File

@ -32,17 +32,13 @@
</div>
{{/* 批量操作表单:勾选框用 form="bulk" 关联,避免与行内表单嵌套 */}}
<form id="bulk" method="post" action="/admin/crawl/bulk"></form>
<form id="bulk" method="post" action="/admin/spider/bulk"></form>
<div class="ops" style="margin:10px 0">
<label class="muted">每品牌场次上限
<select name="maxShows" form="bulk">
<option value="1">1 场(最快探有无数据)</option>
<option value="3" selected>3 场(最近,推荐)</option>
<option value="0">全量(所有历史场次)</option>
</select>
</label>
<button type="submit" form="bulk" class="op">把勾选的品牌加入抓取队列</button>
<span class="muted">(勾选本页任意行,先点复选框列头可全选本页)</span>
<span class="muted">
(勾选本页任意行,先点复选框列头可全选本页。抓取一律全量:
已抓全的场次会被自动跳过,不用担心重复下载。)
</span>
</div>
<table>
@ -62,8 +58,8 @@
<td class="muted">{{if $cs}}{{$cs.LastAt}}{{else}}—{{end}}</td>
<td>
<a class="view" href="/admin/brands/{{.UID}}">编辑</a>
<form class="inline" method="post" action="/admin/crawl/brands/{{.UID}}" onsubmit="return confirm('把「{{.NameEn}}」加入抓取队列?默认只抓最近 3 场。')">
<input type="hidden" name="back" value="/admin/brands"><input type="hidden" name="maxShows" value="3">
<form class="inline" method="post" action="/admin/spider/brands/{{.UID}}" onsubmit="return confirm('把「{{.NameEn}}」加入抓取队列?(全量,已抓全的场次自动跳过)')">
<input type="hidden" name="back" value="/admin/brands">
<button type="submit" class="op">加入队列</button>
</form>
<form class="inline" method="post" action="/admin/brands/{{.UID}}/delete" onsubmit="return confirm('确认删除品牌「{{.NameEn}}」?该品牌下若还有图集会拒绝删除。')">

View File

@ -1,83 +0,0 @@
{{define "title"}}抓取任务 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 品牌抓取任务{{end}}
{{define "barRight"}}<a href="/admin/brands">去品牌管理加任务</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>抓取任务</h1>
<p class="muted">
任务是「从品牌管理页加入」的:勾选品牌 → 选场次上限 → 加入抓取队列。
这里看的是执行状态与失败原因。真正干活的是 spider 服务(可在另一台机器)。
</p>
<p class="muted">
待领取 {{.Queued}} · 执行中 {{.Running}} · 已完成 {{.Done}} · 失败 {{.Failed}},共 {{.Total}} 条。
<strong>「已提交 0 场」不是错误</strong>——Vogue 上很多品牌没有走秀页面(实测 A 开头里约 75%)。
</p>
<table>
<thead><tr>
<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" 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 data-live-shows="{{.ID}}">{{.Shows}}</td>
<td>{{.Attempts}}</td>
<td class="muted">{{if .Worker}}{{.Worker}}{{else}}—{{end}}</td>
<td class="muted">{{.UpdatedAt}}</td>
<td class="err break">{{.LastError}}</td>
<td>
<div class="ops">
{{if .CanRetry}}<form class="inline" method="post" action="/admin/crawl/tasks/{{.ID}}/retry"><button class="op" type="submit">重试</button></form>{{end}}
{{if .CanCancel}}<form class="inline" method="post" action="/admin/crawl/tasks/{{.ID}}/cancel"><button class="op" type="submit">取消</button></form>{{end}}
<form class="inline" method="post" action="/admin/crawl/tasks/{{.ID}}/delete" onsubmit="return confirm('删除任务记录?已入库的数据不受影响。')"><button class="op" type="submit">删除</button></form>
</div>
</td>
</tr>{{end}}
</tbody>
</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}}

View File

@ -1,24 +0,0 @@
{{define "title"}}入库任务 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 爬虫入库任务{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>爬虫入库任务</h1>
<p class="muted">worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。</p>
<table>
<thead><tr><th>ID</th><th>状态</th><th>重试</th><th>错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody>
{{range .Jobs}}<tr>
<td><code>{{.ID}}</code></td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>{{.Attempts}}</td>
<td class="err break">{{.LastError}}</td>
<td class="muted">{{.UpdatedAt}}</td>
<td>{{if eq .Status "failed"}}<form class="retry" method="post" action="/admin/ingest-jobs/{{.ID}}/retry"><button class="retry" type="submit">重试</button></form>{{else}}<span class="muted">—</span>{{end}}</td>
</tr>{{end}}
</tbody>
</table>
{{template "pager" .Pager}}
</div>
{{end}}

View File

@ -0,0 +1,134 @@
{{define "title"}}爬虫 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 爬虫{{end}}
{{define "barRight"}}<a href="/admin/brands">去品牌管理加任务</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>爬虫</h1>
<p class="muted">
这里看「爬虫现在在干什么」:<strong>正在抓</strong>是当下,
<strong>待抓队列</strong>是排队等着的品牌,
<strong>最近入库结果</strong>是后端已经处理完的场次。
</p>
<p class="muted">
入队请去<a href="/admin/brands">品牌管理</a>勾选品牌。抓取一律全量,
已经抓全的场次会被自动跳过,不会重复下载。
</p>
<p class="muted" id="spider-status">
爬虫 <span class="badge {{.StateClass}}" id="st-state">{{.State}}</span>
· 正在抓 <strong id="st-running">{{.RunningCount}}</strong> 个
· 队列 <strong id="st-queued">{{.QueuedCount}}</strong> 个
· 待入库 <strong id="st-pending">{{.PendingIngest}}</strong> 条
</p>
<h2>正在抓</h2>
<table>
<thead><tr><th>品牌</th><th>进度</th><th>心跳</th><th>Worker</th></tr></thead>
<tbody id="running-body">
{{range .Running}}<tr>
<td><strong>{{.BrandName}}</strong></td>
<td class="progress{{if .Stale}} stale{{end}}">{{if .Progress}}{{.Progress}}{{else}}刚领取,正在拉取秀列表…{{end}}</td>
<td class="muted{{if .Stale}} stale{{end}}">{{.Beat}}前</td>
<td class="muted">{{.Worker}}</td>
</tr>{{end}}
{{if not .Running}}<tr id="running-empty"><td colspan="4" class="muted">当前没有正在抓取的任务。</td></tr>{{end}}
</tbody>
</table>
<h2>待抓队列({{.QueuedCount}})</h2>
<table>
<thead><tr><th>品牌</th><th>入队时间</th><th>已等待</th></tr></thead>
<tbody>
{{range .Queued}}<tr>
<td>{{.BrandName}}</td>
<td class="muted">{{.Since}}</td>
<td class="muted">{{.Waiting}}</td>
</tr>{{end}}
{{if not .Queued}}<tr><td colspan="3" class="muted">队列为空。</td></tr>{{end}}
</tbody>
</table>
<h2>最近入库结果</h2>
<p class="muted">最近 50 条。「已存在跳过」是正常状态——说明这场秀之前抓过,没有重复写库。</p>
<table>
<thead><tr><th>时间</th><th>品牌</th><th>场次</th><th>结果</th><th>说明</th></tr></thead>
<tbody>
{{range .Results}}<tr>
<td class="muted">{{.At}}</td>
<td>{{.BrandName}}</td>
<td>{{.Title}}</td>
<td>{{if .Bad}}<span class="badge failed">{{.Result}}</span>{{else}}<span class="badge done">{{.Result}}</span>{{end}}</td>
<td class="err break">{{.Note}}</td>
</tr>{{end}}
{{if not .Results}}<tr><td colspan="5" class="muted">还没有入库记录。</td></tr>{{end}}
</tbody>
</table>
</div>
<script>
// 抓取进度轮询:只刷新状态条与「正在抓」表格。
//
// 用 DOM API 建行而不是拼 innerHTML:品牌名来自数据库,直接拼字符串会把
// 特殊字符当成标签解析(既会破版,也是一条注入面)。
(function () {
var body = document.getElementById('running-body');
if (!body) return;
function cell(text, cls) {
var td = document.createElement('td');
td.textContent = text;
if (cls) td.className = cls;
return td;
}
function paint(list) {
while (body.firstChild) body.removeChild(body.firstChild);
if (!list.length) {
var tr0 = document.createElement('tr');
var td0 = document.createElement('td');
td0.colSpan = 4;
td0.className = 'muted';
td0.textContent = '当前没有正在抓取的任务。';
tr0.appendChild(td0);
body.appendChild(tr0);
return;
}
list.forEach(function (it) {
var tr = document.createElement('tr');
var tdBrand = document.createElement('td');
var b = document.createElement('strong');
b.textContent = it.brand || '—';
tdBrand.appendChild(b);
tr.appendChild(tdBrand);
tr.appendChild(cell(it.progress || '刚领取,正在拉取秀列表…', it.stale ? 'progress stale' : 'progress'));
tr.appendChild(cell((it.beat || '0 秒') + '前', it.stale ? 'muted stale' : 'muted'));
tr.appendChild(cell(it.worker || '—', 'muted'));
body.appendChild(tr);
});
}
function tick() {
fetch('/admin/spider/live', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
var st = document.getElementById('st-state');
if (st) { st.textContent = d.state; st.className = 'badge ' + d.state_class; }
var rn = document.getElementById('st-running');
if (rn) rn.textContent = (d.running || []).length;
var qn = document.getElementById('st-queued');
if (qn) qn.textContent = d.queued;
var pn = document.getElementById('st-pending');
if (pn) pn.textContent = d.pending_ingest;
paint(d.running || []);
})
.catch(function () { /* 网络抖动忽略,下一轮再拉 */ });
}
setInterval(tick, 4000);
tick();
})();
</script>
{{end}}

View File

@ -18,8 +18,7 @@
<a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a>
<a href="/admin/articles" class="{{if or (eq .NavKey "articles") (eq .NavKey "runway-edit") (eq .NavKey "snap-edit") (eq .NavKey "runway-detail") (eq .NavKey "street-snap-detail")}}on{{end}}">文章{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a>
<a href="/admin/brands" class="{{if or (eq .NavKey "brands") (eq .NavKey "brand-form")}}on{{end}}">品牌</a>
<a href="/admin/crawl" class="{{if eq .NavKey "crawl"}}on{{end}}">抓取任务</a>
<a href="/admin/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a>
<a href="/admin/spider" class="{{if eq .NavKey "spider"}}on{{end}}">爬虫</a>
<a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a>
<span class="sep"></span>
{{template "barRight" .}}

View File

@ -16,9 +16,13 @@ const (
CrawlCanceled = "canceled" // 后台手动取消
)
// CrawlStuckTimeoutSec 是 running 任务的租约时长:超过该时长仍未回报,
// 视为 worker 已崩溃(断电 / 被杀),任务可被重新领取,避免永久卡在 running。
const CrawlStuckTimeoutSec = 900
// CrawlStuckTimeoutSec 是 running 任务的「静默时长」阈值:领取后这么久既没回报、
// 也没有进度心跳,才视为 worker 已崩溃(断电 / 被杀),任务可被重新领取。
//
// 300 秒而不是更长:卡住后 5 分钟内自动回收,不必人工干预。
// 判活同时看 progress_at(spider 每 60 秒打一次心跳)——只看 locked_at 会把
// 跑了十几分钟的正常任务误判成卡死(一次全量抓取可能要几个小时)。
const CrawlStuckTimeoutSec = 300
// CrawlMaxAttempts 自动重试上限;达到后置 failed,需后台手动「重试」。
const CrawlMaxAttempts = 3

View File

@ -34,6 +34,7 @@ const (
IngestOutcomeReused = "reused_rejected" // 复用被驳回的记录并覆盖
IngestOutcomeSkippedExisting = "skipped_existing" // 实体已存在,跳过(不重下图片)
IngestOutcomeSkippedDeleted = "skipped_deleted" // 实体已软删,跳过(删除即永久)
IngestOutcomeRepaired = "repaired" // 实体已存在,但补齐了缺失的图片(覆盖率不足时重爬)
IngestOutcomeCleaned = "cleaned" // 媒体清理任务
IngestOutcomeNoop = "noop" // 无需处理(空载荷)
)

View File

@ -14,6 +14,11 @@ type BrandRunway struct {
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
// ExpectedImages 该场秀应有的图片总数(spider 上报)。0 = 未知(本字段上线前的历史数据)。
//
// 用途:重爬前的覆盖率判定——实际张数接近应有张数才算「抓全了」。
// 只看「有没有图」会把当初只抓到十几张的场次永久判成已完成,缺的图再也补不上。
ExpectedImages int `gorm:"column:expected_images;not null;default:0" json:"expected_images"`
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
Year uint16 `gorm:"column:year" json:"year"`
Cover string `gorm:"column:cover" json:"cover"`

View File

@ -47,12 +47,20 @@ type CrawlRepository interface {
//
// crawl_tasks 每品牌只有一行且会被覆盖,历史只存在 brand_crawl_logs 里。
ListCrawlLogs(ctx context.Context, brandID uint32, limit int) ([]model.CrawlLog, error)
// ShowCoverage 返回某品牌「已入库的秀 → 已有图片张数」,键是实体键(年份+季节+类型)。
// ShowCoverage 返回某品牌「已入库的秀 → 覆盖率」,键是实体键(年份+季节+类型)。
//
// 用途:spider 抓取前预检,**整场跳过**已入库的秀。
// 用途:spider 抓取前预检,整场跳过已经抓全的秀。
// 没有它,重爬会把几百张已经下过的图再下一遍——图片 key 是内容 sha1,
// 不下载就无法知道它其实已经存在,带宽全白烧(线上实测 62 分钟 0 新增)。
ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]int, error)
ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]ShowCoverageStat, error)
}
// ShowCoverageStat 一场秀的图片覆盖情况。
//
// Expected 为 0 表示未知(该字段上线前入库的历史数据),调用方应退化为按张数阈值判断。
type ShowCoverageStat struct {
Images int // 库里实际图片张数
Expected int // 应有张数(spider 上报,0=未知)
}
// ShowKey 一场秀的业务主键,与 ingest 的实体键一致(品牌 + 年份 + 季节 + 类型)。
@ -208,13 +216,19 @@ func (r *crawlRepository) Claim(ctx context.Context, worker string) (*model.Craw
}
}()
// 回收租约过期的 running(worker 崩溃 / 断电),否则任务永远卡着。
// 回收「静默」的 running(worker 崩溃 / 断电),否则任务永远卡着。
//
// 存活判定用**两个信号取较晚者**:locked_at(领取时刻)与 progress_at(进度心跳)。
// 只看 locked_at 会误杀长任务——一次全量抓取可能跑几个小时,而 locked_at 一写就不再更新;
// 加上心跳后,正常在跑的任务永远不会被回收,只有真的 5 分钟没动静才落回队列。
if now > model.CrawlStuckTimeoutSec {
cutoff := 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 {
WHERE status = 'running' AND locked_at > 0
AND locked_at < ? AND coalesce(progress_at, 0) < ?`,
now, cutoff, cutoff).Error; err != nil {
return nil, err
}
}
@ -448,16 +462,17 @@ func (r *crawlRepository) Counts(ctx context.Context) (map[string]int64, error)
// 只统计未软删的图:一张被删过的秀如果只剩几张,说明当初没抓全,
// 调用方会按阈值判断是否值得重抓,而不是一律跳过。
// JOIN 保证 image_count > 0,没有图的秀不会出现在结果里。
func (r *crawlRepository) ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]int, error) {
func (r *crawlRepository) ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]ShowCoverageStat, 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"`
Expected int `gorm:"column:expected"`
}
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
count(i.id) AS images, max(r.expected_images) AS expected
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
@ -465,9 +480,10 @@ func (r *crawlRepository) ShowCoverage(ctx context.Context, brandID uint32) (map
brandID).Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[ShowKey]int, len(rows))
out := make(map[ShowKey]ShowCoverageStat, len(rows))
for _, row := range rows {
out[ShowKey{Year: row.Year, Season: row.Season, CollectionType: row.CollectionType}] = row.Images
out[ShowKey{Year: row.Year, Season: row.Season, CollectionType: row.CollectionType}] =
ShowCoverageStat{Images: row.Images, Expected: row.Expected}
}
return out, nil
}

View File

@ -65,6 +65,15 @@ type IngestRepository interface {
BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
// RunwayImageState 返回某场秀已有图片的 key→id 映射与最大排序号,供「补齐缺图」比对。
//
// 图片 key 是内容 sha1,spider 直传时会一并上报,所以**不用下载**就能知道哪张缺。
RunwayImageState(ctx context.Context, runwayID uint32) (RunwayImageState, error)
// CreateRunwayImage 插入一张图片(回填自增 id,供细节图挂 parent_image_id)。
CreateRunwayImage(ctx context.Context, img *model.BrandRunwayImage) error
// SyncRunwayImageStats 重算 image_count(主图行数),并抬高 expected_images。
// expected 只增不减:抓取中途失败导致张数变少时,不应把「应有张数」改小。
SyncRunwayImageStats(ctx context.Context, runwayID uint32, expected int) error
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
// 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
@ -77,6 +86,13 @@ type IngestRepository interface {
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
CountJobs(ctx context.Context) (int64, error)
// CountPendingJobs 返回尚未被 worker 处理的入库任务数(后台状态条)。
CountPendingJobs(ctx context.Context) (int64, error)
// RecentCrawlResults 取最近若干条爬虫入库结果,带品牌名与场次标题,供「最近入库结果」表格。
//
// 场次标题从 payload JSON 里取:入库表只存原始载荷,没有 title 列,
// 为展示再冗余一列不值得——payload 里本来就有。
RecentCrawlResults(ctx context.Context, limit int) ([]CrawlResultRow, error)
// RetryJob 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。
RetryJob(ctx context.Context, id uint32) error
// EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(payload 为待清理 key 的 JSON)。
@ -656,3 +672,124 @@ func indexOf(s, sub string) int {
}
return -1
}
// CrawlResultRow 一条最近的爬虫入库结果(后台展示用)。
type CrawlResultRow struct {
UpdatedAt uint32
Status string
Outcome string
Note string
BrandName string
Title string // 场次英文标题(来自 payload)
}
// CountPendingJobs 统计待处理的入库任务。
func (r *ingestRepository) CountPendingJobs(ctx context.Context) (int64, error) {
var n int64
if err := r.db.WithContext(ctx).Raw(
`SELECT count(*) FROM ingest_jobs WHERE status = ?`, model.IngestStatusPending).Scan(&n).Error; err != nil {
return 0, err
}
return n, nil
}
// RecentCrawlResults 取最近 N 条爬虫入库结果。
//
// payload 用 `LIKE '{%'` 先挡一道再转 jsonb:正常载荷一定是 JSON 对象,
// 但历史脏数据/空串会让 `::jsonb` 抛错并整页 500——展示页不该被一条坏数据拖垮。
func (r *ingestRepository) RecentCrawlResults(ctx context.Context, limit int) ([]CrawlResultRow, error) {
if limit <= 0 {
limit = 50
}
var rows []struct {
UpdatedAt uint32 `gorm:"column:updated_at"`
Status string `gorm:"column:status"`
Outcome string `gorm:"column:outcome"`
Note string `gorm:"column:note"`
BrandName string `gorm:"column:brand_name"`
Title string `gorm:"column:title"`
}
if err := r.db.WithContext(ctx).Raw(`
SELECT j.updated_at, j.status, coalesce(j.outcome, '') AS outcome,
coalesce(j.note, '') AS note,
coalesce(b.name_en, '') AS brand_name,
CASE WHEN j.payload LIKE '{%' THEN coalesce(j.payload::jsonb->>'title_en', '') ELSE '' END AS title
FROM ingest_jobs j
LEFT JOIN brands b ON b.id = j.brand_id
WHERE j.kind = ?
ORDER BY j.id DESC
LIMIT ?`, model.IngestKindCrawl, limit).Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]CrawlResultRow, 0, len(rows))
for _, row := range rows {
out = append(out, CrawlResultRow{
UpdatedAt: row.UpdatedAt, Status: row.Status, Outcome: row.Outcome,
Note: row.Note, BrandName: row.BrandName, Title: row.Title,
})
}
return out, nil
}
// RunwayImageState 一场秀已有图片的快照:key→id 映射 + 最大排序号。
type RunwayImageState struct {
// Keys 图片存储 key(如 runway/<sha1>.jpg)→ 图片行 id。
Keys map[string]uint32
// MaxSort 当前最大 sort_order;补齐的新图从它往后排。
MaxSort uint32
}
// RunwayImageState 读取已有图片(只含未删除的行)。
//
// 用 key 而不是 URL 做比对:库里存的就是 key,spider 直传后上报的也是 key,
// 于是「这张图有没有」变成一次纯内存查表,不用下载、不用请求存储。
func (r *ingestRepository) RunwayImageState(ctx context.Context, runwayID uint32) (RunwayImageState, error) {
var rows []struct {
ID uint32 `gorm:"column:id"`
Image string `gorm:"column:image"`
SortOrder uint32 `gorm:"column:sort_order"`
}
if err := r.db.WithContext(ctx).Raw(
`SELECT id, coalesce(image, '') AS image, coalesce(sort_order, 0) AS sort_order
FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0`,
runwayID).Scan(&rows).Error; err != nil {
return RunwayImageState{}, err
}
st := RunwayImageState{Keys: make(map[string]uint32, len(rows))}
for _, row := range rows {
if row.Image != "" {
st.Keys[row.Image] = row.ID
}
if row.SortOrder > st.MaxSort {
st.MaxSort = row.SortOrder
}
}
return st, nil
}
// CreateRunwayImage 插入一张图片并回填自增 id。
// 细节图需要把父主图的 id 写进 parent_image_id,所以必须拿回 id。
func (r *ingestRepository) CreateRunwayImage(ctx context.Context, img *model.BrandRunwayImage) error {
now := uint32(time.Now().Unix())
img.CreatedAt, img.UpdatedAt = now, now
return r.db.WithContext(ctx).Create(img).Error
}
// SyncRunwayImageStats 重算主图张数并抬高应有张数。
//
// image_count 用子查询从实际行数算,而不是传入累加值:
// 并发补图或中途失败时,累加值容易和真实行数漂移,而 image_count 会直接影响
// 公开卡片的「N 张」角标与热度排序,必须与真实行一致。
func (r *ingestRepository) SyncRunwayImageStats(ctx context.Context, runwayID uint32, expected int) error {
if expected < 0 {
expected = 0
}
return r.db.WithContext(ctx).Exec(`
UPDATE brand_runways SET
image_count = (SELECT count(*) FROM brand_runway_images
WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0),
expected_images = greatest(expected_images, ?),
updated_at = ?
WHERE id = ?`,
runwayID, expected, uint32(time.Now().Unix()), runwayID).Error
}

View File

@ -132,19 +132,12 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
// 品牌抓取任务控制面:后台决定爬哪些品牌,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)
admin.POST("/crawl/tasks/:id/cancel", opt.Backstage.CrawlCancel)
admin.POST("/crawl/tasks/:id/delete", opt.Backstage.CrawlDelete)
// 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)
// 爬虫页:把「抓取」与「入库」两条队列拼成一条流水线(只读)。
// 入队仍在品牌页,两个 POST 是被那里的按钮调用的。
admin.GET("/spider", opt.Backstage.SpiderBoard)
admin.GET("/spider/live", opt.Backstage.SpiderLive)
admin.POST("/spider/bulk", opt.Backstage.SpiderBulk)
admin.POST("/spider/brands/:uid", opt.Backstage.SpiderEnqueue)
// 用户管理:列出用户 + 提级 / 降为 VIP
admin.GET("/users", opt.Backstage.Users)
admin.POST("/users/:id/tier", opt.Backstage.UserSetTier)

View File

@ -664,7 +664,7 @@ func TestBackstageNavStatsOnEveryPage(t *testing.T) {
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
}
// 导航已在共享 partial 里,各模块入口都应在
for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
for _, label := range []string{"文章", "品牌", "爬虫", "用户"} {
if !strings.Contains(body, ">"+label) {
t.Fatalf("导航应包含入口 %q", label)
}

View File

@ -155,6 +155,16 @@ func (s *IngestService) RetryJob(ctx context.Context, id uint32) error {
return s.repo.RetryJob(ctx, id)
}
// CountPendingJobs 待处理入库任务数(后台爬虫页状态条)。
func (s *IngestService) CountPendingJobs(ctx context.Context) (int64, error) {
return s.repo.CountPendingJobs(ctx)
}
// RecentCrawlResults 最近若干条入库结果(后台爬虫页的「最近入库结果」表)。
func (s *IngestService) RecentCrawlResults(ctx context.Context, limit int) ([]repository.CrawlResultRow, error) {
return s.repo.RecentCrawlResults(ctx, limit)
}
// Run 启动 worker 循环:定时领取 pending 任务并处理,直到 ctx 取消。
// 多实例安全:Claim 用 SKIP LOCKED,互不抢同一条。
func (s *IngestService) Run(ctx context.Context, batch int, interval time.Duration) {
@ -361,14 +371,29 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
}
}
if action == ingestSkip {
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过(回填 published_at)", job.ID, existStatus)
// 临时回填:仅更新发布时间与缺失年份,不重下图片(图片已在库)。
// 实体已存在 ≠ 抓全了:先按 key 比对把缺的图补上。
//
// 不补的话,「覆盖率判定说这场不完整 → spider 重爬 → 后端直接跳过」会形成死循环:
// 每次重爬都把整场图下一遍,库里却一张不增,缺的图永远补不上。
added, mErr := s.mergeMissingImages(ctx, reuseID, brandID, p)
if mErr != nil {
s.failOrRetry(ctx, job.ID, "merge missing images: "+mErr.Error())
return
}
// 回填发布时间与缺失年份(不重下图片)。
if p.PublishedAt != 0 {
if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil {
s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error())
return
}
}
if added > 0 {
log.Printf("[ingest] job=%d runway 实体已存在,补齐 %d 张缺图", job.ID, added)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeRepaired,
fmt.Sprintf("已存在(status=%s),补齐 %d 张缺图", existStatus, added))
return
}
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),图片齐全,跳过", job.ID, existStatus)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedExisting, "已存在(status="+existStatus+"),跳过重下")
return
}
@ -432,6 +457,10 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
PublishedAt: p.PublishedAt,
Cover: cover,
ImageCount: imageCount,
// 应有张数取「spider 上报值」与「本次实际入库数」的较大者:
// 去重会砍掉重复图,payload 里的总数可能偏高;而实际入库数就是本次能拿到的全部。
// 取较大者保证覆盖率判定不会因为去重而永远判「不完整」。
ExpectedImages: maxInt(p.ExpectedImages, int(imageCount)),
Status: model.StatusPending,
}
@ -593,6 +622,107 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
return cover, rows, vecs, keys, failed, timing
}
// maxInt 取两数较大者(expected_images 只增不减)。
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// mergeMissingImages 把 payload 里「库里还没有」的图片补进一场已存在的秀,返回补了几张。
//
// 为什么不用下载:媒体机直传时 payload 带的是 `main_key` / `detail_keys`(内容 sha1),
// 库里的 `image` 列存的也是同一个 key,所以「这张图有没有」是一次纯内存查表。
// 图片本来就已经在对象存储里了,补的是**数据库行**,不产生任何网络流量。
//
// 老爬虫只上报 URL(没有 key)时无法比对——那种情况直接返回 0,
// 由「覆盖率判定」保证新爬虫一定上报 key,从而让补齐这条路可用。
func (s *IngestService) mergeMissingImages(ctx context.Context, runwayID, brandID uint32, p dto.RunwayIngest) (int, error) {
if runwayID == 0 || len(p.Looks) == 0 {
return 0, nil
}
st, err := s.repo.RunwayImageState(ctx, runwayID)
if err != nil {
return 0, err
}
added := 0
order := st.MaxSort
for li, look := range p.Looks {
lookNo := li + 1
// 主图:已有就复用 id(细节图要挂它),没有就新建一行。
var parentID uint32
if look.MainKey != "" {
if id, ok := st.Keys[look.MainKey]; ok {
parentID = id
} else {
order++
img := model.BrandRunwayImage{
RunwayID: runwayID,
BrandID: brandID,
Image: look.MainKey,
Name: fmt.Sprintf("Look %d", lookNo),
SortOrder: order,
IsDetail: 0,
ImageSize: look.MainSize,
}
if err := s.repo.CreateRunwayImage(ctx, &img); err != nil {
return added, err
}
parentID = img.ID
st.Keys[look.MainKey] = img.ID
added++
}
}
// 细节图:必须能挂到父主图,否则宁可跳过(挂不上的细节图在后台无法展开)。
if parentID == 0 {
continue
}
for di, dk := range look.DetailKeys {
if dk == "" {
continue
}
if _, ok := st.Keys[dk]; ok {
continue
}
order++
img := model.BrandRunwayImage{
RunwayID: runwayID,
BrandID: brandID,
Image: dk,
Name: fmt.Sprintf("Look %d — Detail %d", lookNo, di+1),
SortOrder: order,
IsDetail: 1,
ParentImageID: parentID,
ImageSize: sizeAt(look.DetailSizes, di),
}
if err := s.repo.CreateRunwayImage(ctx, &img); err != nil {
return added, err
}
st.Keys[dk] = img.ID
added++
}
}
if added > 0 || p.ExpectedImages > 0 {
if err := s.repo.SyncRunwayImageStats(ctx, runwayID, p.ExpectedImages); err != nil {
return added, err
}
}
return added, nil
}
// sizeAt 越界时返回 0(spider 可能只上报部分 size)。
func sizeAt(sizes []int64, i int) int64 {
if i >= 0 && i < len(sizes) {
return sizes[i]
}
return 0
}
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。
func countMainImages(images []model.BrandRunwayImage) uint16 {