From 64b264ee73c786d5872f9e17b78ccf131c2bfd67 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Sun, 25 Oct 2026 11:37:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=93=81=E7=89=8C=E6=8A=93=E5=8F=96?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=EF=BC=88brand=5Fcrawl=5Flogs=EF=BC=89+=20?= =?UTF-8?q?=E5=93=81=E7=89=8C=E9=A1=B5=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crawl_tasks 每品牌只有一行(uq_crawl_tasks_brand),入队/完成都是覆盖, 答不了「什么时候爬过、爬了几次、哪次没抓到」。故新增 append-only 历史表: - 在 Report 的同一事务里写一条:品牌 / 任务 / 状态 / 第几次尝试 / 场次上限 / 实际上报场次 / 错误 / 执行者 / 领取与回报时间 - 写入后只保留该品牌最近 30 条(CrawlLogKeepPerBrand), 历史用于排障而非归档,避免无限增长 - 品牌详情页新增「抓取历史」区块(结果 / 明细 / 耗时 / 执行者), 与「更新记录」上下相邻,构成「抓取一次 → 每场入库结果」的完整链路 量级:每品牌每次抓取 1 行、约 200B(含索引);全站跑一遍 23237 行 ≈ 4.6MB。 --- .../2026-10-25-02-brand-crawl-logs.sql | 26 ++++++++++ internal/handler/backstage_brand_records.go | 45 +++++++++++++++++ internal/handler/backstage_handler.go | 5 +- .../handler/templates/pages/brand-form.html | 19 +++++++ internal/model/crawl_log.go | 30 +++++++++++ internal/repository/crawl_repository.go | 50 ++++++++++++++++++- 6 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 db/migrations/2026-10-25-02-brand-crawl-logs.sql create mode 100644 internal/model/crawl_log.go diff --git a/db/migrations/2026-10-25-02-brand-crawl-logs.sql b/db/migrations/2026-10-25-02-brand-crawl-logs.sql new file mode 100644 index 0000000..9fce3fe --- /dev/null +++ b/db/migrations/2026-10-25-02-brand-crawl-logs.sql @@ -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); diff --git a/internal/handler/backstage_brand_records.go b/internal/handler/backstage_brand_records.go index fb7fa77..2f9185f 100644 --- a/internal/handler/backstage_brand_records.go +++ b/internal/handler/backstage_brand_records.go @@ -95,6 +95,51 @@ func buildBrandRecordViews(recs []repository.BrandIngestRecord) []brandRecordVie 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)) diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index 9b3af49..59f8260 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -293,7 +293,7 @@ func (h *BackstageHandler) BrandEdit(c *gin.Context) { data["Records"] = buildBrandRecordViews(recs) } } - // 抓取侧:crawl_tasks 每品牌只有一行(最近一次),这里当摘要用 + // 抓取侧:最近一次状态(摘要)+ 抓取历史(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 { @@ -301,6 +301,9 @@ func (h *BackstageHandler) BrandEdit(c *gin.Context) { 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) diff --git a/internal/handler/templates/pages/brand-form.html b/internal/handler/templates/pages/brand-form.html index e7d8bea..b1c9768 100644 --- a/internal/handler/templates/pages/brand-form.html +++ b/internal/handler/templates/pages/brand-form.html @@ -13,6 +13,25 @@ {{if .UID}} +
每次抓取一条(每品牌保留最近 30 条)。这一层回答「什么时候爬的、爬了几场、哪次没抓到」。
+{{if .CrawlLogs}} +| 时间 | 结果 | 明细 | 耗时 | 执行者 |
|---|---|---|---|---|
| {{.TimeText}} | +{{.StatusText}} | +{{.Detail}} | +{{if .Elapsed}}{{.Elapsed}}{{else}}-{{end}} | +{{if .Worker}}{{.Worker}}{{else}}-{{end}} |
+
还没有抓取记录。把品牌加入抓取队列后,每次抓取(抓到几场 / 失败原因 / 谁跑的)都会出现在这里。
+{{end}} +{{if .LastCrawl}}最近抓取:{{.LastCrawl}} · {{.CrawlStatus}}{{else}}还没有抓取记录{{end}} diff --git a/internal/model/crawl_log.go b/internal/model/crawl_log.go new file mode 100644 index 0000000..a475829 --- /dev/null +++ b/internal/model/crawl_log.go @@ -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" } diff --git a/internal/repository/crawl_repository.go b/internal/repository/crawl_repository.go index a0104d0..a27ef39 100644 --- a/internal/repository/crawl_repository.go +++ b/internal/repository/crawl_repository.go @@ -38,6 +38,10 @@ type CrawlRepository interface { // BrandCrawlStates 返回这些品牌在抓取队列里的「最近状态 + 最近时间」。 // 品牌列表用它展示「抓取状态 / 上次抓取」;没有任务记录的品牌不会出现在 map 里。 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) } // BrandCrawlState 品牌最近一次抓取任务的状态快照。 @@ -244,7 +248,7 @@ func (r *crawlRepository) Report(ctx context.Context, id uint32, status string, if status == model.CrawlFailed && cur.Attempts < model.CrawlMaxAttempts { next = model.CrawlQueued } - return tx.Model(&model.CrawlTask{}).Where("id = ?", id). + if err := tx.Model(&model.CrawlTask{}).Where("id = ?", id). Updates(map[string]any{ "status": next, "shows_submitted": shows, @@ -252,7 +256,33 @@ func (r *crawlRepository) Report(ctx context.Context, id uint32, status string, "locked_at": 0, "worker": "", "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 }) } @@ -293,6 +323,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 } +// 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) { out := make(map[uint32]BrandCrawlState, len(brandIDs)) if len(brandIDs) == 0 {