Compare commits
9 Commits
5f1c69ce98
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bc0fce4edd | |||
| e930ac41c8 | |||
| b30f18fc20 | |||
| 51e3d13383 | |||
| db3e3efab9 | |||
| d6ed96624a | |||
| 64b264ee73 | |||
| c8a70760cc | |||
| c483018cc8 |
@ -20,6 +20,9 @@ PG_CONTAINER=1Panel-postgresql-4Ndp
|
|||||||
|
|
||||||
# ---- 后端密钥(必填,缺了容器拒绝启动)----
|
# ---- 后端密钥(必填,缺了容器拒绝启动)----
|
||||||
# 公开 ID 混淆盐:上线后不要再改,改了所有旧链接失效
|
# 公开 ID 混淆盐:上线后不要再改,改了所有旧链接失效
|
||||||
|
# ⚠️ 本地开发后端若连的是生产库(同一套 DB),这个值必须与线上完全一致。
|
||||||
|
# 不一致时本地后端会把 crawl/ingest 里线上编码的 brand_uid 解码成另一个 id,
|
||||||
|
# 任务被判「brand not found」——本地机器于是把生产队列里的任务做成失败。
|
||||||
HASHID_SECRET=
|
HASHID_SECRET=
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
# 爬虫上报 HMAC 密钥:须与爬虫端 config.yml 的 ingest.secret 完全一致
|
# 爬虫上报 HMAC 密钥:须与爬虫端 config.yml 的 ingest.secret 完全一致
|
||||||
|
|||||||
142
cmd/s4migrate/main.go
Normal file
142
cmd/s4migrate/main.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
// Command s4migrate 把历史上兜底存在本地的图片(库里的 `/uploads/...` 路径)
|
||||||
|
// 迁移到 S4,并把库里的路径改写为对象 key。
|
||||||
|
//
|
||||||
|
// 背景:S4 未启用(或上传失败)时,入库会兜底写本地磁盘,库里留下 `/uploads/runway/xxx.jpg`。
|
||||||
|
// 这类路径只有在「nginx 把 /uploads 转发给后端」时才可访问;本项目的原则是
|
||||||
|
// **图片一律进 S4**,所以这里做一次性的归并,不保留本地兜底访问通道。
|
||||||
|
//
|
||||||
|
// 用法(在本机,先把服务器上的容器卷拷下来):
|
||||||
|
//
|
||||||
|
// s4migrate -config configs/config.local.yml -dir ./uploads -dry-run
|
||||||
|
// s4migrate -config configs/config.local.yml -dir ./uploads
|
||||||
|
//
|
||||||
|
// 幂等:已经迁移过的行不再是 `/uploads/` 前缀,重复执行只会处理剩余的。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fashionapi/internal/config"
|
||||||
|
"fashionapi/internal/database"
|
||||||
|
"fashionapi/internal/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := flag.String("config", "", "配置文件路径(默认 configs/config.yml + config.local.yml 叠加)")
|
||||||
|
dir := flag.String("dir", "", "本地 uploads 目录(其下应有 runway/ 与 street/)")
|
||||||
|
dryRun := flag.Bool("dry-run", false, "只打印将要做什么,不写库不上传")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *dir == "" {
|
||||||
|
log.Fatal("必须指定 -dir(本地 uploads 目录)")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.S4.Enabled || cfg.S4.AK == "" {
|
||||||
|
log.Fatal("配置里 S4 未启用或缺少 AK/SK,无法迁移")
|
||||||
|
}
|
||||||
|
db, err := database.New(cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = database.Close(db) }()
|
||||||
|
|
||||||
|
up := storage.NewS4Uploader(cfg.S4.AK, cfg.S4.SK, cfg.S4.Bucket, cfg.S4.Endpoint, cfg.S4.BaseURL, cfg.S4.Region)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
type target struct {
|
||||||
|
table string
|
||||||
|
id uint32
|
||||||
|
image string
|
||||||
|
}
|
||||||
|
var targets []target
|
||||||
|
for _, tbl := range []string{"brand_runway_images", "street_snap_images"} {
|
||||||
|
var rows []struct {
|
||||||
|
ID uint32
|
||||||
|
Image string
|
||||||
|
}
|
||||||
|
if err := db.Raw("SELECT id, image FROM " + tbl +
|
||||||
|
" WHERE is_deleted = 0 AND image LIKE '/uploads/%' ORDER BY id").Scan(&rows).Error; err != nil {
|
||||||
|
log.Fatalf("查询 %s 失败: %v", tbl, err)
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
targets = append(targets, target{table: tbl, id: r.ID, image: r.Image})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("待迁移图片 %d 张(来源 %s)", len(targets), *dir)
|
||||||
|
|
||||||
|
var okCount, failCount, missing int
|
||||||
|
start := time.Now()
|
||||||
|
for i, t := range targets {
|
||||||
|
rel := strings.TrimPrefix(t.image, "/uploads/") // runway/ab12.jpg
|
||||||
|
local := filepath.Join(*dir, filepath.FromSlash(rel))
|
||||||
|
data, rerr := os.ReadFile(local)
|
||||||
|
if rerr != nil {
|
||||||
|
missing++
|
||||||
|
log.Printf(" [缺失] %s/%d %s: %v", t.table, t.id, rel, rerr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *dryRun {
|
||||||
|
log.Printf(" [dry] %s/%d %s (%d 字节)", t.table, t.id, rel, len(data))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 文件名本身就是内容 sha1(LocalUploader 的命名),所以 key 与本地相对路径一致。
|
||||||
|
if _, uerr := up.Upload(ctx, rel, data, contentTypeOf(rel)); uerr != nil {
|
||||||
|
failCount++
|
||||||
|
log.Printf(" [失败] %s/%d %s: %v", t.table, t.id, rel, uerr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := db.Exec("UPDATE "+t.table+" SET image = ? WHERE id = ?", rel, t.id).Error; err != nil {
|
||||||
|
failCount++
|
||||||
|
log.Printf(" [写库失败] %s/%d %s: %v", t.table, t.id, rel, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
okCount++
|
||||||
|
if (i+1)%20 == 0 {
|
||||||
|
log.Printf(" 进度 %d/%d", i+1, len(targets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *dryRun {
|
||||||
|
log.Printf("dry-run 结束:%d 张可迁移,%d 张本地文件缺失", len(targets)-missing, missing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封面同样可能指向 /uploads/:一并改写成 key(否则列表封面仍是死链)。
|
||||||
|
for _, sql := range []string{
|
||||||
|
"UPDATE brand_runways SET cover = replace(cover, '/uploads/', '') WHERE cover LIKE '/uploads/%'",
|
||||||
|
"UPDATE street_snaps SET cover = replace(cover, '/uploads/', '') WHERE cover LIKE '/uploads/%'",
|
||||||
|
} {
|
||||||
|
if err := db.Exec(sql).Error; err != nil {
|
||||||
|
log.Printf(" [封面改写失败] %s: %v", sql, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("完成:成功 %d,失败 %d,本地文件缺失 %d,耗时 %v",
|
||||||
|
okCount, failCount, missing, time.Since(start).Round(time.Second))
|
||||||
|
fmt.Println("提示:若仍有残留(缺失文件),用 SELECT 复核后再手工处理。")
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentTypeOf 按扩展名给 Content-Type,与上传时保持一致。
|
||||||
|
func contentTypeOf(key string) string {
|
||||||
|
switch strings.ToLower(filepath.Ext(key)) {
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp"
|
||||||
|
default:
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -125,7 +125,9 @@ s4:
|
|||||||
bucket: "toomstudio" # 你的缤纷云 S4 桶名
|
bucket: "toomstudio" # 你的缤纷云 S4 桶名
|
||||||
endpoint: "https://s3.bitiful.net"
|
endpoint: "https://s3.bitiful.net"
|
||||||
region: "cn-east-1"
|
region: "cn-east-1"
|
||||||
base_url: "" # 留空自动推导 https://toomstudio.s3.bitiful.net
|
base_url: "http://bitiful.23cm.cn" # CDN 自定义域名(留空则自动推导 https://toomstudio.s3.bitiful.net)
|
||||||
|
# 注意:该域名目前只有 HTTP 证书可用,HTTPS 仍返回网宿默认证书,
|
||||||
|
# 等 CDN 侧签发证书后改成 https://bitiful.23cm.cn 即可(只改这一行)。
|
||||||
style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp
|
style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp
|
||||||
style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display
|
style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display
|
||||||
|
|
||||||
|
|||||||
19
db/migrations/2026-10-25-01-crawl-progress.sql
Normal file
19
db/migrations/2026-10-25-01-crawl-progress.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- 抓取任务的执行进度。
|
||||||
|
--
|
||||||
|
-- 为什么需要:spider 一次抓取的品牌可能有几十场秀、每场几百张图,
|
||||||
|
-- 跑起来几分钟到几小时。此前只有 queued/running/done 三个状态,
|
||||||
|
-- 后台看到「执行中」就再无信息,既判断不了「在干什么」,
|
||||||
|
-- 也判断不了「是慢还是卡死」。
|
||||||
|
--
|
||||||
|
-- progress_at 与 updated_at 分开记:updated_at 会在领取/回报时变,
|
||||||
|
-- 而 progress_at 只有 spider 真的推进了才变——用它判卡死更准。
|
||||||
|
ALTER TABLE crawl_tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS progress_done smallint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS progress_total smallint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS progress_text varchar(160) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS progress_at bigint NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN crawl_tasks.progress_done IS '已处理的秀数量(spider 上报)';
|
||||||
|
COMMENT ON COLUMN crawl_tasks.progress_total IS '本任务计划处理的秀数量(预检过滤后)';
|
||||||
|
COMMENT ON COLUMN crawl_tasks.progress_text IS '当前/最近一场秀的标题,供后台显示「正在抓什么」';
|
||||||
|
COMMENT ON COLUMN crawl_tasks.progress_at IS '最后一次进度心跳的 Unix 秒;用于判断任务是否卡死';
|
||||||
15
db/migrations/2026-10-25-01-ingest-outcome.sql
Normal file
15
db/migrations/2026-10-25-01-ingest-outcome.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- 入库任务的「结果类型」与品牌归属(品牌详情页的更新记录用)。
|
||||||
|
--
|
||||||
|
-- 背景:原来任务只有 status=done,分不清「新建入库」「实体已存在跳过」「实体已删除跳过」,
|
||||||
|
-- 于是「这个品牌这次爬了到底干了什么」只能去翻进程日志,日志一滚就没了。
|
||||||
|
--
|
||||||
|
-- outcome 记录结果类型,note 记录人可读的细节(新建几张图 / 已存在的状态是什么)。
|
||||||
|
ALTER TABLE ingest_jobs ADD COLUMN IF NOT EXISTS outcome varchar(24) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE ingest_jobs ADD COLUMN IF NOT EXISTS note text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- brand_id:提交任务时就解出品牌,品牌详情页按它直接查,不必每次解 payload 里的 hashid。
|
||||||
|
-- 0 表示无品牌(街拍任务)。
|
||||||
|
ALTER TABLE ingest_jobs ADD COLUMN IF NOT EXISTS brand_id bigint NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- 品牌详情页的查询是「某品牌最近 N 条」,按 (brand_id, id DESC) 走索引。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ingest_jobs_brand ON ingest_jobs (brand_id, id DESC);
|
||||||
26
db/migrations/2026-10-25-02-brand-crawl-logs.sql
Normal file
26
db/migrations/2026-10-25-02-brand-crawl-logs.sql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
-- 品牌抓取历史(append-only)。
|
||||||
|
--
|
||||||
|
-- 为什么另建一张表:crawl_tasks 每品牌只有一行(uq_crawl_tasks_brand),
|
||||||
|
-- 入队/完成都是覆盖,答不了「这个品牌什么时候爬过、爬了几次、哪次没抓到」。
|
||||||
|
-- 这里每次 worker 回报写一条,与 ingest_jobs(每场秀一条)拼成完整链路:
|
||||||
|
-- 抓取一次 → 上报 N 场 → 每场入库结果。
|
||||||
|
--
|
||||||
|
-- 量级:每品牌每次抓取 1 行,行宽约 160B(含索引约 200B)。
|
||||||
|
-- 全品牌跑一遍 23237 行 ≈ 4.6MB;按月重爬一年 ≈ 56MB。
|
||||||
|
-- 为防止无限增长,仓库层在写入后只保留每品牌最近 30 条(见 CrawlLogKeepPerBrand)。
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_crawl_logs (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
brand_id bigint NOT NULL,
|
||||||
|
task_id bigint NOT NULL DEFAULT 0,
|
||||||
|
status varchar(16) NOT NULL DEFAULT '', -- 回报的原始状态:done / failed
|
||||||
|
attempt smallint NOT NULL DEFAULT 1, -- 第几次尝试(含自动重试)
|
||||||
|
shows_requested smallint NOT NULL DEFAULT 0, -- 任务上的场次上限
|
||||||
|
shows_submitted integer NOT NULL DEFAULT 0, -- 实际上报了几场
|
||||||
|
message text NOT NULL DEFAULT '', -- spider 回报的错误/说明
|
||||||
|
worker varchar(64) NOT NULL DEFAULT '',
|
||||||
|
started_at bigint NOT NULL DEFAULT 0, -- 领取任务的时间
|
||||||
|
finished_at bigint NOT NULL DEFAULT 0 -- 回报时间
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 品牌详情页:某品牌最近 N 条
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_brand_crawl_logs_brand ON brand_crawl_logs (brand_id, id DESC);
|
||||||
11
db/migrations/2026-10-25-02-runway-expected-images.sql
Normal file
11
db/migrations/2026-10-25-02-runway-expected-images.sql
Normal 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=未知,判定时回落阈值';
|
||||||
15
db/migrations/2026-10-25-03-menswear-season-code.sql
Normal file
15
db/migrations/2026-10-25-03-menswear-season-code.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- 修正男装的 season_code:春/夏男装应为 SSyy,秋/冬男装为 FWyy。
|
||||||
|
--
|
||||||
|
-- 原实现里 menswear 一律返回 FW,于是同年的 Spring/Fall 男装共用 FWyy,
|
||||||
|
-- 而实体键是(brand_id + season_code + collection_type)→ 后到的一场被判「已存在」跳过。
|
||||||
|
-- 实测 AMBUSH:Vogue 25 场,入库 21 场,缺的 4 场全是男装。
|
||||||
|
--
|
||||||
|
-- 只改「春夏男装」(标题以 Spring 开头)的历史行;秋冬男装本来就是 FWyy,保持不变。
|
||||||
|
-- 安全性:同一品牌的春夏男装此前必然与秋冬男装撞键而未入库,
|
||||||
|
-- 因此改成 SSyy 不会与现有行冲突(唯一索引 (brand_id, season_code, collection_type))。
|
||||||
|
UPDATE brand_runways
|
||||||
|
SET season_code = 'SS' || right(year::text, 2)
|
||||||
|
WHERE collection_type = 'menswear'
|
||||||
|
AND season_code LIKE 'FW%'
|
||||||
|
AND year >= 2000
|
||||||
|
AND (title_en LIKE 'Spring%' OR title_en LIKE 'Resort%');
|
||||||
137
docs/superpowers/specs/2026-10-25-spider-console-design.md
Normal file
137
docs/superpowers/specs/2026-10-25-spider-console-design.md
Normal 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` 任务无法在界面上恢复,需改库处理(已确认接受)。
|
||||||
@ -29,6 +29,9 @@ type RunwayIngest struct {
|
|||||||
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
||||||
City string `json:"city"` // 地区/城市(street 用)
|
City string `json:"city"` // 地区/城市(street 用)
|
||||||
PublishedAt uint32 `json:"published_at"` // Vogue 详情页 datePublished 的 Unix 时间戳(秒);0=未抽取。标题缺年份时用于矫正 year
|
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 列表(无细节分组时回退用)
|
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
|
||||||
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
|
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
|
||||||
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。
|
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。
|
||||||
|
|||||||
@ -540,3 +540,8 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||||||
}
|
}
|
||||||
/* 近邻若落在默认隐藏的副图(.cell.det)上,锚点定位后必须可见,否则跳过去是一片空白。 */
|
/* 近邻若落在默认隐藏的副图(.cell.det)上,锚点定位后必须可见,否则跳过去是一片空白。 */
|
||||||
.cell.det:target { display: flex; }
|
.cell.det:target { display: flex; }
|
||||||
|
|
||||||
|
/* 抓取进度:正常更新的进度用灰字;心跳停了(可能卡住)才用警示色。
|
||||||
|
用颜色而不是弹窗,是因为轮询每 4 秒一次,弹窗会变成骚扰。 */
|
||||||
|
.progress { color: #666; font-size: 12px; }
|
||||||
|
.progress.stale { color: #b45309; font-weight: 600; }
|
||||||
|
|||||||
150
internal/handler/backstage_brand_records.go
Normal file
150
internal/handler/backstage_brand_records.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// brandRecordView 是品牌详情页「更新记录」的一行(已格式化成可直接渲染的字符串)。
|
||||||
|
//
|
||||||
|
// 为什么不把原始结构丢给模板:outcome / status 是给机器看的枚举,
|
||||||
|
// 模板里再写一堆 if 既啰嗦又容易漏;这里一次性把「结果」翻译成人话。
|
||||||
|
type brandRecordView struct {
|
||||||
|
TimeText string // 时间(MM-DD HH:MM)
|
||||||
|
Title string // 场次标题(如 Resort 2027)
|
||||||
|
OutcomeText string // 结果(新建 / 已存在跳过 / 失败 / 排队中…)
|
||||||
|
Detail string // 明细(新建 N 张图 / 错误原因)
|
||||||
|
}
|
||||||
|
|
||||||
|
// outcomeText 把结果枚举翻译成中文。空值时回落到任务状态。
|
||||||
|
func outcomeText(outcome, status string) string {
|
||||||
|
switch outcome {
|
||||||
|
case model.IngestOutcomeCreated:
|
||||||
|
return "新建"
|
||||||
|
case model.IngestOutcomeReused:
|
||||||
|
return "复用驳回"
|
||||||
|
case model.IngestOutcomeSkippedExisting:
|
||||||
|
return "已存在·跳过"
|
||||||
|
case model.IngestOutcomeSkippedDeleted:
|
||||||
|
return "已删除·跳过"
|
||||||
|
case model.IngestOutcomeCleaned:
|
||||||
|
return "媒体清理"
|
||||||
|
case model.IngestOutcomeNoop:
|
||||||
|
return "无操作"
|
||||||
|
}
|
||||||
|
// 老数据没有 outcome:只能按状态说清楚
|
||||||
|
switch status {
|
||||||
|
case model.IngestStatusDone:
|
||||||
|
return "完成"
|
||||||
|
case model.IngestStatusFailed:
|
||||||
|
return "失败"
|
||||||
|
case model.IngestStatusProcessing:
|
||||||
|
return "处理中"
|
||||||
|
default:
|
||||||
|
return "排队中"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlStatusText 抓取任务状态 → 中文。
|
||||||
|
func crawlStatusText(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "queued":
|
||||||
|
return "排队中"
|
||||||
|
case "running":
|
||||||
|
return "抓取中"
|
||||||
|
case "done":
|
||||||
|
return "已完成"
|
||||||
|
case "failed":
|
||||||
|
return "失败"
|
||||||
|
case "canceled":
|
||||||
|
return "已取消"
|
||||||
|
default:
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBrandRecordViews 把仓储记录转成模板行;失败的行优先显示错误原因(比 note 更有用)。
|
||||||
|
func buildBrandRecordViews(recs []repository.BrandIngestRecord) []brandRecordView {
|
||||||
|
out := make([]brandRecordView, 0, len(recs))
|
||||||
|
for _, r := range recs {
|
||||||
|
v := brandRecordView{
|
||||||
|
TimeText: time.Unix(int64(r.UpdatedAt), 0).Format("01-02 15:04"),
|
||||||
|
Title: r.Title,
|
||||||
|
OutcomeText: outcomeText(r.Outcome, r.Status),
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.Status == model.IngestStatusFailed && r.LastError != "":
|
||||||
|
v.Detail = "错误:" + truncateRunes(r.LastError, 120)
|
||||||
|
case r.Note != "":
|
||||||
|
v.Detail = r.Note
|
||||||
|
case r.Status == model.IngestStatusPending:
|
||||||
|
v.Detail = "等待 worker 处理"
|
||||||
|
default:
|
||||||
|
v.Detail = "-"
|
||||||
|
}
|
||||||
|
// 重试过的任务标出来:同一场反复失败时,这一列能直接看出「试了几次」
|
||||||
|
if r.Attempts > 0 && r.Status != model.IngestStatusDone {
|
||||||
|
v.Detail += fmt.Sprintf("(第 %d 次尝试)", r.Attempts+1)
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlLogView 是品牌详情页「抓取历史」的一行。
|
||||||
|
type crawlLogView struct {
|
||||||
|
TimeText string // 结束时间
|
||||||
|
StatusText string // 完成 / 失败
|
||||||
|
Detail string // 上报几场(上限几场)/ 错误原因 / 第几次尝试
|
||||||
|
Worker string // 谁跑的(主机名-进程号),排障时能看出是不是野实例
|
||||||
|
Elapsed string // 耗时(有开始时间才算)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCrawlLogViews 把抓取历史转成模板行。
|
||||||
|
func buildCrawlLogViews(logs []model.CrawlLog) []crawlLogView {
|
||||||
|
out := make([]crawlLogView, 0, len(logs))
|
||||||
|
for _, l := range logs {
|
||||||
|
v := crawlLogView{
|
||||||
|
TimeText: time.Unix(int64(l.FinishedAt), 0).Format("01-02 15:04"),
|
||||||
|
StatusText: crawlStatusText(l.Status),
|
||||||
|
Worker: l.Worker,
|
||||||
|
}
|
||||||
|
if l.Status == model.CrawlFailed {
|
||||||
|
v.StatusText = "失败"
|
||||||
|
if l.Message != "" {
|
||||||
|
v.Detail = "错误:" + truncateRunes(l.Message, 100)
|
||||||
|
} else {
|
||||||
|
v.Detail = "错误:(无详情)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v.Detail = fmt.Sprintf("上报 %d 场", l.ShowsSubmitted)
|
||||||
|
if l.ShowsRequested > 0 {
|
||||||
|
v.Detail += fmt.Sprintf("(上限 %d)", l.ShowsRequested)
|
||||||
|
}
|
||||||
|
if l.Message != "" {
|
||||||
|
v.Detail += " · " + truncateRunes(l.Message, 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.Attempt > 1 {
|
||||||
|
v.Detail += fmt.Sprintf(" · 第 %d 次尝试", l.Attempt)
|
||||||
|
}
|
||||||
|
if l.StartedAt > 0 && l.FinishedAt > l.StartedAt {
|
||||||
|
v.Elapsed = fmt.Sprintf("%d 秒", l.FinishedAt-l.StartedAt)
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateRunes 按字符(不是字节)截断,避免把中文切成半个字。
|
||||||
|
func truncateRunes(s string, n int) string {
|
||||||
|
r := []rune(strings.TrimSpace(s))
|
||||||
|
if len(r) <= n {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "…"
|
||||||
|
}
|
||||||
@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
"fashionapi/internal/pkg/pagination"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 本文件收口「品牌抓取任务」的控制面:
|
// 本文件收口「品牌抓取任务」的控制面:
|
||||||
@ -22,21 +21,50 @@ import (
|
|||||||
//
|
//
|
||||||
// 与入库任务(ingest_jobs)的分工见 model.CrawlTask 的注释。
|
// 与入库任务(ingest_jobs)的分工见 model.CrawlTask 的注释。
|
||||||
|
|
||||||
// crawlRowView 列表展示结构(时间已格式化为字符串,模板里没有日期函数)。
|
// 本文件保留:进度格式化(progressLabel / progressStale)、状态文案(crawlStatusLabel /
|
||||||
type crawlRowView struct {
|
// crawlStatusClass,品牌页的「抓取状态」列在用),以及 spider 侧的全部接口。
|
||||||
ID uint32
|
// 原先的列表页视图 crawlRowView 与 CrawlList 已随「抓取任务」页一起下线,
|
||||||
BrandUID string
|
// 页面改由 backstage_spider.go 的「爬虫」页承担。
|
||||||
BrandName string
|
|
||||||
Status string
|
// progressStaleSec 进度心跳超过这个时长没更新,就认为可能卡住了。
|
||||||
StatusLabel string
|
// 一场秀的图片下载在正常情况下是分钟级;10 分钟没有心跳基本可以断定卡住。
|
||||||
MaxShows int16
|
const progressStaleSec = 600
|
||||||
Shows int32
|
|
||||||
Attempts int16
|
// progressLabel 拼出「3/25 场 · 秀标题」。
|
||||||
Worker string
|
//
|
||||||
UpdatedAt string
|
// 只显示数字没意义(不知道在处理哪一场),只显示标题也没意义(不知道还剩多少),
|
||||||
LastError string
|
// 所以两者拼在一起。
|
||||||
CanRetry bool
|
func progressLabel(t model.CrawlTask) string {
|
||||||
CanCancel bool
|
out := ""
|
||||||
|
switch {
|
||||||
|
case t.ProgressTotal > 0:
|
||||||
|
out = fmt.Sprintf("%d/%d 场", t.ProgressDone, t.ProgressTotal)
|
||||||
|
case t.ProgressDone > 0:
|
||||||
|
out = fmt.Sprintf("%d 场", t.ProgressDone)
|
||||||
|
}
|
||||||
|
if t.ProgressText != "" {
|
||||||
|
if out != "" {
|
||||||
|
out += " · "
|
||||||
|
}
|
||||||
|
out += t.ProgressText
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressStale 判断「执行中但心跳停了」。
|
||||||
|
//
|
||||||
|
// 用 progress_at 而不是 updated_at:updated_at 在领取、回报时都会变,
|
||||||
|
// 用它判断会把「刚领取、还没开始抓」误判成卡死。
|
||||||
|
// 还没上报过进度时退化为 locked_at(领取时间)。
|
||||||
|
func progressStale(t model.CrawlTask, now int64) bool {
|
||||||
|
if t.Status != model.CrawlRunning {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
beat := int64(t.ProgressAt)
|
||||||
|
if beat == 0 {
|
||||||
|
beat = int64(t.LockedAt)
|
||||||
|
}
|
||||||
|
return beat > 0 && now-beat > progressStaleSec
|
||||||
}
|
}
|
||||||
|
|
||||||
// crawlStatusClass 把抓取状态映射到后台已有的徽标配色类(admin.css 只有
|
// crawlStatusClass 把抓取状态映射到后台已有的徽标配色类(admin.css 只有
|
||||||
@ -71,158 +99,6 @@ func crawlStatusLabel(s string) string {
|
|||||||
return s
|
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,
|
|
||||||
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),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 组挂载)
|
// spider 服务接口(HMAC 验签,由 router 的 /admin/internal 组挂载)
|
||||||
@ -263,6 +139,77 @@ func (h *BackstageHandler) WorkerTask(c *gin.Context) {
|
|||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkerShowsCovered 返回某品牌「已入库的秀 → 已有图片张数」,供 spider 抓取前预检。
|
||||||
|
//
|
||||||
|
// 为什么需要它:图片 key 是内容 sha1,**不下载就不知道一张图是否已存在**。
|
||||||
|
// 于是重爬会把几百张已下过的图再下一遍(线上实测 Celine 62 分钟、约 600MB,0 新增)。
|
||||||
|
// 这个接口让 spider 在抓之前就知道「这场我已经有了」,整场跳过,一张都不下。
|
||||||
|
//
|
||||||
|
// 请求:GET /admin/internal/shows/covered?brand_uid=<hashid>
|
||||||
|
// 响应:{"items":[{"year":2020,"season":"fall","collection_type":"rtw","images":415}]}
|
||||||
|
func (h *BackstageHandler) WorkerShowsCovered(c *gin.Context) {
|
||||||
|
if h.crawl == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crawl repository not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := strings.TrimSpace(c.Query("brand_uid"))
|
||||||
|
brandID, err := hashid.Decode(uid)
|
||||||
|
if err != nil || brandID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "brand_uid 非法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
coverage, err := h.crawl.ShowCoverage(c.Request.Context(), brandID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query coverage failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(coverage))
|
||||||
|
for k, st := range coverage {
|
||||||
|
items = append(items, gin.H{
|
||||||
|
"year": k.Year,
|
||||||
|
"season": k.Season,
|
||||||
|
"collection_type": k.CollectionType,
|
||||||
|
"images": st.Images,
|
||||||
|
// expected=0 表示历史数据没有记录应有张数,spider 会退化为按张数阈值判断。
|
||||||
|
"expected": st.Expected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerProgress 接收 spider 的执行进度(每处理完一场秀上报一次)。
|
||||||
|
//
|
||||||
|
// 请求:POST /admin/internal/crawl/tasks/:id/progress
|
||||||
|
//
|
||||||
|
// {"done": 3, "total": 25, "text": "Fall 2020 Ready-to-Wear"}
|
||||||
|
//
|
||||||
|
// 回报失败不影响抓取:进度是观测信息,丢了只是后台显示旧一点。
|
||||||
|
func (h *BackstageHandler) WorkerProgress(c *gin.Context) {
|
||||||
|
if h.crawl == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crawl repository not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := atoiDefault(c.Param("id"), 0)
|
||||||
|
if id <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "任务 ID 非法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Done int `json:"done"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.crawl.UpdateProgress(c.Request.Context(), uint32(id), body.Done, body.Total, body.Text); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "update progress failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// WorkerUploadURL 给媒体机签发 S4 直传 URL(PUT 预签名)。
|
// WorkerUploadURL 给媒体机签发 S4 直传 URL(PUT 预签名)。
|
||||||
//
|
//
|
||||||
// 设计取舍:不在 spider 里配 AK/SK、也不引入 S3 SDK —— 密钥只留在后端,
|
// 设计取舍:不在 spider 里配 AK/SK、也不引入 S3 SDK —— 密钥只留在后端,
|
||||||
|
|||||||
@ -273,14 +273,40 @@ func (h *BackstageHandler) BrandCreate(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/admin/brands")
|
c.Redirect(http.StatusFound, "/admin/brands")
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrandEdit 渲染品牌编辑表单(按编码 id 取现有值)。
|
// BrandEdit 渲染品牌编辑表单(按编码 id 取现有值)+ 该品牌的更新记录。
|
||||||
|
//
|
||||||
|
// 更新记录放在这里而不是单独做页面:品牌是这件事的主体,人打开一个品牌
|
||||||
|
// 想看的正是「它最近几次抓取/入库到底做了什么」,跳出去看列表反而多一步。
|
||||||
func (h *BackstageHandler) BrandEdit(c *gin.Context) {
|
func (h *BackstageHandler) BrandEdit(c *gin.Context) {
|
||||||
b, err := h.brand.Get(c.Request.Context(), c.Param("id"))
|
uid := c.Param("id")
|
||||||
|
b, err := h.brand.Get(c.Request.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "品牌不存在或已删除: " + c.Param("id")})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "品牌不存在或已删除: " + uid})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
renderPage(c, http.StatusOK, "brand-form", gin.H{"UID": c.Param("id"), "NameEn": b.NameEn, "NameCn": b.NameCn})
|
data := gin.H{"UID": uid, "NameEn": b.NameEn, "NameCn": b.NameCn}
|
||||||
|
|
||||||
|
if brandID, derr := hashid.Decode(uid); derr == nil && brandID > 0 {
|
||||||
|
// 入库记录:只有状态 + 结果 + 明细,不拉 payload(可能几十 KB)
|
||||||
|
if h.ingest != nil {
|
||||||
|
if recs, rerr := h.ingest.ListBrandRecords(c.Request.Context(), brandID, 30); rerr == nil {
|
||||||
|
data["Records"] = buildBrandRecordViews(recs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 抓取侧:最近一次状态(摘要)+ 抓取历史(brand_crawl_logs,每次一条)
|
||||||
|
if h.crawl != nil {
|
||||||
|
if states, serr := h.crawl.BrandCrawlStates(c.Request.Context(), []uint32{brandID}); serr == nil {
|
||||||
|
if st, ok := states[brandID]; ok {
|
||||||
|
data["CrawlStatus"] = crawlStatusText(st.Status)
|
||||||
|
data["LastCrawl"] = st.UpdatedAt.Format("01-02 15:04")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logs, lerr := h.crawl.ListCrawlLogs(c.Request.Context(), brandID, 20); lerr == nil {
|
||||||
|
data["CrawlLogs"] = buildCrawlLogViews(logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderPage(c, http.StatusOK, "brand-form", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrandUpdate 处理品牌编辑表单。
|
// BrandUpdate 处理品牌编辑表单。
|
||||||
@ -750,96 +776,6 @@ func (h *BackstageHandler) SuggestTitleCn(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"title_cn": cn})
|
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)兜底成字符串。
|
// usernameString 把中间件注入的 username(any)兜底成字符串。
|
||||||
func usernameString(v any) string {
|
func usernameString(v any) string {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
|
|||||||
@ -28,14 +28,13 @@ func TestBackstageTemplatesParsed(t *testing.T) {
|
|||||||
"articles",
|
"articles",
|
||||||
"brand-form",
|
"brand-form",
|
||||||
"brands",
|
"brands",
|
||||||
"crawl",
|
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"error",
|
"error",
|
||||||
"ingest-jobs",
|
|
||||||
"login",
|
"login",
|
||||||
"runway-detail",
|
"runway-detail",
|
||||||
"runway-edit",
|
"runway-edit",
|
||||||
"snap-edit",
|
"snap-edit",
|
||||||
|
"spider",
|
||||||
"street-snap-detail",
|
"street-snap-detail",
|
||||||
"users",
|
"users",
|
||||||
}
|
}
|
||||||
@ -74,8 +73,10 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
|||||||
"Images": []any{},
|
"Images": []any{},
|
||||||
// 列表数据
|
// 列表数据
|
||||||
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
|
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
|
||||||
// 抓取任务页(crawl.html):列表 + 四个状态计数
|
// 爬虫页(spider.html):状态条 + 正在抓 + 待抓队列 + 最近入库结果
|
||||||
"Rows": []any{}, "Queued": int64(0), "Running": int64(0), "Done": int64(0), "Failed": int64(0),
|
"State": "空闲", "StateClass": "done", "RunningCount": 0, "QueuedCount": 0,
|
||||||
|
"QueuedShown": 0, "QueuedPreview": 50, "PendingIngest": int64(0),
|
||||||
|
"Running": []spiderRunView{}, "Queued": []spiderQueueView{}, "Results": []spiderResultView{},
|
||||||
// 品牌页的「抓取状态 / 上次抓取」列(按 hashid 索引)
|
// 品牌页的「抓取状态 / 上次抓取」列(按 hashid 索引)
|
||||||
"CrawlStates": map[string]any{}, "Stats": map[string]any{}, "Added": 0,
|
"CrawlStates": map[string]any{}, "Stats": map[string]any{}, "Added": 0,
|
||||||
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
|
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
|
||||||
|
|||||||
312
internal/handler/backstage_spider.go
Normal file
312
internal/handler/backstage_spider.go
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队列总数必须走 Counts(SELECT count(*) GROUP BY status),不能用 len(queues):
|
||||||
|
// ListActive 有 LIMIT,队列上千时 len 会永远停在 200 附近,
|
||||||
|
// 看起来像「一直抓不完」,其实是数字被截断了。
|
||||||
|
counts, _ := h.crawl.Counts(ctx)
|
||||||
|
|
||||||
|
// 表格只铺前 50 个,避免一次渲染几百行拖慢页面;真实总数用 count 展示。
|
||||||
|
const queuedPreview = 50
|
||||||
|
shown := queues
|
||||||
|
if len(shown) > queuedPreview {
|
||||||
|
shown = shown[:queuedPreview]
|
||||||
|
}
|
||||||
|
|
||||||
|
state, class := spiderState(active, now)
|
||||||
|
renderPage(c, http.StatusOK, "spider", gin.H{
|
||||||
|
"State": state,
|
||||||
|
"StateClass": class,
|
||||||
|
"RunningCount": int(counts[model.CrawlRunning]),
|
||||||
|
"QueuedCount": int(counts[model.CrawlQueued]),
|
||||||
|
"QueuedShown": len(shown),
|
||||||
|
"QueuedPreview": queuedPreview,
|
||||||
|
"PendingIngest": pending,
|
||||||
|
"Running": runs,
|
||||||
|
"Queued": shown,
|
||||||
|
"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, _ := 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
|
||||||
|
}
|
||||||
|
// 同 SpiderBoard:队列数走 count(*),不要用 len(queues)(受 LIMIT 截断)。
|
||||||
|
counts, _ := h.crawl.Counts(ctx)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"state": state,
|
||||||
|
"state_class": class,
|
||||||
|
"running": items,
|
||||||
|
"queued": counts[model.CrawlQueued],
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
internal/handler/spider_render_test.go
Normal file
89
internal/handler/spider_render_test.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
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,
|
||||||
|
"QueuedShown": 2,
|
||||||
|
"QueuedPreview": 50,
|
||||||
|
"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 徽标")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队列被截断时要说清楚:否则「队列 1620 个」配一张 50 行的表会让人以为数据错了。
|
||||||
|
data["QueuedCount"] = 1620
|
||||||
|
data["QueuedShown"] = 50
|
||||||
|
var buf2 bytes.Buffer
|
||||||
|
if err := tpl.ExecuteTemplate(&buf2, "layout", data); err != nil {
|
||||||
|
t.Fatalf("渲染截断提示失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf2.String(), "只列前 50 个") {
|
||||||
|
t.Error("队列被截断时缺少提示")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,5 +11,47 @@
|
|||||||
<label>中文名(name_cn)</label><input name="name_cn" value="{{.NameCn}}" placeholder="如 艾克妮">
|
<label>中文名(name_cn)</label><input name="name_cn" value="{{.NameCn}}" placeholder="如 艾克妮">
|
||||||
<button type="submit">{{if .UID}}保存{{else}}创建{{end}}</button>
|
<button type="submit">{{if .UID}}保存{{else}}创建{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{{if .UID}}
|
||||||
|
<h2 style="margin-top:32px">抓取历史</h2>
|
||||||
|
<p class="muted">每次抓取一条(每品牌保留最近 30 条)。这一层回答「什么时候爬的、爬了几场、哪次没抓到」。</p>
|
||||||
|
{{if .CrawlLogs}}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>时间</th><th>结果</th><th>明细</th><th>耗时</th><th>执行者</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .CrawlLogs}}<tr>
|
||||||
|
<td>{{.TimeText}}</td>
|
||||||
|
<td>{{.StatusText}}</td>
|
||||||
|
<td>{{.Detail}}</td>
|
||||||
|
<td>{{if .Elapsed}}{{.Elapsed}}{{else}}-{{end}}</td>
|
||||||
|
<td><code>{{if .Worker}}{{.Worker}}{{else}}-{{end}}</code></td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">还没有抓取记录。把品牌加入抓取队列后,每次抓取(抓到几场 / 失败原因 / 谁跑的)都会出现在这里。</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2 style="margin-top:32px">更新记录</h2>
|
||||||
|
<p class="muted">
|
||||||
|
{{if .LastCrawl}}最近抓取:{{.LastCrawl}} · {{.CrawlStatus}}{{else}}还没有抓取记录{{end}}
|
||||||
|
(只显示最近 30 条入库记录;抓取侧每品牌只保留最近一次的状态)
|
||||||
|
</p>
|
||||||
|
{{if .Records}}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>时间</th><th>场次</th><th>结果</th><th>明细</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Records}}<tr>
|
||||||
|
<td>{{.TimeText}}</td>
|
||||||
|
<td>{{if .Title}}{{.Title}}{{else}}-{{end}}</td>
|
||||||
|
<td>{{.OutcomeText}}</td>
|
||||||
|
<td>{{.Detail}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">还没有入库记录。把品牌加入抓取队列后,每一场的处理结果(新建 / 已存在跳过 / 失败原因)都会出现在这里。</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -32,17 +32,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* 批量操作表单:勾选框用 form="bulk" 关联,避免与行内表单嵌套 */}}
|
{{/* 批量操作表单:勾选框用 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">
|
<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>
|
<button type="submit" form="bulk" class="op">把勾选的品牌加入抓取队列</button>
|
||||||
<span class="muted">(勾选本页任意行,先点复选框列头可全选本页)</span>
|
<span class="muted">
|
||||||
|
(勾选本页任意行,先点复选框列头可全选本页。抓取一律全量:
|
||||||
|
已抓全的场次会被自动跳过,不用担心重复下载。)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
@ -62,8 +58,8 @@
|
|||||||
<td class="muted">{{if $cs}}{{$cs.LastAt}}{{else}}—{{end}}</td>
|
<td class="muted">{{if $cs}}{{$cs.LastAt}}{{else}}—{{end}}</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="view" href="/admin/brands/{{.UID}}">编辑</a>
|
<a class="view" href="/admin/brands/{{.UID}}">编辑</a>
|
||||||
<form class="inline" method="post" action="/admin/crawl/brands/{{.UID}}" onsubmit="return confirm('把「{{.NameEn}}」加入抓取队列?默认只抓最近 3 场。')">
|
<form class="inline" method="post" action="/admin/spider/brands/{{.UID}}" onsubmit="return confirm('把「{{.NameEn}}」加入抓取队列?(全量,已抓全的场次自动跳过)')">
|
||||||
<input type="hidden" name="back" value="/admin/brands"><input type="hidden" name="maxShows" value="3">
|
<input type="hidden" name="back" value="/admin/brands">
|
||||||
<button type="submit" class="op">加入队列</button>
|
<button type="submit" class="op">加入队列</button>
|
||||||
</form>
|
</form>
|
||||||
<form class="inline" method="post" action="/admin/brands/{{.UID}}/delete" onsubmit="return confirm('确认删除品牌「{{.NameEn}}」?该品牌下若还有图集会拒绝删除。')">
|
<form class="inline" method="post" action="/admin/brands/{{.UID}}/delete" onsubmit="return confirm('确认删除品牌「{{.NameEn}}」?该品牌下若还有图集会拒绝删除。')">
|
||||||
|
|||||||
@ -1,44 +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>Worker</th><th>更新时间</th><th>错误</th><th>操作</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Rows}}<tr>
|
|
||||||
<td><code>{{.ID}}</code></td>
|
|
||||||
<td><a class="view" href="/admin/brands/{{.BrandUID}}">{{.BrandName}}</a></td>
|
|
||||||
<td><span class="badge">{{.StatusLabel}}</span></td>
|
|
||||||
<td>{{if eq .MaxShows 0}}<strong>全量</strong>{{else}}{{.MaxShows}}{{end}}</td>
|
|
||||||
<td>{{.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>
|
|
||||||
{{end}}
|
|
||||||
@ -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}}
|
|
||||||
135
internal/handler/templates/pages/spider.html
Normal file
135
internal/handler/templates/pages/spider.html
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
{{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>
|
||||||
|
{{if gt .QueuedCount .QueuedShown}}<p class="muted">队列里共 {{.QueuedCount}} 个,下面只列前 {{.QueuedShown}} 个。</p>{{end}}
|
||||||
|
<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}}
|
||||||
@ -18,8 +18,7 @@
|
|||||||
<a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a>
|
<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/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/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/spider" class="{{if eq .NavKey "spider"}}on{{end}}">爬虫</a>
|
||||||
<a href="/admin/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a>
|
|
||||||
<a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a>
|
<a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
{{template "barRight" .}}
|
{{template "barRight" .}}
|
||||||
|
|||||||
30
internal/model/crawl_log.go
Normal file
30
internal/model/crawl_log.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// CrawlLogKeepPerBrand 每个品牌保留的抓取历史条数。
|
||||||
|
//
|
||||||
|
// 抓取历史是「最近发生了什么」的排障材料,不是审计归档;留太多只会让查询变慢。
|
||||||
|
// 30 条足够覆盖「最近一次批量重爬 + 若干次手动重试」。
|
||||||
|
const CrawlLogKeepPerBrand = 30
|
||||||
|
|
||||||
|
// CrawlLog 品牌抓取历史的一行(append-only)。
|
||||||
|
//
|
||||||
|
// 与 ingest_jobs 的分工:
|
||||||
|
// - CrawlLog 记录「这一次抓取」的进程级结果(抓到几场、报了什么错、谁跑的);
|
||||||
|
// - ingest_jobs 记录「每一场秀」的入库结果(新建 / 跳过 / 失败)。
|
||||||
|
// 两者按时间顺序摆在品牌详情页,就是完整的更新链路。
|
||||||
|
type CrawlLog struct {
|
||||||
|
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||||
|
BrandID uint32 `gorm:"column:brand_id;not null" json:"brand_id"`
|
||||||
|
TaskID uint32 `gorm:"column:task_id;not null;default:0" json:"task_id"`
|
||||||
|
Status string `gorm:"column:status;size:16;not null;default:''" json:"status"` // done / failed
|
||||||
|
Attempt int16 `gorm:"column:attempt;not null;default:1" json:"attempt"`
|
||||||
|
ShowsRequested int16 `gorm:"column:shows_requested;not null;default:0" json:"shows_requested"`
|
||||||
|
ShowsSubmitted int32 `gorm:"column:shows_submitted;not null;default:0" json:"shows_submitted"`
|
||||||
|
Message string `gorm:"column:message;type:text;not null;default:''" json:"message"`
|
||||||
|
Worker string `gorm:"column:worker;size:64;not null;default:''" json:"worker"`
|
||||||
|
StartedAt uint32 `gorm:"column:started_at;not null;default:0" json:"started_at"`
|
||||||
|
FinishedAt uint32 `gorm:"column:finished_at;not null;default:0" json:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名。
|
||||||
|
func (CrawlLog) TableName() string { return "brand_crawl_logs" }
|
||||||
@ -16,9 +16,13 @@ const (
|
|||||||
CrawlCanceled = "canceled" // 后台手动取消
|
CrawlCanceled = "canceled" // 后台手动取消
|
||||||
)
|
)
|
||||||
|
|
||||||
// CrawlStuckTimeoutSec 是 running 任务的租约时长:超过该时长仍未回报,
|
// CrawlStuckTimeoutSec 是 running 任务的「静默时长」阈值:领取后这么久既没回报、
|
||||||
// 视为 worker 已崩溃(断电 / 被杀),任务可被重新领取,避免永久卡在 running。
|
// 也没有进度心跳,才视为 worker 已崩溃(断电 / 被杀),任务可被重新领取。
|
||||||
const CrawlStuckTimeoutSec = 900
|
//
|
||||||
|
// 300 秒而不是更长:卡住后 5 分钟内自动回收,不必人工干预。
|
||||||
|
// 判活同时看 progress_at(spider 每 60 秒打一次心跳)——只看 locked_at 会把
|
||||||
|
// 跑了十几分钟的正常任务误判成卡死(一次全量抓取可能要几个小时)。
|
||||||
|
const CrawlStuckTimeoutSec = 300
|
||||||
|
|
||||||
// CrawlMaxAttempts 自动重试上限;达到后置 failed,需后台手动「重试」。
|
// CrawlMaxAttempts 自动重试上限;达到后置 failed,需后台手动「重试」。
|
||||||
const CrawlMaxAttempts = 3
|
const CrawlMaxAttempts = 3
|
||||||
@ -43,8 +47,14 @@ type CrawlTask struct {
|
|||||||
// ShowsSubmitted 本任务向 ingest 提交了多少场走秀(spider 回报)。
|
// ShowsSubmitted 本任务向 ingest 提交了多少场走秀(spider 回报)。
|
||||||
ShowsSubmitted int32 `gorm:"column:shows_submitted;not null;default:0" json:"shows_submitted"`
|
ShowsSubmitted int32 `gorm:"column:shows_submitted;not null;default:0" json:"shows_submitted"`
|
||||||
LastError string `gorm:"column:last_error;type:text" json:"last_error"`
|
LastError string `gorm:"column:last_error;type:text" json:"last_error"`
|
||||||
CreatedAt uint32 `gorm:"column:created_at;not null;default:0" json:"created_at"`
|
// 进度:spider 每处理完一场秀上报一次。
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at;not null;default:0" json:"updated_at"`
|
// 一次抓取可能跑几十分钟,没有这四项后台只能看到「执行中」,分不清慢和卡死。
|
||||||
|
ProgressDone int16 `gorm:"column:progress_done;not null;default:0" json:"progress_done"`
|
||||||
|
ProgressTotal int16 `gorm:"column:progress_total;not null;default:0" json:"progress_total"`
|
||||||
|
ProgressText string `gorm:"column:progress_text;size:160;not null;default:''" json:"progress_text"`
|
||||||
|
ProgressAt uint32 `gorm:"column:progress_at;not null;default:0" json:"progress_at"`
|
||||||
|
CreatedAt uint32 `gorm:"column:created_at;not null;default:0" json:"created_at"`
|
||||||
|
UpdatedAt uint32 `gorm:"column:updated_at;not null;default:0" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名。
|
// TableName 指定表名。
|
||||||
|
|||||||
@ -19,8 +19,26 @@ type IngestJob struct {
|
|||||||
LastError string `gorm:"column:last_error;type:text" json:"last_error"`
|
LastError string `gorm:"column:last_error;type:text" json:"last_error"`
|
||||||
Kind string `gorm:"column:kind;size:16;not null;default:'crawl'" json:"kind"` // crawl|media_cleanup
|
Kind string `gorm:"column:kind;size:16;not null;default:'crawl'" json:"kind"` // crawl|media_cleanup
|
||||||
NextAttemptAt uint32 `gorm:"column:next_attempt_at;not null;default:0" json:"next_attempt_at"` // 重试退避:到达该 unix 时间才可被领取
|
NextAttemptAt uint32 `gorm:"column:next_attempt_at;not null;default:0" json:"next_attempt_at"` // 重试退避:到达该 unix 时间才可被领取
|
||||||
|
// Outcome 结果类型(见 IngestOutcome* 常量),Note 是人可读的细节。
|
||||||
|
// 只有 done 状态才有意义:同为 done,「新建 42 张图」和「实体已存在跳过」是完全不同的事,
|
||||||
|
// 后台的「品牌更新记录」靠这两个字段说清楚每次到底做了什么。
|
||||||
|
Outcome string `gorm:"column:outcome;size:24;not null;default:''" json:"outcome"`
|
||||||
|
Note string `gorm:"column:note;type:text;not null;default:''" json:"note"`
|
||||||
|
// BrandID 提交时就解出的品牌 id(街拍为 0),供品牌详情页直接按品牌查更新记录。
|
||||||
|
BrandID uint32 `gorm:"column:brand_id;not null;default:0" json:"brand_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 入库结果类型(ingest_jobs.outcome)。
|
||||||
|
const (
|
||||||
|
IngestOutcomeCreated = "created" // 新建入站
|
||||||
|
IngestOutcomeReused = "reused_rejected" // 复用被驳回的记录并覆盖
|
||||||
|
IngestOutcomeSkippedExisting = "skipped_existing" // 实体已存在,跳过(不重下图片)
|
||||||
|
IngestOutcomeSkippedDeleted = "skipped_deleted" // 实体已软删,跳过(删除即永久)
|
||||||
|
IngestOutcomeRepaired = "repaired" // 实体已存在,但补齐了缺失的图片(覆盖率不足时重爬)
|
||||||
|
IngestOutcomeCleaned = "cleaned" // 媒体清理任务
|
||||||
|
IngestOutcomeNoop = "noop" // 无需处理(空载荷)
|
||||||
|
)
|
||||||
|
|
||||||
// TableName 指定队列表名。
|
// TableName 指定队列表名。
|
||||||
func (IngestJob) TableName() string { return "ingest_jobs" }
|
func (IngestJob) TableName() string { return "ingest_jobs" }
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,11 @@ type BrandRunway struct {
|
|||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
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"`
|
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
|
||||||
Year uint16 `gorm:"column:year" json:"year"`
|
Year uint16 `gorm:"column:year" json:"year"`
|
||||||
Cover string `gorm:"column:cover" json:"cover"`
|
Cover string `gorm:"column:cover" json:"cover"`
|
||||||
|
|||||||
@ -46,6 +46,16 @@ func prefixOf(collectionType, season string) string {
|
|||||||
// 高定沿用秋冬编码(FW),与排序优先级一致
|
// 高定沿用秋冬编码(FW),与排序优先级一致
|
||||||
return "FW"
|
return "FW"
|
||||||
case ColMenswear:
|
case ColMenswear:
|
||||||
|
// 男装必须按真实季节区分:曾经一律返回 FW,导致同年的
|
||||||
|
// 「Spring 2021 Menswear」与「Fall 2021 Menswear」推出同一个 season_code(FW21),
|
||||||
|
// 而实体键是(品牌 + season_code + 系列),于是后到的那场被判「已存在」静默丢弃。
|
||||||
|
// 实测 AMBUSH:Vogue 25 场只入库 21 场,缺的 4 场全是男装。
|
||||||
|
switch season {
|
||||||
|
case SeasonSpring:
|
||||||
|
return "SS"
|
||||||
|
case SeasonFall:
|
||||||
|
return "FW"
|
||||||
|
}
|
||||||
return "FW"
|
return "FW"
|
||||||
}
|
}
|
||||||
switch season {
|
switch season {
|
||||||
|
|||||||
@ -35,9 +35,39 @@ type CrawlRepository interface {
|
|||||||
Delete(ctx context.Context, id uint32) error
|
Delete(ctx context.Context, id uint32) error
|
||||||
// Counts 各状态任务数,供导航角标使用。
|
// Counts 各状态任务数,供导航角标使用。
|
||||||
Counts(ctx context.Context) (map[string]int64, error)
|
Counts(ctx context.Context) (map[string]int64, error)
|
||||||
|
// UpdateProgress 记录执行进度(spider 每处理完一场秀上报一次)。
|
||||||
|
// 只对 running 生效:任务已被取消/回收时,迟到的进度不应把状态写回去。
|
||||||
|
UpdateProgress(ctx context.Context, id uint32, done, total int, text string) error
|
||||||
|
// ListActive 取 running + queued 的任务(后台进度轮询用)。
|
||||||
|
ListActive(ctx context.Context) ([]model.CrawlTaskRow, error)
|
||||||
// BrandCrawlStates 返回这些品牌在抓取队列里的「最近状态 + 最近时间」。
|
// BrandCrawlStates 返回这些品牌在抓取队列里的「最近状态 + 最近时间」。
|
||||||
// 品牌列表用它展示「抓取状态 / 上次抓取」;没有任务记录的品牌不会出现在 map 里。
|
// 品牌列表用它展示「抓取状态 / 上次抓取」;没有任务记录的品牌不会出现在 map 里。
|
||||||
BrandCrawlStates(ctx context.Context, brandIDs []uint32) (map[uint32]BrandCrawlState, error)
|
BrandCrawlStates(ctx context.Context, brandIDs []uint32) (map[uint32]BrandCrawlState, error)
|
||||||
|
// ListCrawlLogs 取某品牌最近的抓取历史(id 倒序),品牌详情页展示用。
|
||||||
|
//
|
||||||
|
// crawl_tasks 每品牌只有一行且会被覆盖,历史只存在 brand_crawl_logs 里。
|
||||||
|
ListCrawlLogs(ctx context.Context, brandID uint32, limit int) ([]model.CrawlLog, error)
|
||||||
|
// ShowCoverage 返回某品牌「已入库的秀 → 覆盖率」,键是实体键(年份+季节+类型)。
|
||||||
|
//
|
||||||
|
// 用途:spider 抓取前预检,整场跳过已经抓全的秀。
|
||||||
|
// 没有它,重爬会把几百张已经下过的图再下一遍——图片 key 是内容 sha1,
|
||||||
|
// 不下载就无法知道它其实已经存在,带宽全白烧(线上实测 62 分钟 0 新增)。
|
||||||
|
ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]ShowCoverageStat, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowCoverageStat 一场秀的图片覆盖情况。
|
||||||
|
//
|
||||||
|
// Expected 为 0 表示未知(该字段上线前入库的历史数据),调用方应退化为按张数阈值判断。
|
||||||
|
type ShowCoverageStat struct {
|
||||||
|
Images int // 库里实际图片张数
|
||||||
|
Expected int // 应有张数(spider 上报,0=未知)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowKey 一场秀的业务主键,与 ingest 的实体键一致(品牌 + 年份 + 季节 + 类型)。
|
||||||
|
type ShowKey struct {
|
||||||
|
Year uint16
|
||||||
|
Season string
|
||||||
|
CollectionType string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrandCrawlState 品牌最近一次抓取任务的状态快照。
|
// BrandCrawlState 品牌最近一次抓取任务的状态快照。
|
||||||
@ -56,7 +86,9 @@ func NewCrawlRepository(db *gorm.DB) CrawlRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const crawlColumns = `t.id, t.brand_id, t.status, t.priority, t.max_shows, t.worker,
|
const crawlColumns = `t.id, t.brand_id, t.status, t.priority, t.max_shows, t.worker,
|
||||||
t.locked_at, t.attempts, t.shows_submitted, t.last_error, t.created_at, t.updated_at,
|
t.locked_at, t.attempts, t.shows_submitted, t.last_error,
|
||||||
|
t.progress_done, t.progress_total, t.progress_text, t.progress_at,
|
||||||
|
t.created_at, t.updated_at,
|
||||||
coalesce(b.name_en, '') AS brand_name`
|
coalesce(b.name_en, '') AS brand_name`
|
||||||
|
|
||||||
func (r *crawlRepository) Enqueue(ctx context.Context, brandID uint32, maxShows int, priority int) (bool, error) {
|
func (r *crawlRepository) Enqueue(ctx context.Context, brandID uint32, maxShows int, priority int) (bool, error) {
|
||||||
@ -184,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 {
|
if now > model.CrawlStuckTimeoutSec {
|
||||||
|
cutoff := now - model.CrawlStuckTimeoutSec
|
||||||
if err := tx.Exec(`
|
if err := tx.Exec(`
|
||||||
UPDATE crawl_tasks
|
UPDATE crawl_tasks
|
||||||
SET status = 'queued', locked_at = 0, worker = '', updated_at = ?
|
SET status = 'queued', locked_at = 0, worker = '', updated_at = ?
|
||||||
WHERE status = 'running' AND locked_at > 0 AND locked_at < ?`,
|
WHERE status = 'running' AND locked_at > 0
|
||||||
now, now-model.CrawlStuckTimeoutSec).Error; err != nil {
|
AND locked_at < ? AND coalesce(progress_at, 0) < ?`,
|
||||||
|
now, cutoff, cutoff).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -244,7 +282,7 @@ func (r *crawlRepository) Report(ctx context.Context, id uint32, status string,
|
|||||||
if status == model.CrawlFailed && cur.Attempts < model.CrawlMaxAttempts {
|
if status == model.CrawlFailed && cur.Attempts < model.CrawlMaxAttempts {
|
||||||
next = model.CrawlQueued
|
next = model.CrawlQueued
|
||||||
}
|
}
|
||||||
return tx.Model(&model.CrawlTask{}).Where("id = ?", id).
|
if err := tx.Model(&model.CrawlTask{}).Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": next,
|
"status": next,
|
||||||
"shows_submitted": shows,
|
"shows_submitted": shows,
|
||||||
@ -252,7 +290,33 @@ func (r *crawlRepository) Report(ctx context.Context, id uint32, status string,
|
|||||||
"locked_at": 0,
|
"locked_at": 0,
|
||||||
"worker": "",
|
"worker": "",
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}).Error
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同事务写一条抓取历史:crawl_tasks 每品牌只有一行会被覆盖,
|
||||||
|
// 不在这里留痕就永远查不到「上次爬是什么时候、抓到几场」。
|
||||||
|
log := model.CrawlLog{
|
||||||
|
BrandID: cur.BrandID,
|
||||||
|
TaskID: cur.ID,
|
||||||
|
Status: status,
|
||||||
|
Attempt: cur.Attempts + 1,
|
||||||
|
ShowsRequested: cur.MaxShows,
|
||||||
|
ShowsSubmitted: int32(shows),
|
||||||
|
Message: errMsg,
|
||||||
|
Worker: cur.Worker,
|
||||||
|
StartedAt: cur.LockedAt,
|
||||||
|
FinishedAt: now,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&log).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只保留该品牌最近 N 条:历史用于排障,不是归档,避免无限增长。
|
||||||
|
return tx.Exec(
|
||||||
|
"DELETE FROM brand_crawl_logs WHERE brand_id = ? AND id < "+
|
||||||
|
"(SELECT id FROM brand_crawl_logs WHERE brand_id = ? ORDER BY id DESC OFFSET ? LIMIT 1)",
|
||||||
|
cur.BrandID, cur.BrandID, model.CrawlLogKeepPerBrand).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,6 +341,46 @@ func (r *crawlRepository) List(ctx context.Context, offset, limit int) ([]model.
|
|||||||
return rows, total, nil
|
return rows, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateProgress 写入进度心跳。
|
||||||
|
//
|
||||||
|
// 用 `AND status = 'running'` 而不是无条件更新:
|
||||||
|
// 后台可能刚把任务取消,此时迟到的进度不该把它写活,
|
||||||
|
// 也不该刷新 updated_at 掩盖「已经被取消」的事实。
|
||||||
|
func (r *crawlRepository) UpdateProgress(ctx context.Context, id uint32, done, total int, text string) error {
|
||||||
|
if done < 0 {
|
||||||
|
done = 0
|
||||||
|
}
|
||||||
|
if total < 0 {
|
||||||
|
total = 0
|
||||||
|
}
|
||||||
|
if len(text) > 160 {
|
||||||
|
text = text[:160]
|
||||||
|
}
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
return r.db.WithContext(ctx).Exec(
|
||||||
|
`UPDATE crawl_tasks
|
||||||
|
SET progress_done = ?, progress_total = ?, progress_text = ?,
|
||||||
|
progress_at = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'running'`,
|
||||||
|
done, total, text, now, now, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActive 取正在跑与排队中的任务,供后台进度轮询(全量返回,不分页)。
|
||||||
|
// 只取 runnning/queued:已完成的任务进度不会再变,没必要每 4 秒重传一遍。
|
||||||
|
func (r *crawlRepository) ListActive(ctx context.Context) ([]model.CrawlTaskRow, error) {
|
||||||
|
var rows []model.CrawlTaskRow
|
||||||
|
err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT `+crawlColumns+`
|
||||||
|
FROM crawl_tasks t LEFT JOIN brands b ON b.id = t.brand_id
|
||||||
|
WHERE t.status IN ('running', 'queued')
|
||||||
|
ORDER BY CASE t.status WHEN 'running' THEN 0 ELSE 1 END, t.id DESC
|
||||||
|
LIMIT 200`).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *crawlRepository) SetStatus(ctx context.Context, id uint32, status string) error {
|
func (r *crawlRepository) SetStatus(ctx context.Context, id uint32, status string) error {
|
||||||
return r.db.WithContext(ctx).Model(&model.CrawlTask{}).Where("id = ?", id).
|
return r.db.WithContext(ctx).Model(&model.CrawlTask{}).Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
@ -293,6 +397,22 @@ func (r *crawlRepository) Delete(ctx context.Context, id uint32) error {
|
|||||||
return r.db.WithContext(ctx).Exec(`DELETE FROM crawl_tasks WHERE id = ?`, id).Error
|
return r.db.WithContext(ctx).Exec(`DELETE FROM crawl_tasks WHERE id = ?`, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListCrawlLogs 取某品牌最近的抓取历史。
|
||||||
|
func (r *crawlRepository) ListCrawlLogs(ctx context.Context, brandID uint32, limit int) ([]model.CrawlLog, error) {
|
||||||
|
if brandID == 0 || limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rows []model.CrawlLog
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Where("brand_id = ?", brandID).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *crawlRepository) BrandCrawlStates(ctx context.Context, brandIDs []uint32) (map[uint32]BrandCrawlState, error) {
|
func (r *crawlRepository) BrandCrawlStates(ctx context.Context, brandIDs []uint32) (map[uint32]BrandCrawlState, error) {
|
||||||
out := make(map[uint32]BrandCrawlState, len(brandIDs))
|
out := make(map[uint32]BrandCrawlState, len(brandIDs))
|
||||||
if len(brandIDs) == 0 {
|
if len(brandIDs) == 0 {
|
||||||
@ -336,3 +456,34 @@ func (r *crawlRepository) Counts(ctx context.Context) (map[string]int64, error)
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShowCoverage 统计每个「(年份, 季节, 类型)」已入库的图片张数。
|
||||||
|
//
|
||||||
|
// 只统计未软删的图:一张被删过的秀如果只剩几张,说明当初没抓全,
|
||||||
|
// 调用方会按阈值判断是否值得重抓,而不是一律跳过。
|
||||||
|
// JOIN 保证 image_count > 0,没有图的秀不会出现在结果里。
|
||||||
|
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, 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
|
||||||
|
GROUP BY r.year, r.season, r.collection_type`,
|
||||||
|
brandID).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[ShowKey]ShowCoverageStat, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out[ShowKey{Year: row.Year, Season: row.Season, CollectionType: row.CollectionType}] =
|
||||||
|
ShowCoverageStat{Images: row.Images, Expected: row.Expected}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -28,8 +28,19 @@ type IngestRepository interface {
|
|||||||
// Claim 原子领取最多 limit 条 pending 任务并置为 processing,返回这些任务。
|
// Claim 原子领取最多 limit 条 pending 任务并置为 processing,返回这些任务。
|
||||||
// 用 SKIP LOCKED 保证多 worker 不抢同一条。
|
// 用 SKIP LOCKED 保证多 worker 不抢同一条。
|
||||||
Claim(ctx context.Context, limit int) ([]model.IngestJob, error)
|
Claim(ctx context.Context, limit int) ([]model.IngestJob, error)
|
||||||
// MarkDone 标记任务成功。
|
// MarkDone 标记任务成功,并记录结果类型与人可读细节。
|
||||||
MarkDone(ctx context.Context, id uint32) error
|
//
|
||||||
|
// outcome 取 model.IngestOutcome* 常量,note 例如「新建 42 张图」「已存在(status=published)」。
|
||||||
|
// 二者是后台「品牌更新记录」的全部信息源:只有 status=done 看不出这次到底新建还是跳过。
|
||||||
|
MarkDone(ctx context.Context, id uint32, outcome, note string) error
|
||||||
|
// RunwayTitleExists 按(品牌 + 英文标题)查已有图集,返回其 id 与是否存在。
|
||||||
|
//
|
||||||
|
// 用途:季节码推导规则变更后,同一场秀可能被算出不同的 season_code(如 Spring 男装
|
||||||
|
// 曾是 FWyy、现在是 SSyy)。老实例按旧规则写入会建出「同标题不同季节码」的重复图集。
|
||||||
|
// 建行前用它兜底,把「标题相同」视为同一场秀。
|
||||||
|
RunwayTitleExists(ctx context.Context, brandID uint32, titleEn string) (uint32, bool, error)
|
||||||
|
// ListByBrand 取某品牌的入库记录(按 id 倒序),供品牌详情页展示更新历史。
|
||||||
|
ListByBrand(ctx context.Context, brandID uint32, limit int) ([]BrandIngestRecord, error)
|
||||||
// MarkFailed 标记任务失败并记录错误(attempts 自增)。
|
// MarkFailed 标记任务失败并记录错误(attempts 自增)。
|
||||||
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
||||||
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
||||||
@ -54,6 +65,15 @@ type IngestRepository interface {
|
|||||||
BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error
|
BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error
|
||||||
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
|
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
|
||||||
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
|
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)查正式表既有行,返回其状态与是否已被软删。
|
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
|
||||||
// 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。
|
// 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。
|
||||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
||||||
@ -66,6 +86,13 @@ type IngestRepository interface {
|
|||||||
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
|
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
|
||||||
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
|
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
|
||||||
CountJobs(ctx context.Context) (int64, error)
|
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 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。
|
||||||
RetryJob(ctx context.Context, id uint32) error
|
RetryJob(ctx context.Context, id uint32) error
|
||||||
// EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(payload 为待清理 key 的 JSON)。
|
// EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(payload 为待清理 key 的 JSON)。
|
||||||
@ -165,17 +192,65 @@ func (r *ingestRepository) Claim(ctx context.Context, limit int) ([]model.Ingest
|
|||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) MarkDone(ctx context.Context, id uint32) error {
|
func (r *ingestRepository) MarkDone(ctx context.Context, id uint32, outcome, note string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.IngestJob{}).
|
Model(&model.IngestJob{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": model.IngestStatusDone,
|
"status": model.IngestStatusDone,
|
||||||
|
"outcome": outcome,
|
||||||
|
"note": note,
|
||||||
"last_error": "",
|
"last_error": "",
|
||||||
"updated_at": uint32(time.Now().Unix()),
|
"updated_at": uint32(time.Now().Unix()),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunwayTitleExists 按(品牌 + 英文标题)查已有图集。
|
||||||
|
func (r *ingestRepository) RunwayTitleExists(ctx context.Context, brandID uint32, titleEn string) (uint32, bool, error) {
|
||||||
|
if brandID == 0 || titleEn == "" {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
var id uint32
|
||||||
|
if err := r.db.WithContext(ctx).Raw(
|
||||||
|
"SELECT id FROM brand_runways WHERE brand_id = ? AND title_en = ? AND is_deleted = 0 LIMIT 1",
|
||||||
|
brandID, titleEn).Scan(&id).Error; err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return id, id > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrandIngestRecord 是品牌详情页需要的「一条更新记录」。
|
||||||
|
//
|
||||||
|
// 只取必要列:payload 可能几十 KB(含全部 look 的 key),一次拉 20 条会传上兆,
|
||||||
|
// 所以标题直接在 SQL 里从 JSON 取出,不回传整段 payload。
|
||||||
|
type BrandIngestRecord struct {
|
||||||
|
ID uint32 `gorm:"column:id"`
|
||||||
|
Title string `gorm:"column:title"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
Outcome string `gorm:"column:outcome"`
|
||||||
|
Note string `gorm:"column:note"`
|
||||||
|
LastError string `gorm:"column:last_error"`
|
||||||
|
Attempts uint8 `gorm:"column:attempts"`
|
||||||
|
UpdatedAt uint32 `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByBrand 取某品牌的入库记录(按 id 倒序),供品牌详情页展示更新历史。
|
||||||
|
func (r *ingestRepository) ListByBrand(ctx context.Context, brandID uint32, limit int) ([]BrandIngestRecord, error) {
|
||||||
|
if brandID == 0 || limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rows []BrandIngestRecord
|
||||||
|
if err := r.db.WithContext(ctx).Raw(
|
||||||
|
"SELECT id, status, coalesce(outcome, '') AS outcome, coalesce(note, '') AS note, "+
|
||||||
|
"coalesce(last_error, '') AS last_error, attempts, updated_at, "+
|
||||||
|
"coalesce(payload::json->>'title_en', '') AS title "+
|
||||||
|
"FROM ingest_jobs WHERE brand_id = ? ORDER BY id DESC LIMIT ?",
|
||||||
|
brandID, limit).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
|
func (r *ingestRepository) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.IngestJob{}).
|
Model(&model.IngestJob{}).
|
||||||
@ -597,3 +672,124 @@ func indexOf(s, sub string) int {
|
|||||||
}
|
}
|
||||||
return -1
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -65,6 +65,10 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
internal.Use(middleware.IngestAuth(*opt.IngestAuth))
|
internal.Use(middleware.IngestAuth(*opt.IngestAuth))
|
||||||
internal.POST("/ingest", opt.Ingest.Submit)
|
internal.POST("/ingest", opt.Ingest.Submit)
|
||||||
internal.GET("/crawl/brands", opt.Ingest.CrawlBrands)
|
internal.GET("/crawl/brands", opt.Ingest.CrawlBrands)
|
||||||
|
// 已入库的秀(按 年份+季节+类型 聚合):spider 抓前预检,整场跳过,避免重复下载。
|
||||||
|
internal.GET("/shows/covered", opt.Backstage.WorkerShowsCovered)
|
||||||
|
// 执行进度:spider 每处理完一场秀上报一次,后台列表页轮询 /admin/crawl/live 读取。
|
||||||
|
internal.POST("/crawl/tasks/:id/progress", opt.Backstage.WorkerProgress)
|
||||||
// spider 服务化:领取品牌抓取任务 / 回报结果(与 ingest 共用同一套 HMAC 验签)。
|
// spider 服务化:领取品牌抓取任务 / 回报结果(与 ingest 共用同一套 HMAC 验签)。
|
||||||
internal.GET("/crawl/tasks", opt.Backstage.WorkerTask)
|
internal.GET("/crawl/tasks", opt.Backstage.WorkerTask)
|
||||||
internal.POST("/crawl/tasks/:id/result", opt.Backstage.WorkerReport)
|
internal.POST("/crawl/tasks/:id/result", opt.Backstage.WorkerReport)
|
||||||
@ -128,17 +132,12 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
|
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/cover", opt.Backstage.StreetSnapSetCover)
|
||||||
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
|
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
|
||||||
// 品牌抓取任务控制面:后台决定爬哪些品牌,spider 服务来领取执行。
|
// 爬虫页:把「抓取」与「入库」两条队列拼成一条流水线(只读)。
|
||||||
// 列表 + 批量按前缀入队 + 单品牌入队 + 取消/重试/删除。
|
// 入队仍在品牌页,两个 POST 是被那里的按钮调用的。
|
||||||
admin.GET("/crawl", opt.Backstage.CrawlList)
|
admin.GET("/spider", opt.Backstage.SpiderBoard)
|
||||||
admin.POST("/crawl/bulk", opt.Backstage.CrawlBulk)
|
admin.GET("/spider/live", opt.Backstage.SpiderLive)
|
||||||
admin.POST("/crawl/brands/:uid", opt.Backstage.CrawlEnqueue)
|
admin.POST("/spider/bulk", opt.Backstage.SpiderBulk)
|
||||||
admin.POST("/crawl/tasks/:id/retry", opt.Backstage.CrawlRetry)
|
admin.POST("/spider/brands/:uid", opt.Backstage.SpiderEnqueue)
|
||||||
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)
|
|
||||||
// 用户管理:列出用户 + 提级 / 降为 VIP
|
// 用户管理:列出用户 + 提级 / 降为 VIP
|
||||||
admin.GET("/users", opt.Backstage.Users)
|
admin.GET("/users", opt.Backstage.Users)
|
||||||
admin.POST("/users/:id/tier", opt.Backstage.UserSetTier)
|
admin.POST("/users/:id/tier", opt.Backstage.UserSetTier)
|
||||||
|
|||||||
@ -664,7 +664,7 @@ func TestBackstageNavStatsOnEveryPage(t *testing.T) {
|
|||||||
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
|
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
|
||||||
}
|
}
|
||||||
// 导航已在共享 partial 里,各模块入口都应在
|
// 导航已在共享 partial 里,各模块入口都应在
|
||||||
for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
|
for _, label := range []string{"文章", "品牌", "爬虫", "用户"} {
|
||||||
if !strings.Contains(body, ">"+label) {
|
if !strings.Contains(body, ">"+label) {
|
||||||
t.Fatalf("导航应包含入口 %q", label)
|
t.Fatalf("导航应包含入口 %q", label)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,9 @@ type captureIngestRepo struct {
|
|||||||
repository.IngestRepository
|
repository.IngestRepository
|
||||||
createdRW *model.BrandRunway
|
createdRW *model.BrandRunway
|
||||||
createdImages []model.BrandRunwayImage
|
createdImages []model.BrandRunwayImage
|
||||||
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
|
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
|
||||||
|
doneOutcome string // 最近一次 MarkDone 的结果类型(断言「跳过」是否标清楚)
|
||||||
|
doneNote string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
|
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
|
||||||
@ -35,7 +37,23 @@ func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *mode
|
|||||||
return 1, nil
|
return 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
|
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32, outcome, note string) error {
|
||||||
|
// 记下 outcome/note,供「跳过的场次也要标清楚原因」这类断言使用
|
||||||
|
c.doneOutcome = outcome
|
||||||
|
c.doneNote = note
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunwayTitleExists 捕获型仓储没有真实数据,一律视为「不存在」,
|
||||||
|
// 免得测试路径走进「同标题已存在 → 跳过」分支。
|
||||||
|
func (c *captureIngestRepo) RunwayTitleExists(ctx context.Context, brandID uint32, titleEn string) (uint32, bool, error) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByBrand 捕获型仓储不需要品牌维度查询。
|
||||||
|
func (c *captureIngestRepo) ListByBrand(ctx context.Context, brandID uint32, limit int) ([]repository.BrandIngestRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
|
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
|
||||||
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
||||||
|
|||||||
@ -117,13 +117,26 @@ func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (u
|
|||||||
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
|
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
|
||||||
return 0, NewError(http.StatusBadRequest, "brand_uid required")
|
return 0, NewError(http.StatusBadRequest, "brand_uid required")
|
||||||
}
|
}
|
||||||
job := &model.IngestJob{Payload: string(raw), Kind: model.IngestKindCrawl}
|
// brand_id 提交时就解出来:品牌详情页按它直接查更新记录,
|
||||||
|
// 不必每次把 payload 里的 hashid 解一遍(也避免与查询时的盐不一致)。
|
||||||
|
var brandID uint32
|
||||||
|
if payload.BrandUID != "" {
|
||||||
|
if id, derr := hashid.Decode(payload.BrandUID); derr == nil {
|
||||||
|
brandID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job := &model.IngestJob{Payload: string(raw), Kind: model.IngestKindCrawl, BrandID: brandID}
|
||||||
if err := s.repo.Enqueue(ctx, job); err != nil {
|
if err := s.repo.Enqueue(ctx, job); err != nil {
|
||||||
return 0, internalErr(err.Error())
|
return 0, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
return job.ID, nil
|
return job.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListBrandRecords 取某品牌的入库记录(品牌详情页的「更新记录」)。
|
||||||
|
func (s *IngestService) ListBrandRecords(ctx context.Context, brandID uint32, limit int) ([]repository.BrandIngestRecord, error) {
|
||||||
|
return s.repo.ListByBrand(ctx, brandID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
// ListJobs 分页列出入库任务(后台监控页用)。返回本页任务、总条数与错误。
|
// ListJobs 分页列出入库任务(后台监控页用)。返回本页任务、总条数与错误。
|
||||||
func (s *IngestService) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, int64, error) {
|
func (s *IngestService) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, int64, error) {
|
||||||
jobs, err := s.repo.ListJobs(ctx, offset, limit)
|
jobs, err := s.repo.ListJobs(ctx, offset, limit)
|
||||||
@ -142,6 +155,16 @@ func (s *IngestService) RetryJob(ctx context.Context, id uint32) error {
|
|||||||
return s.repo.RetryJob(ctx, id)
|
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 取消。
|
// Run 启动 worker 循环:定时领取 pending 任务并处理,直到 ctx 取消。
|
||||||
// 多实例安全:Claim 用 SKIP LOCKED,互不抢同一条。
|
// 多实例安全:Claim 用 SKIP LOCKED,互不抢同一条。
|
||||||
func (s *IngestService) Run(ctx context.Context, batch int, interval time.Duration) {
|
func (s *IngestService) Run(ctx context.Context, batch int, interval time.Duration) {
|
||||||
@ -245,11 +268,11 @@ func (s *IngestService) processMediaCleanup(ctx context.Context, job model.Inges
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(p.Keys) == 0 {
|
if len(p.Keys) == 0 {
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeNoop, "媒体清理:无需处理")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
purgeOrphanImages(ctx, s.media, s.del, p.Keys)
|
purgeOrphanImages(ctx, s.media, s.del, p.Keys)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeCleaned, "媒体清理完成")
|
||||||
}
|
}
|
||||||
|
|
||||||
// failOrRetry 把临时失败(如网络抖动 / 单图下载失败)按指数退避自动重试:
|
// failOrRetry 把临时失败(如网络抖动 / 单图下载失败)按指数退避自动重试:
|
||||||
@ -266,10 +289,22 @@ func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg strin
|
|||||||
// processRunway 走秀入库:品牌校验 → 实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 brand_runways。
|
// processRunway 走秀入库:品牌校验 → 实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 brand_runways。
|
||||||
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||||
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
|
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
|
||||||
brandID, err := hashid.Decode(p.BrandUID)
|
//
|
||||||
if err != nil {
|
// 优先用「提交任务时就解好的 brand_id」:它由接收上报的那个实例解出,盐一定正确。
|
||||||
_ = s.repo.MarkFailed(ctx, job.ID, "unknown brand_uid")
|
// 只有当 brand_id 缺省(本次改动前的老任务)才回落到解 payload 里的 hashid。
|
||||||
return
|
//
|
||||||
|
// 为什么必须这样:hashid 的解码依赖 HASHID_SECRET。线上曾出现另一台机器上的
|
||||||
|
// 旧后端连同一套库抢任务,它的盐与线上不同 → 同一个 uid 解出别的 id → 整场被判
|
||||||
|
// brand not found 丢掉(AMBUSH 那批 8 场就是这么没的)。brand_id 落库后,
|
||||||
|
// 即便 worker 与提交方不是同一个实例,也不再有这个分歧。
|
||||||
|
brandID := job.BrandID
|
||||||
|
if brandID == 0 {
|
||||||
|
id, derr := hashid.Decode(p.BrandUID)
|
||||||
|
if derr != nil {
|
||||||
|
_ = s.repo.MarkFailed(ctx, job.ID, "unknown brand_uid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
brandID = id
|
||||||
}
|
}
|
||||||
if _, err := s.brandRepo.FindByID(ctx, brandID); err != nil {
|
if _, err := s.brandRepo.FindByID(ctx, brandID); err != nil {
|
||||||
// 必须区分「品牌真不存在」与「查询本身失败」:前者永久失败,后者应退避重试。
|
// 必须区分「品牌真不存在」与「查询本身失败」:前者永久失败,后者应退避重试。
|
||||||
@ -304,7 +339,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
|
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
|
||||||
if exist && isDeleted {
|
if exist && isDeleted {
|
||||||
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedDeleted, "实体已删除(删除即永久,不重爬)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !exist && p.Year == 0 {
|
if !exist && p.Year == 0 {
|
||||||
@ -319,21 +354,47 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
reuseID = rid
|
reuseID = rid
|
||||||
if exist && isDeleted {
|
if exist && isDeleted {
|
||||||
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedDeleted, "实体已删除(删除即永久,不重爬)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
action := decideIngestAction(exist, existStatus)
|
action := decideIngestAction(exist, existStatus)
|
||||||
|
// 季节码漂移保护:实体键按 season_code 去重,但规则改过(Spring 男装由 FWyy 改为 SSyy)。
|
||||||
|
// 若还有实例按旧规则算码,就会把同一场秀当成新场次再建一行。
|
||||||
|
// 这里在建行前用「同品牌同标题」再兜一次底,宁可跳过也不产生重复。
|
||||||
|
if !exist && action != ingestSkip {
|
||||||
|
if dupID, found, terr := s.repo.RunwayTitleExists(ctx, brandID, p.TitleEn); terr == nil && found {
|
||||||
|
log.Printf("[ingest] job=%d runway 已存在同标题图集 id=%d(季节码可能漂移),跳过", job.ID, dupID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedExisting,
|
||||||
|
fmt.Sprintf("已存在同标题图集(id=%d),跳过(季节码漂移保护)", dupID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if action == ingestSkip {
|
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 p.PublishedAt != 0 {
|
||||||
if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil {
|
if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil {
|
||||||
s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error())
|
s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -396,6 +457,10 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
PublishedAt: p.PublishedAt,
|
PublishedAt: p.PublishedAt,
|
||||||
Cover: cover,
|
Cover: cover,
|
||||||
ImageCount: imageCount,
|
ImageCount: imageCount,
|
||||||
|
// 应有张数取「spider 上报值」与「本次实际入库数」的较大者:
|
||||||
|
// 去重会砍掉重复图,payload 里的总数可能偏高;而实际入库数就是本次能拿到的全部。
|
||||||
|
// 取较大者保证覆盖率判定不会因为去重而永远判「不完整」。
|
||||||
|
ExpectedImages: maxInt(p.ExpectedImages, int(imageCount)),
|
||||||
Status: model.StatusPending,
|
Status: model.StatusPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -439,7 +504,11 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
}
|
}
|
||||||
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||||
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
|
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
outcome, note := model.IngestOutcomeCreated, fmt.Sprintf("新建 %d 张图", len(images))
|
||||||
|
if action == ingestReuseRejected {
|
||||||
|
outcome, note = model.IngestOutcomeReused, fmt.Sprintf("复用驳回记录,覆盖 %d 张图", len(images))
|
||||||
|
}
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID, outcome, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
||||||
@ -553,6 +622,107 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
|||||||
return cover, rows, vecs, keys, failed, timing
|
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 的口径。
|
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
|
||||||
// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。
|
// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。
|
||||||
func countMainImages(images []model.BrandRunwayImage) uint16 {
|
func countMainImages(images []model.BrandRunwayImage) uint16 {
|
||||||
@ -581,13 +751,13 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
|
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
|
||||||
if exist && isDeleted {
|
if exist && isDeleted {
|
||||||
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedDeleted, "街拍实体已删除(删除即永久,不重爬)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
action := decideIngestAction(exist, existStatus)
|
action := decideIngestAction(exist, existStatus)
|
||||||
if action == ingestSkip {
|
if action == ingestSkip {
|
||||||
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
|
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedExisting, "街拍已存在(status="+existStatus+"),跳过重下")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -655,7 +825,11 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
s.precomputeSimilar(ctx, dto.IngestKindStreet, embedIDs)
|
s.precomputeSimilar(ctx, dto.IngestKindStreet, embedIDs)
|
||||||
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||||
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
|
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
outcome, note := model.IngestOutcomeCreated, fmt.Sprintf("新建街拍 %d 张图", len(rows))
|
||||||
|
if action == ingestReuseRejected {
|
||||||
|
outcome, note = model.IngestOutcomeReused, fmt.Sprintf("复用驳回的街拍,覆盖 %d 张图", len(rows))
|
||||||
|
}
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID, outcome, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||||
|
|||||||
@ -17,7 +17,13 @@ func (fakeMediaRepo) CountImageKeyRefs(ctx context.Context, key string) (int, er
|
|||||||
// fakeIngestRepo 仅实现 processMediaCleanup 需要的 MarkDone/MarkFailed(其余由内嵌 nil 接口兜底,不会被调用)。
|
// fakeIngestRepo 仅实现 processMediaCleanup 需要的 MarkDone/MarkFailed(其余由内嵌 nil 接口兜底,不会被调用)。
|
||||||
type fakeIngestRepo struct{ repository.IngestRepository }
|
type fakeIngestRepo struct{ repository.IngestRepository }
|
||||||
|
|
||||||
func (fakeIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
|
func (fakeIngestRepo) MarkDone(ctx context.Context, id uint32, outcome, note string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeIngestRepo) ListByBrand(ctx context.Context, brandID uint32, limit int) ([]repository.BrandIngestRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
func (fakeIngestRepo) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
|
func (fakeIngestRepo) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
94
scripts/check_s4.py
Normal file
94
scripts/check_s4.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""在生产机上验证 S4 直传链路:签发预签名 URL → PUT 一个小文件 → 公开读回。
|
||||||
|
|
||||||
|
只输出状态与长度,不输出密钥。
|
||||||
|
用法:python3 check_s4.py
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ENV_PATH = "/opt/fashion/backend_v2/.env"
|
||||||
|
ENDPOINT = "http://127.0.0.1:18092/admin/internal/upload-url"
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path):
|
||||||
|
env = {}
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
env[key.strip()] = value
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
env = load_env(ENV_PATH)
|
||||||
|
secret = env.get("INGEST_SECRET", "")
|
||||||
|
bucket = env.get("S4_BUCKET", "")
|
||||||
|
if not secret:
|
||||||
|
print("!! .env 里没有 INGEST_SECRET")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
key = f"runway/s4test-{int(time.time())}.jpg"
|
||||||
|
body = json.dumps({"items": [{"key": key, "content_type": "image/jpeg"}]}, separators=(",", ":"))
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
nonce = secrets.token_hex(8)
|
||||||
|
sig = hmac.new(secret.encode(), f"{ts}.{nonce}.{body}".encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
ENDPOINT,
|
||||||
|
data=body.encode(),
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Signature": sig,
|
||||||
|
"X-Timestamp": ts,
|
||||||
|
"X-Nonce": nonce,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
print(f"1) 签发预签名: HTTP {resp.status}")
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
print(f"1) 签发预签名失败: HTTP {exc.code} {exc.read()[:200]!r}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
item = data["items"][0]
|
||||||
|
# 预签名 URL 里带签名参数,只显示 host 与 path
|
||||||
|
parsed = urllib.request.urlparse(item["url"])
|
||||||
|
print(f" 签名 URL: {parsed.scheme}://{parsed.netloc}{parsed.path[:40]}... 长度={len(item['url'])}")
|
||||||
|
|
||||||
|
payload = b"\xff\xd8\xff\xe0" + b"fashion-s4-probe" + b"\xff\xd9"
|
||||||
|
put = urllib.request.Request(
|
||||||
|
item["url"], data=payload, method="PUT", headers={"Content-Type": "image/jpeg"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(put, timeout=30) as resp:
|
||||||
|
print(f"2) 上传: HTTP {resp.status}")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
print(f"2) 上传失败: HTTP {exc.code} {exc.read()[:300]!r}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
public_url = f"https://{bucket}.s3.bitiful.net/{key}"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(public_url, timeout=20) as resp:
|
||||||
|
got = resp.read()
|
||||||
|
print(f"3) 公开读取: HTTP {resp.status} 字节={len(got)} 一致={got == payload}")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
print(f"3) 公开读取失败: HTTP {exc.code}(桶可能不是公开读)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f" 测试对象: {key}({len(payload)} 字节,可忽略)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
53
scripts/patch_env.py
Normal file
53
scripts/patch_env.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""把 KEY=VALUE 文件里的键合并进目标 .env(存在则替换,不存在则追加)。
|
||||||
|
|
||||||
|
用途:在不把密钥回显到终端的前提下,更新服务器上的生产配置。
|
||||||
|
用法:python3 patch_env.py <kv文件> <目标.env>
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("用法: patch_env.py <kv文件> <目标.env>")
|
||||||
|
return 2
|
||||||
|
kv_path, env_path = sys.argv[1], sys.argv[2]
|
||||||
|
|
||||||
|
kv = {}
|
||||||
|
with open(kv_path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or "=" not in line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
kv[key.strip()] = value
|
||||||
|
|
||||||
|
with open(env_path, encoding="utf-8") as fh:
|
||||||
|
lines = fh.read().splitlines()
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for line in lines:
|
||||||
|
m = re.match(r"^([A-Z0-9_]+)=", line)
|
||||||
|
if m and m.group(1) in kv:
|
||||||
|
key = m.group(1)
|
||||||
|
out.append(f"{key}={kv[key]}")
|
||||||
|
seen.add(key)
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
for key, value in kv.items():
|
||||||
|
if key not in seen:
|
||||||
|
out.append(f"{key}={value}")
|
||||||
|
|
||||||
|
with open(env_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("\n".join(out) + "\n")
|
||||||
|
|
||||||
|
# 只打印键名与长度,绝不打印值
|
||||||
|
for key in sorted(kv):
|
||||||
|
print(f" {key}: {'已更新' if key in seen else '已追加'} 长度={len(kv[key])}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user