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:
15
db/migrations/2026-10-25-01-ingest-outcome.sql
Normal file
15
db/migrations/2026-10-25-01-ingest-outcome.sql
Normal 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);
|
||||||
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")
|
c.Redirect(http.StatusFound, "/admin/brands")
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrandEdit 渲染品牌编辑表单(按编码 id 取现有值)。
|
// BrandEdit 渲染品牌编辑表单(按编码 id 取现有值)+ 该品牌的更新记录。
|
||||||
|
//
|
||||||
|
// 更新记录放在这里而不是单独做页面:品牌是这件事的主体,人打开一个品牌
|
||||||
|
// 想看的正是「它最近几次抓取/入库到底做了什么」,跳出去看列表反而多一步。
|
||||||
func (h *BackstageHandler) BrandEdit(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "品牌不存在或已删除: " + c.Param("id")})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "品牌不存在或已删除: " + uid})
|
||||||
return
|
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 处理品牌编辑表单。
|
// BrandUpdate 处理品牌编辑表单。
|
||||||
|
|||||||
@ -11,5 +11,28 @@
|
|||||||
<label>中文名(name_cn)</label><input name="name_cn" value="{{.NameCn}}" placeholder="如 艾克妮">
|
<label>中文名(name_cn)</label><input name="name_cn" value="{{.NameCn}}" placeholder="如 艾克妮">
|
||||||
<button type="submit">{{if .UID}}保存{{else}}创建{{end}}</button>
|
<button type="submit">{{if .UID}}保存{{else}}创建{{end}}</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -19,8 +19,25 @@ type IngestJob struct {
|
|||||||
LastError string `gorm:"column:last_error;type:text" json:"last_error"`
|
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
|
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 时间才可被领取
|
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 指定队列表名。
|
// TableName 指定队列表名。
|
||||||
func (IngestJob) TableName() string { return "ingest_jobs" }
|
func (IngestJob) TableName() string { return "ingest_jobs" }
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,13 @@ type IngestRepository interface {
|
|||||||
// Claim 原子领取最多 limit 条 pending 任务并置为 processing,返回这些任务。
|
// Claim 原子领取最多 limit 条 pending 任务并置为 processing,返回这些任务。
|
||||||
// 用 SKIP LOCKED 保证多 worker 不抢同一条。
|
// 用 SKIP LOCKED 保证多 worker 不抢同一条。
|
||||||
Claim(ctx context.Context, limit int) ([]model.IngestJob, error)
|
Claim(ctx context.Context, limit int) ([]model.IngestJob, error)
|
||||||
// MarkDone 标记任务成功。
|
// MarkDone 标记任务成功,并记录结果类型与人可读细节。
|
||||||
MarkDone(ctx context.Context, id uint32) error
|
//
|
||||||
|
// 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 标记任务失败并记录错误(attempts 自增)。
|
||||||
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
||||||
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
||||||
@ -165,17 +170,51 @@ func (r *ingestRepository) Claim(ctx context.Context, limit int) ([]model.Ingest
|
|||||||
return jobs, nil
|
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).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.IngestJob{}).
|
Model(&model.IngestJob{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": model.IngestStatusDone,
|
"status": model.IngestStatusDone,
|
||||||
|
"outcome": outcome,
|
||||||
|
"note": note,
|
||||||
"last_error": "",
|
"last_error": "",
|
||||||
"updated_at": uint32(time.Now().Unix()),
|
"updated_at": uint32(time.Now().Unix()),
|
||||||
}).Error
|
}).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 {
|
func (r *ingestRepository) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.IngestJob{}).
|
Model(&model.IngestJob{}).
|
||||||
|
|||||||
@ -20,6 +20,8 @@ type captureIngestRepo struct {
|
|||||||
createdRW *model.BrandRunway
|
createdRW *model.BrandRunway
|
||||||
createdImages []model.BrandRunwayImage
|
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) {
|
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
|
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 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
|
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
|
||||||
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
||||||
|
|||||||
@ -117,13 +117,26 @@ func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (u
|
|||||||
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
|
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
|
||||||
return 0, NewError(http.StatusBadRequest, "brand_uid required")
|
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 {
|
if err := s.repo.Enqueue(ctx, job); err != nil {
|
||||||
return 0, internalErr(err.Error())
|
return 0, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
return job.ID, nil
|
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 分页列出入库任务(后台监控页用)。返回本页任务、总条数与错误。
|
// ListJobs 分页列出入库任务(后台监控页用)。返回本页任务、总条数与错误。
|
||||||
func (s *IngestService) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, int64, error) {
|
func (s *IngestService) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, int64, error) {
|
||||||
jobs, err := s.repo.ListJobs(ctx, offset, limit)
|
jobs, err := s.repo.ListJobs(ctx, offset, limit)
|
||||||
@ -245,11 +258,11 @@ func (s *IngestService) processMediaCleanup(ctx context.Context, job model.Inges
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(p.Keys) == 0 {
|
if len(p.Keys) == 0 {
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeNoop, "媒体清理:无需处理")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
purgeOrphanImages(ctx, s.media, s.del, p.Keys)
|
purgeOrphanImages(ctx, s.media, s.del, p.Keys)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeCleaned, "媒体清理完成")
|
||||||
}
|
}
|
||||||
|
|
||||||
// failOrRetry 把临时失败(如网络抖动 / 单图下载失败)按指数退避自动重试:
|
// failOrRetry 把临时失败(如网络抖动 / 单图下载失败)按指数退避自动重试:
|
||||||
@ -266,11 +279,23 @@ func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg strin
|
|||||||
// processRunway 走秀入库:品牌校验 → 实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 brand_runways。
|
// processRunway 走秀入库:品牌校验 → 实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 brand_runways。
|
||||||
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||||
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
|
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
|
||||||
brandID, err := hashid.Decode(p.BrandUID)
|
//
|
||||||
if err != nil {
|
// 优先用「提交任务时就解好的 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")
|
_ = s.repo.MarkFailed(ctx, job.ID, "unknown brand_uid")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
brandID = id
|
||||||
|
}
|
||||||
if _, err := s.brandRepo.FindByID(ctx, brandID); err != nil {
|
if _, err := s.brandRepo.FindByID(ctx, brandID); err != nil {
|
||||||
// 必须区分「品牌真不存在」与「查询本身失败」:前者永久失败,后者应退避重试。
|
// 必须区分「品牌真不存在」与「查询本身失败」:前者永久失败,后者应退避重试。
|
||||||
// 曾把两者一律记为 "brand not found",导致数据库抖动时一批任务被误判为永久失败,
|
// 曾把两者一律记为 "brand not found",导致数据库抖动时一批任务被误判为永久失败,
|
||||||
@ -304,7 +329,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
|
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
|
||||||
if exist && isDeleted {
|
if exist && isDeleted {
|
||||||
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
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
|
return
|
||||||
}
|
}
|
||||||
if !exist && p.Year == 0 {
|
if !exist && p.Year == 0 {
|
||||||
@ -319,7 +344,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
reuseID = rid
|
reuseID = rid
|
||||||
if exist && isDeleted {
|
if exist && isDeleted {
|
||||||
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -333,7 +358,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedExisting, "已存在(status="+existStatus+"),跳过重下")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -439,7 +464,11 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
}
|
}
|
||||||
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||||
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
|
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 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
||||||
@ -581,13 +610,13 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
|
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
|
||||||
if exist && isDeleted {
|
if exist && isDeleted {
|
||||||
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
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
|
return
|
||||||
}
|
}
|
||||||
action := decideIngestAction(exist, existStatus)
|
action := decideIngestAction(exist, existStatus)
|
||||||
if action == ingestSkip {
|
if action == ingestSkip {
|
||||||
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -655,7 +684,11 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
s.precomputeSimilar(ctx, dto.IngestKindStreet, embedIDs)
|
s.precomputeSimilar(ctx, dto.IngestKindStreet, embedIDs)
|
||||||
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||||
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
|
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 列表, 是否有任意一张失败)。
|
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||||
|
|||||||
@ -17,7 +17,13 @@ func (fakeMediaRepo) CountImageKeyRefs(ctx context.Context, key string) (int, er
|
|||||||
// fakeIngestRepo 仅实现 processMediaCleanup 需要的 MarkDone/MarkFailed(其余由内嵌 nil 接口兜底,不会被调用)。
|
// fakeIngestRepo 仅实现 processMediaCleanup 需要的 MarkDone/MarkFailed(其余由内嵌 nil 接口兜底,不会被调用)。
|
||||||
type fakeIngestRepo struct{ repository.IngestRepository }
|
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 {
|
func (fakeIngestRepo) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user