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:
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}}
|
||||
|
||||
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" }
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user