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:
105
internal/handler/backstage_brand_records.go
Normal file
105
internal/handler/backstage_brand_records.go
Normal 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]) + "…"
|
||||
}
|
||||
@ -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 处理品牌编辑表单。
|
||||
|
||||
@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user