feat: 抓取任务队列 + 媒体机直传 + 向量回填

crawl(品牌抓取任务队列)
- /admin/internal/crawl/tasks 领取 / :id/result 回报,spider 可常驻拉任务
- 后台品牌列表支持「加入抓取队列」,队列页可看状态与重试
- 租约超时的 running 自动回收为 queued,避免 worker 崩溃后任务卡死

media(媒体机直传)
- ingest 载荷新增 main_key/detail_keys/main_size/detail_sizes:
  有 key 就直接落库,跳过下载与上传;与 URL 形态可逐图混用
- 细节图按 Details/DetailKeys 两者较长者遍历(只给 key 时不再丢图)
- 媒体机上传的 key 不进失败回滚清单(内容寻址、可能与他人共享)
- /admin/internal/upload-url 签发 S4 PUT 预签名(spider 不持有 AK/SK)

embeddings(向量回填)
- GET /admin/internal/embeddings/pending 派发缺向量的图(含可直接下载的 url)
- POST /admin/internal/embeddings 回写向量并重算近邻;失败项计入
  image_embedding_failures,连续失败到上限后退出队列(坏图不占位)
- 近邻重算改为异步串行:同步会让 100 张的批次 POST 超时(实测 >60s)
- 新增 key 分支单测:混用 key/url、细节图只给 key、字节数、回滚清单、向量占位
This commit is contained in:
toom1996
2026-10-25 00:37:13 +08:00
parent e18c5a0815
commit 151e09ab30
22 changed files with 1535 additions and 28 deletions

View File

@ -5,6 +5,7 @@ import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@ -271,7 +272,15 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
return
}
if _, err := s.brandRepo.FindByID(ctx, brandID); err != nil {
_ = s.repo.MarkFailed(ctx, job.ID, "brand not found")
// 必须区分「品牌真不存在」与「查询本身失败」:前者永久失败,后者应退避重试。
// 曾把两者一律记为 "brand not found",导致数据库抖动时一批任务被误判为永久失败,
// 且真实错误(连接超时 / 池耗尽)在队列里完全看不到,排查时无线索。
log.Printf("[ingest][diag] job=%d brand_uid=%q decoded=%d err=%v", job.ID, p.BrandUID, brandID, err)
if errors.Is(err, repository.ErrNotFound) {
_ = s.repo.MarkFailed(ctx, job.ID, "brand not found")
return
}
s.failOrRetry(ctx, job.ID, "brand lookup: "+err.Error())
return
}
@ -454,22 +463,48 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
failed := false
// 摊平:顺序 = 逐个 look 先主图、再其细节图,与原串行遍历顺序完全一致。
// 每张图优先取「媒体机已上传的 key」,没有 key 才回落到「下载原始 URL」。
tasks := make([]lookImageTask, 0, len(looks))
for li, look := range looks {
lookIdx := li + 1
if look.Main != "" {
if look.MainKey != "" {
tasks = append(tasks, lookImageTask{key: look.MainKey, size: look.MainSize, lookIdx: lookIdx})
} else if look.Main != "" {
tasks = append(tasks, lookImageTask{url: look.Main, lookIdx: lookIdx})
}
for di, d := range look.Details {
if d == "" {
// 细节图数量取两个数组的较大者:媒体机直传时可能只给 DetailKeys 而不给 URL,
// 只按 Details 遍历会把这些细节图静默丢掉。
detailN := len(look.Details)
if len(look.DetailKeys) > detailN {
detailN = len(look.DetailKeys)
}
for di := 0; di < detailN; di++ {
var d string
if di < len(look.Details) {
d = look.Details[di]
}
hasKey := di < len(look.DetailKeys) && look.DetailKeys[di] != ""
if d == "" && !hasKey {
continue
}
tasks = append(tasks, lookImageTask{url: d, lookIdx: lookIdx, isDetail: true, detailNo: di + 1})
t := lookImageTask{lookIdx: lookIdx, isDetail: true, detailNo: di + 1}
if hasKey {
t.key = look.DetailKeys[di]
if di < len(look.DetailSizes) {
t.size = look.DetailSizes[di]
}
} else {
t.url = d
}
tasks = append(tasks, t)
}
}
// 并发段:下载 + 上传(耗时大头)。
// 并发段:下载 + 上传(耗时大头)。key 路径的图已在存储里,直接构造结果跳过 IO。
results := concurrentFetch(ctx, len(tasks), func(c context.Context, i int) downloadResult {
if k := tasks[i].key; k != "" {
return downloadResult{url: k, key: k, sha1: sha1FromKey(k), size: tasks[i].size}
}
return s.downloadAndUpload(c, tasks[i].url, prefix)
})
@ -484,7 +519,12 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
failed = true
continue
}
keys = append(keys, r.key)
// 只把「本次任务自己上传的 key」纳入回滚清单。
// 媒体机直传的 key 不进清单:它是内容寻址的,可能被别的图集共享,
// 失败时删掉会连带弄坏别人已入库的图;这类孤儿由存储侧清理策略负责。
if task.key == "" {
keys = append(keys, r.key)
}
if seen[r.sha1] {
continue
}
@ -830,9 +870,27 @@ func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) d
// 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。
type lookImageTask struct {
url string
lookIdx int // 1-based look 序号
isDetail bool // true=细节图
detailNo int // 细节图序号(1-based),主图为 0
key string // 非空 = 媒体机已上传,worker 跳过下载/上传,直接落库
size int64 // key 路径下由 spider 上报的字节数(0=未知)
lookIdx int // 1-based look 序号
isDetail bool // true=细节图
detailNo int // 细节图序号(1-based),主图为 0
}
// sha1FromKey 从内容寻址 key(runway/<sha1>.jpg 或 street/<sha1>.png)反推 sha1,
// 供批次内去重用;key 不符合该形态时退化为 key 本身(仍能起到去重作用)。
func sha1FromKey(key string) string {
base := key
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
if i := strings.LastIndex(base, "."); i > 0 {
base = base[:i]
}
if base == "" {
return key
}
return base
}
// downloadAndUpload 把单张远程图下载后上传到存储(S4优先,失败兜底本地)。