feat: 品牌抓取历史(brand_crawl_logs)+ 品牌页展示

crawl_tasks 每品牌只有一行(uq_crawl_tasks_brand),入队/完成都是覆盖,
答不了「什么时候爬过、爬了几次、哪次没抓到」。故新增 append-only 历史表:

- 在 Report 的同一事务里写一条:品牌 / 任务 / 状态 / 第几次尝试 /
  场次上限 / 实际上报场次 / 错误 / 执行者 / 领取与回报时间
- 写入后只保留该品牌最近 30 条(CrawlLogKeepPerBrand),
  历史用于排障而非归档,避免无限增长
- 品牌详情页新增「抓取历史」区块(结果 / 明细 / 耗时 / 执行者),
  与「更新记录」上下相邻,构成「抓取一次 → 每场入库结果」的完整链路

量级:每品牌每次抓取 1 行、约 200B(含索引);全站跑一遍 23237 行 ≈ 4.6MB。
This commit is contained in:
toom1996
2026-10-25 11:37:43 +08:00
parent c8a70760cc
commit 64b264ee73
6 changed files with 172 additions and 3 deletions

View File

@ -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))

View File

@ -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)

View File

@ -13,6 +13,25 @@
</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}}