feat: 入库结果落库 + 品牌详情展示更新记录

背景:任务只有 status=done,分不清「新建」「已存在跳过」「已删除跳过」,
品牌更新历史只能在进程日志里翻,日志一滚就没了。

- ingest_jobs 新增 outcome / note / brand_id(迁移 2026-10-25-01)
  outcome 记结果类型,note 记人话细节(新建 N 张图 / 已存在(status=published))
- 提交任务时解出 brand_id 落库,品牌详情页按 (brand_id, id DESC) 直接查
- 处理时优先用 job.brand_id,payload 里的 hashid 只作老任务兜底:
  避免「别的实例盐不同 → 解出错品牌 → 整场 brand not found」再发生
- 品牌编辑页新增「更新记录」区块:时间 / 场次 / 结果 / 明细(含失败原因与尝试次数)
- 测试:fake 仓储补齐新接口,capture 仓储记录 outcome 供断言
This commit is contained in:
toom1996
2026-10-25 11:06:16 +08:00
parent c483018cc8
commit c8a70760cc
9 changed files with 297 additions and 24 deletions

View File

@ -0,0 +1,105 @@
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
}
// truncateRunes 按字符(不是字节)截断,避免把中文切成半个字。
func truncateRunes(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}

View File

@ -273,14 +273,37 @@ func (h *BackstageHandler) BrandCreate(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/brands")
}
// BrandEdit 渲染品牌编辑表单(按编码 id 取现有值)。
// BrandEdit 渲染品牌编辑表单(按编码 id 取现有值)+ 该品牌的更新记录。
//
// 更新记录放在这里而不是单独做页面:品牌是这件事的主体,人打开一个品牌
// 想看的正是「它最近几次抓取/入库到底做了什么」,跳出去看列表反而多一步。
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 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "品牌不存在或已删除: " + c.Param("id")})
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "品牌不存在或已删除: " + uid})
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)
}
}
// 抓取侧:crawl_tasks 每品牌只有一行(最近一次),这里当摘要用
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")
}
}
}
}
renderPage(c, http.StatusOK, "brand-form", data)
}
// BrandUpdate 处理品牌编辑表单。

View File

@ -11,5 +11,28 @@
<label>中文名(name_cn)</label><input name="name_cn" value="{{.NameCn}}" placeholder="如 艾克妮">
<button type="submit">{{if .UID}}保存{{else}}创建{{end}}</button>
</form>
{{if .UID}}
<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>
{{end}}