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,15 @@
-- 入库任务的「结果类型」与品牌归属(品牌详情页的更新记录用)。
--
-- 背景:原来任务只有 status=done,分不清「新建入库」「实体已存在跳过」「实体已删除跳过」,
-- 于是「这个品牌这次爬了到底干了什么」只能去翻进程日志,日志一滚就没了。
--
-- outcome 记录结果类型,note 记录人可读的细节(新建几张图 / 已存在的状态是什么)。
ALTER TABLE ingest_jobs ADD COLUMN IF NOT EXISTS outcome varchar(24) NOT NULL DEFAULT '';
ALTER TABLE ingest_jobs ADD COLUMN IF NOT EXISTS note text NOT NULL DEFAULT '';
-- brand_id:提交任务时就解出品牌,品牌详情页按它直接查,不必每次解 payload 里的 hashid。
-- 0 表示无品牌(街拍任务)。
ALTER TABLE ingest_jobs ADD COLUMN IF NOT EXISTS brand_id bigint NOT NULL DEFAULT 0;
-- 品牌详情页的查询是「某品牌最近 N 条」,按 (brand_id, id DESC) 走索引。
CREATE INDEX IF NOT EXISTS idx_ingest_jobs_brand ON ingest_jobs (brand_id, id DESC);

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}}

View File

@ -19,8 +19,25 @@ type IngestJob struct {
LastError string `gorm:"column:last_error;type:text" json:"last_error"`
Kind string `gorm:"column:kind;size:16;not null;default:'crawl'" json:"kind"` // crawl|media_cleanup
NextAttemptAt uint32 `gorm:"column:next_attempt_at;not null;default:0" json:"next_attempt_at"` // 重试退避:到达该 unix 时间才可被领取
// Outcome 结果类型(见 IngestOutcome* 常量),Note 是人可读的细节。
// 只有 done 状态才有意义:同为 done,「新建 42 张图」和「实体已存在跳过」是完全不同的事,
// 后台的「品牌更新记录」靠这两个字段说清楚每次到底做了什么。
Outcome string `gorm:"column:outcome;size:24;not null;default:''" json:"outcome"`
Note string `gorm:"column:note;type:text;not null;default:''" json:"note"`
// BrandID 提交时就解出的品牌 id(街拍为 0),供品牌详情页直接按品牌查更新记录。
BrandID uint32 `gorm:"column:brand_id;not null;default:0" json:"brand_id"`
}
// 入库结果类型(ingest_jobs.outcome)。
const (
IngestOutcomeCreated = "created" // 新建入站
IngestOutcomeReused = "reused_rejected" // 复用被驳回的记录并覆盖
IngestOutcomeSkippedExisting = "skipped_existing" // 实体已存在,跳过(不重下图片)
IngestOutcomeSkippedDeleted = "skipped_deleted" // 实体已软删,跳过(删除即永久)
IngestOutcomeCleaned = "cleaned" // 媒体清理任务
IngestOutcomeNoop = "noop" // 无需处理(空载荷)
)
// TableName 指定队列表名。
func (IngestJob) TableName() string { return "ingest_jobs" }

View File

@ -28,8 +28,13 @@ type IngestRepository interface {
// Claim 原子领取最多 limit 条 pending 任务并置为 processing,返回这些任务。
// 用 SKIP LOCKED 保证多 worker 不抢同一条。
Claim(ctx context.Context, limit int) ([]model.IngestJob, error)
// MarkDone 标记任务成功。
MarkDone(ctx context.Context, id uint32) error
// MarkDone 标记任务成功,并记录结果类型与人可读细节。
//
// outcome 取 model.IngestOutcome* 常量,note 例如「新建 42 张图」「已存在(status=published)」。
// 二者是后台「品牌更新记录」的全部信息源:只有 status=done 看不出这次到底新建还是跳过。
MarkDone(ctx context.Context, id uint32, outcome, note string) error
// ListByBrand 取某品牌的入库记录(按 id 倒序),供品牌详情页展示更新历史。
ListByBrand(ctx context.Context, brandID uint32, limit int) ([]BrandIngestRecord, error)
// MarkFailed 标记任务失败并记录错误(attempts 自增)。
MarkFailed(ctx context.Context, id uint32, errMsg string) error
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
@ -165,17 +170,51 @@ func (r *ingestRepository) Claim(ctx context.Context, limit int) ([]model.Ingest
return jobs, nil
}
func (r *ingestRepository) MarkDone(ctx context.Context, id uint32) error {
func (r *ingestRepository) MarkDone(ctx context.Context, id uint32, outcome, note string) error {
return r.db.WithContext(ctx).
Model(&model.IngestJob{}).
Where("id = ?", id).
Updates(map[string]any{
"status": model.IngestStatusDone,
"outcome": outcome,
"note": note,
"last_error": "",
"updated_at": uint32(time.Now().Unix()),
}).Error
}
// BrandIngestRecord 是品牌详情页需要的「一条更新记录」。
//
// 只取必要列:payload 可能几十 KB(含全部 look 的 key),一次拉 20 条会传上兆,
// 所以标题直接在 SQL 里从 JSON 取出,不回传整段 payload。
type BrandIngestRecord struct {
ID uint32 `gorm:"column:id"`
Title string `gorm:"column:title"`
Status string `gorm:"column:status"`
Outcome string `gorm:"column:outcome"`
Note string `gorm:"column:note"`
LastError string `gorm:"column:last_error"`
Attempts uint8 `gorm:"column:attempts"`
UpdatedAt uint32 `gorm:"column:updated_at"`
}
// ListByBrand 取某品牌的入库记录(按 id 倒序),供品牌详情页展示更新历史。
func (r *ingestRepository) ListByBrand(ctx context.Context, brandID uint32, limit int) ([]BrandIngestRecord, error) {
if brandID == 0 || limit <= 0 {
return nil, nil
}
var rows []BrandIngestRecord
if err := r.db.WithContext(ctx).Raw(
"SELECT id, status, coalesce(outcome, '') AS outcome, coalesce(note, '') AS note, "+
"coalesce(last_error, '') AS last_error, attempts, updated_at, "+
"coalesce(payload::json->>'title_en', '') AS title "+
"FROM ingest_jobs WHERE brand_id = ? ORDER BY id DESC LIMIT ?",
brandID, limit).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *ingestRepository) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
return r.db.WithContext(ctx).
Model(&model.IngestJob{}).

View File

@ -19,7 +19,9 @@ type captureIngestRepo struct {
repository.IngestRepository
createdRW *model.BrandRunway
createdImages []model.BrandRunwayImage
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
doneOutcome string // 最近一次 MarkDone 的结果类型(断言「跳过」是否标清楚)
doneNote string
}
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
@ -35,7 +37,17 @@ func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *mode
return 1, nil
}
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32, outcome, note string) error {
// 记下 outcome/note,供「跳过的场次也要标清楚原因」这类断言使用
c.doneOutcome = outcome
c.doneNote = note
return nil
}
// ListByBrand 捕获型仓储不需要品牌维度查询。
func (c *captureIngestRepo) ListByBrand(ctx context.Context, brandID uint32, limit int) ([]repository.BrandIngestRecord, error) {
return nil, nil
}
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {

View File

@ -117,13 +117,26 @@ func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (u
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
return 0, NewError(http.StatusBadRequest, "brand_uid required")
}
job := &model.IngestJob{Payload: string(raw), Kind: model.IngestKindCrawl}
// brand_id 提交时就解出来:品牌详情页按它直接查更新记录,
// 不必每次把 payload 里的 hashid 解一遍(也避免与查询时的盐不一致)。
var brandID uint32
if payload.BrandUID != "" {
if id, derr := hashid.Decode(payload.BrandUID); derr == nil {
brandID = id
}
}
job := &model.IngestJob{Payload: string(raw), Kind: model.IngestKindCrawl, BrandID: brandID}
if err := s.repo.Enqueue(ctx, job); err != nil {
return 0, internalErr(err.Error())
}
return job.ID, nil
}
// ListBrandRecords 取某品牌的入库记录(品牌详情页的「更新记录」)。
func (s *IngestService) ListBrandRecords(ctx context.Context, brandID uint32, limit int) ([]repository.BrandIngestRecord, error) {
return s.repo.ListByBrand(ctx, brandID, limit)
}
// ListJobs 分页列出入库任务(后台监控页用)。返回本页任务、总条数与错误。
func (s *IngestService) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, int64, error) {
jobs, err := s.repo.ListJobs(ctx, offset, limit)
@ -245,11 +258,11 @@ func (s *IngestService) processMediaCleanup(ctx context.Context, job model.Inges
return
}
if len(p.Keys) == 0 {
_ = s.repo.MarkDone(ctx, job.ID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeNoop, "媒体清理:无需处理")
return
}
purgeOrphanImages(ctx, s.media, s.del, p.Keys)
_ = s.repo.MarkDone(ctx, job.ID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeCleaned, "媒体清理完成")
}
// failOrRetry 把临时失败(如网络抖动 / 单图下载失败)按指数退避自动重试:
@ -266,10 +279,22 @@ func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg strin
// processRunway 走秀入库:品牌校验 → 实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 brand_runways。
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
brandID, err := hashid.Decode(p.BrandUID)
if err != nil {
_ = s.repo.MarkFailed(ctx, job.ID, "unknown brand_uid")
return
//
// 优先用「提交任务时就解好的 brand_id」:它由接收上报的那个实例解出,盐一定正确。
// 只有当 brand_id 缺省(本次改动前的老任务)才回落到解 payload 里的 hashid。
//
// 为什么必须这样:hashid 的解码依赖 HASHID_SECRET。线上曾出现另一台机器上的
// 旧后端连同一套库抢任务,它的盐与线上不同 → 同一个 uid 解出别的 id → 整场被判
// brand not found 丢掉(AMBUSH 那批 8 场就是这么没的)。brand_id 落库后,
// 即便 worker 与提交方不是同一个实例,也不再有这个分歧。
brandID := job.BrandID
if brandID == 0 {
id, derr := hashid.Decode(p.BrandUID)
if derr != nil {
_ = s.repo.MarkFailed(ctx, job.ID, "unknown brand_uid")
return
}
brandID = id
}
if _, err := s.brandRepo.FindByID(ctx, brandID); err != nil {
// 必须区分「品牌真不存在」与「查询本身失败」:前者永久失败,后者应退避重试。
@ -304,7 +329,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
if exist && isDeleted {
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
_ = s.repo.MarkDone(ctx, job.ID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedDeleted, "实体已删除(删除即永久,不重爬)")
return
}
if !exist && p.Year == 0 {
@ -319,7 +344,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
reuseID = rid
if exist && isDeleted {
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
_ = s.repo.MarkDone(ctx, job.ID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedDeleted, "实体已删除(删除即永久,不重爬)")
return
}
}
@ -333,7 +358,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
return
}
}
_ = s.repo.MarkDone(ctx, job.ID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedExisting, "已存在(status="+existStatus+"),跳过重下")
return
}
@ -439,7 +464,11 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
}
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
_ = s.repo.MarkDone(ctx, job.ID)
outcome, note := model.IngestOutcomeCreated, fmt.Sprintf("新建 %d 张图", len(images))
if action == ingestReuseRejected {
outcome, note = model.IngestOutcomeReused, fmt.Sprintf("复用驳回记录,覆盖 %d 张图", len(images))
}
_ = s.repo.MarkDone(ctx, job.ID, outcome, note)
}
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
@ -581,13 +610,13 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
if exist && isDeleted {
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID)
_ = s.repo.MarkDone(ctx, job.ID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedDeleted, "街拍实体已删除(删除即永久,不重爬)")
return
}
action := decideIngestAction(exist, existStatus)
if action == ingestSkip {
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
_ = s.repo.MarkDone(ctx, job.ID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedExisting, "街拍已存在(status="+existStatus+"),跳过重下")
return
}
@ -655,7 +684,11 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
s.precomputeSimilar(ctx, dto.IngestKindStreet, embedIDs)
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
_ = s.repo.MarkDone(ctx, job.ID)
outcome, note := model.IngestOutcomeCreated, fmt.Sprintf("新建街拍 %d 张图", len(rows))
if action == ingestReuseRejected {
outcome, note = model.IngestOutcomeReused, fmt.Sprintf("复用驳回的街拍,覆盖 %d 张图", len(rows))
}
_ = s.repo.MarkDone(ctx, job.ID, outcome, note)
}
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。

View File

@ -17,7 +17,13 @@ func (fakeMediaRepo) CountImageKeyRefs(ctx context.Context, key string) (int, er
// fakeIngestRepo 仅实现 processMediaCleanup 需要的 MarkDone/MarkFailed(其余由内嵌 nil 接口兜底,不会被调用)。
type fakeIngestRepo struct{ repository.IngestRepository }
func (fakeIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
func (fakeIngestRepo) MarkDone(ctx context.Context, id uint32, outcome, note string) error {
return nil
}
func (fakeIngestRepo) ListByBrand(ctx context.Context, brandID uint32, limit int) ([]repository.BrandIngestRecord, error) {
return nil, nil
}
func (fakeIngestRepo) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
return nil
}