From e930ac41c89c22320975c8791c556d7ab5c9b106 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Sun, 25 Oct 2026 23:59:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=88=AC=E8=99=AB=E9=A1=B5=E5=8F=96?= =?UTF-8?q?=E4=BB=A3=E6=8A=93=E5=8F=96=E4=BB=BB=E5=8A=A1/=E5=85=A5?= =?UTF-8?q?=E5=BA=93=E4=BB=BB=E5=8A=A1=E4=B8=A4=E9=A1=B5=EF=BC=8C=E6=8A=93?= =?UTF-8?q?=E5=8F=96=E6=94=B9=E5=85=A8=E9=87=8F+=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=8E=87=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、后台只留一个「爬虫」页(/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 分钟才回收。 --- .../2026-10-25-02-runway-expected-images.sql | 11 + .../specs/2026-10-25-spider-console-design.md | 137 ++++++++ internal/dto/ingest.go | 3 + internal/handler/backstage_crawl.go | 226 +------------ internal/handler/backstage_handler.go | 90 ------ internal/handler/backstage_handler_test.go | 8 +- internal/handler/backstage_spider.go | 296 ++++++++++++++++++ internal/handler/crawl_render_test.go | 66 ---- internal/handler/spider_render_test.go | 76 +++++ internal/handler/templates/pages/brands.html | 18 +- internal/handler/templates/pages/crawl.html | 83 ----- .../handler/templates/pages/ingest-jobs.html | 24 -- internal/handler/templates/pages/spider.html | 134 ++++++++ internal/handler/templates/partials.html | 3 +- internal/model/crawl_task.go | 10 +- internal/model/ingest_job.go | 1 + internal/model/runway.go | 5 + internal/repository/crawl_repository.go | 36 ++- internal/repository/ingest_repository.go | 137 ++++++++ internal/router/backstage.go | 19 +- internal/router/backstage_test.go | 2 +- internal/service/ingest_service.go | 134 +++++++- 22 files changed, 992 insertions(+), 527 deletions(-) create mode 100644 db/migrations/2026-10-25-02-runway-expected-images.sql create mode 100644 docs/superpowers/specs/2026-10-25-spider-console-design.md create mode 100644 internal/handler/backstage_spider.go delete mode 100644 internal/handler/crawl_render_test.go create mode 100644 internal/handler/spider_render_test.go delete mode 100644 internal/handler/templates/pages/crawl.html delete mode 100644 internal/handler/templates/pages/ingest-jobs.html create mode 100644 internal/handler/templates/pages/spider.html diff --git a/db/migrations/2026-10-25-02-runway-expected-images.sql b/db/migrations/2026-10-25-02-runway-expected-images.sql new file mode 100644 index 0000000..afc44ee --- /dev/null +++ b/db/migrations/2026-10-25-02-runway-expected-images.sql @@ -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=未知,判定时回落阈值'; diff --git a/docs/superpowers/specs/2026-10-25-spider-console-design.md b/docs/superpowers/specs/2026-10-25-spider-console-design.md new file mode 100644 index 0000000..db14900 --- /dev/null +++ b/docs/superpowers/specs/2026-10-25-spider-console-design.md @@ -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` 任务无法在界面上恢复,需改库处理(已确认接受)。 diff --git a/internal/dto/ingest.go b/internal/dto/ingest.go index 51dfa0d..791e9bf 100644 --- a/internal/dto/ingest.go +++ b/internal/dto/ingest.go @@ -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(全部视为主图,无细节)。 diff --git a/internal/handler/backstage_crawl.go b/internal/handler/backstage_crawl.go index 64a6da9..841fdd2 100644 --- a/internal/handler/backstage_crawl.go +++ b/internal/handler/backstage_crawl.go @@ -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}) diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index 59f8260..dbc9d03 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -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 { diff --git a/internal/handler/backstage_handler_test.go b/internal/handler/backstage_handler_test.go index be7b7e9..53a04c1 100644 --- a/internal/handler/backstage_handler_test.go +++ b/internal/handler/backstage_handler_test.go @@ -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 覆盖) diff --git a/internal/handler/backstage_spider.go b/internal/handler/backstage_spider.go new file mode 100644 index 0000000..b092784 --- /dev/null +++ b/internal/handler/backstage_spider.go @@ -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) + } +} diff --git a/internal/handler/crawl_render_test.go b/internal/handler/crawl_render_test.go deleted file mode 100644 index 7b7a090..0000000 --- a/internal/handler/crawl_render_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/internal/handler/spider_render_test.go b/internal/handler/spider_render_test.go new file mode 100644 index 0000000..921d467 --- /dev/null +++ b/internal/handler/spider_render_test.go @@ -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 徽标") + } +} diff --git a/internal/handler/templates/pages/brands.html b/internal/handler/templates/pages/brands.html index ea5d938..cc1af6b 100644 --- a/internal/handler/templates/pages/brands.html +++ b/internal/handler/templates/pages/brands.html @@ -32,17 +32,13 @@ {{/* 批量操作表单:勾选框用 form="bulk" 关联,避免与行内表单嵌套 */}} -
+
- - (勾选本页任意行,先点复选框列头可全选本页) + + (勾选本页任意行,先点复选框列头可全选本页。抓取一律全量: + 已抓全的场次会被自动跳过,不用担心重复下载。) +
@@ -62,8 +58,8 @@
{{if $cs}}{{$cs.LastAt}}{{else}}—{{end}} 编辑 -
- + +
diff --git a/internal/handler/templates/pages/crawl.html b/internal/handler/templates/pages/crawl.html deleted file mode 100644 index 5d2e00b..0000000 --- a/internal/handler/templates/pages/crawl.html +++ /dev/null @@ -1,83 +0,0 @@ -{{define "title"}}抓取任务 · 管理后台{{end}} -{{define "barLabel"}}管理后台 · 品牌抓取任务{{end}} -{{define "barRight"}}去品牌管理加任务 · 退出{{end}} -{{define "content"}} -{{template "bar" .}} -
-

抓取任务

-

- 任务是「从品牌管理页加入」的:勾选品牌 → 选场次上限 → 加入抓取队列。 - 这里看的是执行状态与失败原因。真正干活的是 spider 服务(可在另一台机器)。 -

-

- 待领取 {{.Queued}} · 执行中 {{.Running}} · 已完成 {{.Done}} · 失败 {{.Failed}},共 {{.Total}} 条。 - 「已提交 0 场」不是错误——Vogue 上很多品牌没有走秀页面(实测 A 开头里约 75%)。 -

- - - - - - -{{range .Rows}} - - - - - - - - - - - -{{end}} - -
ID品牌状态进度场次上限已提交尝试Worker更新时间错误操作
{{.ID}}{{.BrandName}}{{.StatusLabel}}{{if .ProgressLabel}}{{.ProgressLabel}}{{else}}—{{end}}{{if eq .MaxShows 0}}全量{{else}}{{.MaxShows}}{{end}}{{.Shows}}{{.Attempts}}{{if .Worker}}{{.Worker}}{{else}}—{{end}}{{.UpdatedAt}}{{.LastError}} -
- {{if .CanRetry}}{{end}} - {{if .CanCancel}}
{{end}} -
-
-
-{{template "pager" .Pager}} -
- -{{end}} diff --git a/internal/handler/templates/pages/ingest-jobs.html b/internal/handler/templates/pages/ingest-jobs.html deleted file mode 100644 index 2ba5fae..0000000 --- a/internal/handler/templates/pages/ingest-jobs.html +++ /dev/null @@ -1,24 +0,0 @@ -{{define "title"}}入库任务 · 管理后台{{end}} -{{define "barLabel"}}管理后台 · 爬虫入库任务{{end}} -{{define "barRight"}}退出{{end}} -{{define "content"}} -{{template "bar" .}} -
-

爬虫入库任务

-

worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。

- - - -{{range .Jobs}} - - - - - - -{{end}} - -
ID状态重试错误信息更新时间操作
{{.ID}}{{.Status}}{{.Attempts}}{{.LastError}}{{.UpdatedAt}}{{if eq .Status "failed"}}
{{else}}—{{end}}
-{{template "pager" .Pager}} -
-{{end}} diff --git a/internal/handler/templates/pages/spider.html b/internal/handler/templates/pages/spider.html new file mode 100644 index 0000000..7bcb579 --- /dev/null +++ b/internal/handler/templates/pages/spider.html @@ -0,0 +1,134 @@ +{{define "title"}}爬虫 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 爬虫{{end}} +{{define "barRight"}}去品牌管理加任务 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

爬虫

+

+ 这里看「爬虫现在在干什么」:正在抓是当下, + 待抓队列是排队等着的品牌, + 最近入库结果是后端已经处理完的场次。 +

+

+ 入队请去品牌管理勾选品牌。抓取一律全量, + 已经抓全的场次会被自动跳过,不会重复下载。 +

+ +

+ 爬虫 {{.State}} + · 正在抓 {{.RunningCount}} 个 + · 队列 {{.QueuedCount}} 个 + · 待入库 {{.PendingIngest}} 条 +

+ +

正在抓

+ + + +{{range .Running}} + + + + +{{end}} +{{if not .Running}}{{end}} + +
品牌进度心跳Worker
{{.BrandName}}{{if .Progress}}{{.Progress}}{{else}}刚领取,正在拉取秀列表…{{end}}{{.Beat}}前{{.Worker}}
当前没有正在抓取的任务。
+ +

待抓队列({{.QueuedCount}})

+ + + +{{range .Queued}} + + + +{{end}} +{{if not .Queued}}{{end}} + +
品牌入队时间已等待
{{.BrandName}}{{.Since}}{{.Waiting}}
队列为空。
+ +

最近入库结果

+

最近 50 条。「已存在跳过」是正常状态——说明这场秀之前抓过,没有重复写库。

+ + + +{{range .Results}} + + + + + +{{end}} +{{if not .Results}}{{end}} + +
时间品牌场次结果说明
{{.At}}{{.BrandName}}{{.Title}}{{if .Bad}}{{.Result}}{{else}}{{.Result}}{{end}}{{.Note}}
还没有入库记录。
+
+ +{{end}} diff --git a/internal/handler/templates/partials.html b/internal/handler/templates/partials.html index 465da9f..a048227 100644 --- a/internal/handler/templates/partials.html +++ b/internal/handler/templates/partials.html @@ -18,8 +18,7 @@ 首页 文章{{if gt .PendingReviews 0}}{{.PendingReviews}}{{end}} 品牌 - 抓取任务 - 入库任务 + 爬虫 用户 {{template "barRight" .}} diff --git a/internal/model/crawl_task.go b/internal/model/crawl_task.go index 58c9b66..76a57fb 100644 --- a/internal/model/crawl_task.go +++ b/internal/model/crawl_task.go @@ -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 diff --git a/internal/model/ingest_job.go b/internal/model/ingest_job.go index 7c1cc26..c017120 100644 --- a/internal/model/ingest_job.go +++ b/internal/model/ingest_job.go @@ -34,6 +34,7 @@ const ( IngestOutcomeReused = "reused_rejected" // 复用被驳回的记录并覆盖 IngestOutcomeSkippedExisting = "skipped_existing" // 实体已存在,跳过(不重下图片) IngestOutcomeSkippedDeleted = "skipped_deleted" // 实体已软删,跳过(删除即永久) + IngestOutcomeRepaired = "repaired" // 实体已存在,但补齐了缺失的图片(覆盖率不足时重爬) IngestOutcomeCleaned = "cleaned" // 媒体清理任务 IngestOutcomeNoop = "noop" // 无需处理(空载荷) ) diff --git a/internal/model/runway.go b/internal/model/runway.go index 6907c99..522735a 100644 --- a/internal/model/runway.go +++ b/internal/model/runway.go @@ -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"` diff --git a/internal/repository/crawl_repository.go b/internal/repository/crawl_repository.go index 62c47b2..efb2490 100644 --- a/internal/repository/crawl_repository.go +++ b/internal/repository/crawl_repository.go @@ -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 } diff --git a/internal/repository/ingest_repository.go b/internal/repository/ingest_repository.go index b6669e3..2637a97 100644 --- a/internal/repository/ingest_repository.go +++ b/internal/repository/ingest_repository.go @@ -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/.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 +} diff --git a/internal/router/backstage.go b/internal/router/backstage.go index 3751c33..e8b8111 100644 --- a/internal/router/backstage.go +++ b/internal/router/backstage.go @@ -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) diff --git a/internal/router/backstage_test.go b/internal/router/backstage_test.go index c842789..8bcfba3 100644 --- a/internal/router/backstage_test.go +++ b/internal/router/backstage_test.go @@ -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) } diff --git a/internal/service/ingest_service.go b/internal/service/ingest_service.go index bff42b0..d85f8d7 100644 --- a/internal/service/ingest_service.go +++ b/internal/service/ingest_service.go @@ -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 {