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:
@ -20,6 +20,8 @@ type BrandService interface {
|
||||
// AdminList 后台品牌列表:字母索引不要求走秀已发布,
|
||||
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开 List 读视图,会挡掉它们)。
|
||||
AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
|
||||
// StatsByIDs 批量统计品牌的图集数 / 图片数(后台列表展示数据量)。
|
||||
StatsByIDs(ctx context.Context, ids []uint32) (map[uint32]repository.BrandStats, error)
|
||||
// Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。
|
||||
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
||||
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
|
||||
@ -201,6 +203,11 @@ func (s *brandService) Delete(ctx context.Context, uid string) error {
|
||||
|
||||
// CrawlTasks 返回爬虫取任务所需的品牌列表:编码 brand_uid + 英文品牌名。
|
||||
// 爬虫直接拿 brand_uid 上送 ingest,无需自己编码;name 用于拼 vogue 抓取 URL。
|
||||
// StatsByIDs 批量统计品牌的图集数 / 图片数(后台列表用)。
|
||||
func (s *brandService) StatsByIDs(ctx context.Context, ids []uint32) (map[uint32]repository.BrandStats, error) {
|
||||
return s.brands.StatsByIDs(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *brandService) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
|
||||
brands, err := s.brands.CrawlBrands(ctx, brandID)
|
||||
if err != nil {
|
||||
|
||||
83
internal/service/ingest_keypath_test.go
Normal file
83
internal/service/ingest_keypath_test.go
Normal file
@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
)
|
||||
|
||||
// TestFetchLookImagesUsesProvidedKeys 验证「媒体机直传」分支:
|
||||
// payload 里带 main_key / detail_keys 时,worker 不应下载、不应上传,
|
||||
// 而是直接把 key 当作要落库的存储地址,且 cover 取首张主图。
|
||||
//
|
||||
// 这条路径是媒体拆分的核心保证:一旦这里退化成下载 URL,
|
||||
// 后端又会重新背上带宽瓶颈,而错误会非常隐蔽(只是变慢,不报错)。
|
||||
func TestFetchLookImagesUsesProvidedKeys(t *testing.T) {
|
||||
s := &IngestService{} // 不装配 uploader/vision:走 key 分支时它们都不该被用到
|
||||
|
||||
looks := []dto.RunwayLook{
|
||||
{
|
||||
MainKey: "runway/aaaa.jpg",
|
||||
MainSize: 100,
|
||||
DetailKeys: []string{"runway/bbbb.jpg"},
|
||||
DetailSizes: []int64{200},
|
||||
},
|
||||
{
|
||||
// 第二张混用旧形态:没有 key,带 URL(这里只验主图,不触发网络)
|
||||
MainKey: "runway/cccc.jpg",
|
||||
MainSize: 300,
|
||||
},
|
||||
}
|
||||
|
||||
cover, rows, vecs, keys, failed, _ := s.fetchLookImages(context.Background(), looks, "runway")
|
||||
|
||||
if failed {
|
||||
t.Fatal("key 分支不应被判定为失败")
|
||||
}
|
||||
if cover != "runway/aaaa.jpg" {
|
||||
t.Fatalf("cover 应取首张主图 key,实际 %q", cover)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("应落 3 行(2 主图 + 1 细节图),实际 %d", len(rows))
|
||||
}
|
||||
if rows[0].Image != "runway/aaaa.jpg" || rows[0].IsDetail != 0 {
|
||||
t.Fatalf("第 1 行应为 aaaa 主图,实际 %+v", rows[0])
|
||||
}
|
||||
if rows[1].Image != "runway/bbbb.jpg" || rows[1].IsDetail != 1 {
|
||||
t.Fatalf("第 2 行应为 bbbb 细节图,实际 %+v", rows[1])
|
||||
}
|
||||
if rows[1].ImageSize != 200 || rows[0].ImageSize != 100 {
|
||||
t.Fatalf("字节数应来自 payload,实际 %d / %d", rows[0].ImageSize, rows[1].ImageSize)
|
||||
}
|
||||
// 媒体机上传的 key 不进回滚清单(内容寻址、可能与他人共享)
|
||||
if len(keys) != 0 {
|
||||
t.Fatalf("key 分支不应产生回滚清单,实际 %v", keys)
|
||||
}
|
||||
if len(vecs) != len(rows) {
|
||||
t.Fatalf("vecs 应与 rows 对齐,实际 %d vs %d", len(vecs), len(rows))
|
||||
}
|
||||
for i, v := range vecs {
|
||||
if v != nil {
|
||||
t.Fatalf("第 %d 张不应带向量(向量由 CLIP 回填),实际 %v", i, v)
|
||||
}
|
||||
}
|
||||
if got := countMainImages(rows); got != 2 {
|
||||
t.Fatalf("主图数应为 2,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSha1FromKey 锁住「从内容寻址 key 反推 sha1」的行为:批次内去重依赖它,
|
||||
// 若解析退化(例如带上目录前缀),同一张图会被重复落库。
|
||||
func TestSha1FromKey(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"runway/4080435af23d8f80bbbcf9e6f55a5f1d00c49685.jpg": "4080435af23d8f80bbbcf9e6f55a5f1d00c49685",
|
||||
"street/abc123.png": "abc123",
|
||||
"plain-key": "plain-key",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sha1FromKey(in); got != want {
|
||||
t.Fatalf("sha1FromKey(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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优先,失败兜底本地)。
|
||||
|
||||
Reference in New Issue
Block a user