From c8a70760ccdd8b6b4e8ece3c1410bee44a748888 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Sun, 25 Oct 2026 11:06:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A5=E5=BA=93=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E8=90=BD=E5=BA=93=20+=20=E5=93=81=E7=89=8C=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E6=9B=B4=E6=96=B0=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:任务只有 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 供断言 --- .../2026-10-25-01-ingest-outcome.sql | 15 +++ internal/handler/backstage_brand_records.go | 105 ++++++++++++++++++ internal/handler/backstage_handler.go | 31 +++++- .../handler/templates/pages/brand-form.html | 23 ++++ internal/model/ingest_job.go | 17 +++ internal/repository/ingest_repository.go | 45 +++++++- internal/service/ingest_image_count_test.go | 16 ++- internal/service/ingest_service.go | 61 +++++++--- internal/service/ingest_service_test.go | 8 +- 9 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 db/migrations/2026-10-25-01-ingest-outcome.sql create mode 100644 internal/handler/backstage_brand_records.go diff --git a/db/migrations/2026-10-25-01-ingest-outcome.sql b/db/migrations/2026-10-25-01-ingest-outcome.sql new file mode 100644 index 0000000..f9aa474 --- /dev/null +++ b/db/migrations/2026-10-25-01-ingest-outcome.sql @@ -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); diff --git a/internal/handler/backstage_brand_records.go b/internal/handler/backstage_brand_records.go new file mode 100644 index 0000000..fb7fa77 --- /dev/null +++ b/internal/handler/backstage_brand_records.go @@ -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]) + "…" +} diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index 7704402..9b3af49 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -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 处理品牌编辑表单。 diff --git a/internal/handler/templates/pages/brand-form.html b/internal/handler/templates/pages/brand-form.html index 826285a..e7d8bea 100644 --- a/internal/handler/templates/pages/brand-form.html +++ b/internal/handler/templates/pages/brand-form.html @@ -11,5 +11,28 @@ + +{{if .UID}} +

更新记录

+

+ {{if .LastCrawl}}最近抓取:{{.LastCrawl}} · {{.CrawlStatus}}{{else}}还没有抓取记录{{end}} + (只显示最近 30 条入库记录;抓取侧每品牌只保留最近一次的状态) +

+{{if .Records}} + + + + {{range .Records}} + + + + + {{end}} + +
时间场次结果明细
{{.TimeText}}{{if .Title}}{{.Title}}{{else}}-{{end}}{{.OutcomeText}}{{.Detail}}
+{{else}} +

还没有入库记录。把品牌加入抓取队列后,每一场的处理结果(新建 / 已存在跳过 / 失败原因)都会出现在这里。

+{{end}} +{{end}} {{end}} diff --git a/internal/model/ingest_job.go b/internal/model/ingest_job.go index 854a507..7c1cc26 100644 --- a/internal/model/ingest_job.go +++ b/internal/model/ingest_job.go @@ -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" } diff --git a/internal/repository/ingest_repository.go b/internal/repository/ingest_repository.go index ded5799..2d3aa0a 100644 --- a/internal/repository/ingest_repository.go +++ b/internal/repository/ingest_repository.go @@ -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{}). diff --git a/internal/service/ingest_image_count_test.go b/internal/service/ingest_image_count_test.go index e087d60..0dcf6d9 100644 --- a/internal/service/ingest_image_count_test.go +++ b/internal/service/ingest_image_count_test.go @@ -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 { diff --git a/internal/service/ingest_service.go b/internal/service/ingest_service.go index 61c0f21..abcf366 100644 --- a/internal/service/ingest_service.go +++ b/internal/service/ingest_service.go @@ -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 列表, 是否有任意一张失败)。 diff --git a/internal/service/ingest_service_test.go b/internal/service/ingest_service_test.go index a83dc4e..e302317 100644 --- a/internal/service/ingest_service_test.go +++ b/internal/service/ingest_service_test.go @@ -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 }