Files
backend_v2/internal/service/ingest_service.go
toom1996 e930ac41c8 feat: 爬虫页取代抓取任务/入库任务两页,抓取改全量+覆盖率跳过
一、后台只留一个「爬虫」页(/admin/spider)
抓取与入库本就是同一条流水线的上下游,分两页看谁都看不全。
新页三块:状态条(在线/卡住 + 队列数 + 待入库数)、正在抓(含进度与心跳)、
最近入库结果(品牌/场次/结果/说明)。只读,取消与重试一并下线。

二、抓取一律全量
删除品牌页的场次上限下拉与 hidden maxShows=3,入队固定 0(全量)。
crawl_tasks.max_shows 列保留,历史任务照常显示。

三、跳过判定改覆盖率
- brand_runways 增加 expected_images(spider 上报的应有张数)
- /shows/covered 返回 images + expected
- 实际张数 >= 应有 - 5 才跳过;expected=0 时回落 10 张阈值
- 实体已存在时不再直接跳过:按 key 比对把缺的图补进库(repaired)
  否则「判定不完整→重爬→后端跳过」会形成死循环,带宽白花、缺图永远补不上

四、租约 900→300 秒,且心跳算存活
只看 locked_at 会把跑了几小时的全量抓取误判成卡死;
加上 progress_at 后,真卡住 5 分钟才回收。
2026-10-25 23:59:54 +08:00

1171 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/season"
"fashionapi/internal/pkg/storage"
"fashionapi/internal/repository"
"fashionapi/internal/vision"
)
// IngestService 爬虫入库管线的业务逻辑层:Submit 入队,worker 异步处理。
//
// 队列复用一张 ingest_jobs 表,靠 kind 列分流两类任务:
// - crawl:爬虫上报的走秀/街拍入库(下载图 → 写草稿)
// - media_cleanup:删除图集时异步清理S4孤儿图(引用计数归零才真删)
type IngestService struct {
repo repository.IngestRepository
brandRepo repository.BrandRepository
media repository.MediaRepository // 跨表图片引用计数(media_cleanup 删孤儿用)
uploader storage.Uploader // 主上传器(S4启用时为S4,否则本地)
del storage.Deleter // 删除器(S4或本地,media_cleanup 真删用)
local *storage.LocalUploader // S4失败时的兜底落地
httpClient *http.Client
translate *TranslateService // 标题中文翻译(可为 nil:测试/未启用时跳过自动补全)
// 视觉服务与向量仓储(均可为 nil:未启用视觉服务时跳过 embed,不影响任何入库行为)。
// 只服务于后台「疑似同款」提示,不参与去重。
vision *vision.Client
embedRep repository.EmbeddingRepository
// similar 「疑似同款」预计算服务(可为 nil:装配期未注入或视觉服务未启用时跳过)。
// 写完向量后顺手把该图集的跨图集近邻算好落库,使后台页面只需读一次小查询。
// 注入用 SetSimilar,避免改动构造签名(该服务在主装配期晚于本服务创建)。
similar *SimilarService
}
// SetSimilar 注入「疑似同款」服务,供写向量后顺手预计算近邻。
// 装配期调用;未注入(nil)时完全跳过预计算,不影响入库。
func (s *IngestService) SetSimilar(sv *SimilarService) { s.similar = sv }
// NewIngestService 创建入库服务。
//
// uploader: 主上传器(S4或本地)
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string, translateSvc *TranslateService, vis *vision.Client, embedRep repository.EmbeddingRepository) *IngestService {
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
dlTransport := &http.Transport{
MaxIdleConns: imageFetchConcurrency * 4,
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
if downloadProxy != "" {
if pu, perr := url.Parse(downloadProxy); perr == nil {
dlTransport.Proxy = http.ProxyURL(pu)
log.Printf("[ingest] 图片下载走代理: %s", downloadProxy)
} else {
log.Printf("[ingest] download_proxy 非法,已忽略并回退 ProxyFromEnvironment: %q", downloadProxy)
dlTransport.Proxy = http.ProxyFromEnvironment
}
} else {
dlTransport.Proxy = http.ProxyFromEnvironment
}
return &IngestService{
repo: repo,
brandRepo: brandRepo,
media: media,
uploader: uploader,
del: del,
local: local,
translate: translateSvc,
vision: vis,
embedRep: embedRep,
// 图片下载是并发执行的(见 imageFetchConcurrency),连接池须匹配并发度:
// Go 默认 MaxIdleConnsPerHost=2,多余连接会在每轮下载时反复重建、白付 TLS 握手开销。
httpClient: &http.Client{
// 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。
// 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。
Timeout: 60 * time.Second,
Transport: dlTransport,
},
}
}
// Submit 把一条上报写入队列(payload 原样存 JSON),立即返回任务 id。
// 真正处理由 worker 异步完成,因此本方法本身很快、不阻塞爬虫。
func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (uint32, error) {
if payload.Kind == "" {
payload.Kind = dto.IngestKindRunway
}
raw, err := json.Marshal(payload)
if err != nil {
return 0, NewError(http.StatusBadRequest, "payload invalid: "+err.Error())
}
// runway 必须带品牌;street 不关联品牌,brand_uid 可选
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
return 0, NewError(http.StatusBadRequest, "brand_uid required")
}
// 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)
if err != nil {
return nil, 0, err
}
total, err := s.repo.CountJobs(ctx)
if err != nil {
return nil, 0, err
}
return jobs, total, nil
}
// RetryJob 把一条 failed 任务重新入队,等待 worker 重新处理。
func (s *IngestService) RetryJob(ctx context.Context, id uint32) error {
return s.repo.RetryJob(ctx, id)
}
// CountPendingJobs 待处理入库任务数(后台爬虫页状态条)。
func (s *IngestService) CountPendingJobs(ctx context.Context) (int64, error) {
return s.repo.CountPendingJobs(ctx)
}
// RecentCrawlResults 最近若干条入库结果(后台爬虫页的「最近入库结果」表)。
func (s *IngestService) RecentCrawlResults(ctx context.Context, limit int) ([]repository.CrawlResultRow, error) {
return s.repo.RecentCrawlResults(ctx, limit)
}
// Run 启动 worker 循环:定时领取 pending 任务并处理,直到 ctx 取消。
// 多实例安全:Claim 用 SKIP LOCKED,互不抢同一条。
func (s *IngestService) Run(ctx context.Context, batch int, interval time.Duration) {
if batch < 1 {
batch = 2
}
if interval <= 0 {
interval = 3 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.drain(ctx, batch)
}
}
}
// drain 一次性领取并处理一批任务。
func (s *IngestService) drain(ctx context.Context, batch int) {
jobs, err := s.repo.Claim(ctx, batch)
if err != nil {
return
}
for i := range jobs {
s.process(ctx, jobs[i])
}
}
// process 处理单条任务:先按 job.Kind(DB 列)分流到 crawl / media_cleanup 两条管线。
// 旧爬虫任务(迁移前)kind 为空,回落 crawl 走秀/街拍入库管线。
func (s *IngestService) process(ctx context.Context, job model.IngestJob) {
start := time.Now()
switch job.Kind {
case model.IngestKindMediaCleanup:
s.processMediaCleanup(ctx, job)
return
case "", model.IngestKindCrawl:
// 走秀/街拍入库管线,按 payload 内 Kind 再分流 runway / street。
default:
// 未知 job.Kind 绝不静默处理,标记失败避免脏数据。
_ = s.repo.MarkFailed(ctx, job.ID, "unknown job kind: "+job.Kind)
return
}
var p dto.RunwayIngest
if err := json.Unmarshal([]byte(job.Payload), &p); err != nil {
_ = s.repo.MarkFailed(ctx, job.ID, "payload parse: "+err.Error())
return
}
if p.Kind == "" {
p.Kind = dto.IngestKindRunway
}
log.Printf("[ingest] job=%d kind=%s 开始处理(图片=%d 张)", job.ID, p.Kind, len(p.Images))
switch p.Kind {
case dto.IngestKindRunway:
s.processRunway(ctx, job, p)
case dto.IngestKindStreet:
s.processStreet(ctx, job, p)
default:
// 未知 kind 绝不静默当成走秀处理;所有爬虫数据都必须落到已注册的审核模块,
// 否则标记任务失败,避免出现「未审核就入库」的脏数据。
_ = s.repo.MarkFailed(ctx, job.ID, "unknown ingest kind: "+p.Kind)
return
}
log.Printf("[ingest] job=%d kind=%s 处理结束 总耗时=%v", job.ID, p.Kind, time.Since(start).Round(time.Millisecond))
}
// MediaCleanupPayload 是「清理S4孤儿图」任务的 payload:待清理的图片 key 列表。
type MediaCleanupPayload struct {
Keys []string `json:"keys"`
}
// EnqueueMediaCleanup 把一批待清理的S4 key 异步入队;真正删除由 worker 的
// processMediaCleanup 按引用计数判定,仅当 key 在所有图集/草稿表中引用归零才真删
// (内容寻址共享 key 不会被误删)。
func (s *IngestService) EnqueueMediaCleanup(ctx context.Context, keys []string) error {
if len(keys) == 0 {
return nil
}
raw, err := json.Marshal(MediaCleanupPayload{Keys: keys})
if err != nil {
return err
}
return s.repo.EnqueueMediaCleanup(ctx, string(raw))
}
// processMediaCleanup 清理S4孤儿图任务:解析 key 列表,按跨表引用计数删除真孤儿。
// 删除/计数失败一律保守跳过(宁可留文件),因此本任务几乎总是成功置 done。
func (s *IngestService) processMediaCleanup(ctx context.Context, job model.IngestJob) {
if s.media == nil || s.del == nil {
_ = s.repo.MarkFailed(ctx, job.ID, "media cleanup not configured")
return
}
var p MediaCleanupPayload
if err := json.Unmarshal([]byte(job.Payload), &p); err != nil {
_ = s.repo.MarkFailed(ctx, job.ID, "media cleanup payload parse: "+err.Error())
return
}
if len(p.Keys) == 0 {
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeNoop, "媒体清理:无需处理")
return
}
purgeOrphanImages(ctx, s.media, s.del, p.Keys)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeCleaned, "媒体清理完成")
}
// failOrRetry 把临时失败(如网络抖动 / 单图下载失败)按指数退避自动重试:
// attempts 未达上限则重置为 pending 并推迟 next_attempt_at,worker 到点再领;
// 达上限才置 failed,等待后台手动「重试」。永久失败(payload 解析错 / 未知 kind)直接
// MarkFailed,不进重试。
func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg string) {
if err := s.repo.ScheduleRetry(ctx, id, errMsg); err != nil {
// 调度失败兜底硬失败,避免任务卡在 processing 无人收。
_ = s.repo.MarkFailed(ctx, id, errMsg)
}
}
// processRunway 走秀入库:品牌校验 → 实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 brand_runways。
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
//
// 优先用「提交任务时就解好的 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 {
// 必须区分「品牌真不存在」与「查询本身失败」:前者永久失败,后者应退避重试。
// 曾把两者一律记为 "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
}
// 2) 实体键查重并决定分支(单表模型)
// 标题缺年份(p.Year=0)时,用发布日期的年份补全年份,使 year / season_code 不再为空,
// 避免这些秀在「按年份/季节筛选」与热门排序(按 year + season_code)中丢失。
effYear := p.Year
if effYear == 0 && p.PublishedAt != 0 {
effYear = uint16(time.Unix(int64(p.PublishedAt), 0).UTC().Year())
}
seasonCode := season.Derive(effYear, p.CollectionType, p.Season)
reuseID, existStatus, exist, isDeleted, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
if err != nil {
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
//(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending)
// 而直接跳过。但唯一索引是最后一道防线,不该靠它来替代这里的前置判断,
// 故仍 fail-closed + 退避重试,不做无谓的写尝试。
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
return
}
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
if exist && isDeleted {
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedDeleted, "实体已删除(删除即永久,不重爬)")
return
}
if !exist && p.Year == 0 {
// 标题无年份的历史行(year=0、season_code 为空)待回填:按旧键再查一次,
// 命中后走跳过分支按主键 id 回填(回填后season_code变为effYear码,后续重跑即可直接命中,幂等)。
var rid uint32
rid, existStatus, exist, isDeleted, err = s.repo.RunwayEntityState(ctx, brandID, "", p.CollectionType)
if err != nil {
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
return
}
reuseID = rid
if exist && isDeleted {
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedDeleted, "实体已删除(删除即永久,不重爬)")
return
}
}
action := decideIngestAction(exist, existStatus)
// 季节码漂移保护:实体键按 season_code 去重,但规则改过(Spring 男装由 FWyy 改为 SSyy)。
// 若还有实例按旧规则算码,就会把同一场秀当成新场次再建一行。
// 这里在建行前用「同品牌同标题」再兜一次底,宁可跳过也不产生重复。
if !exist && action != ingestSkip {
if dupID, found, terr := s.repo.RunwayTitleExists(ctx, brandID, p.TitleEn); terr == nil && found {
log.Printf("[ingest] job=%d runway 已存在同标题图集 id=%d(季节码可能漂移),跳过", job.ID, dupID)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedExisting,
fmt.Sprintf("已存在同标题图集(id=%d),跳过(季节码漂移保护)", dupID))
return
}
}
if action == ingestSkip {
// 实体已存在 ≠ 抓全了:先按 key 比对把缺的图补上。
//
// 不补的话,「覆盖率判定说这场不完整 → spider 重爬 → 后端直接跳过」会形成死循环:
// 每次重爬都把整场图下一遍,库里却一张不增,缺的图永远补不上。
added, mErr := s.mergeMissingImages(ctx, reuseID, brandID, p)
if mErr != nil {
s.failOrRetry(ctx, job.ID, "merge missing images: "+mErr.Error())
return
}
// 回填发布时间与缺失年份(不重下图片)。
if p.PublishedAt != 0 {
if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil {
s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error())
return
}
}
if added > 0 {
log.Printf("[ingest] job=%d runway 实体已存在,补齐 %d 张缺图", job.ID, added)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeRepaired,
fmt.Sprintf("已存在(status=%s),补齐 %d 张缺图", existStatus, added))
return
}
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),图片齐全,跳过", job.ID, existStatus)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedExisting, "已存在(status="+existStatus+"),跳过重下")
return
}
// 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底)
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
var cover string
var images []model.BrandRunwayImage
// vecs 与 images 下标一一对应(nil = 该张未产出向量);落库拿到图片 id 后写入 image_embeddings。
var vecs [][]float32
var keys []string
var imgFailed bool
var imageCount uint16
var timing *fetchTiming
if len(p.Looks) > 0 {
cover, images, vecs, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
// image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks):
// fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。
// 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks),
// 漂移会永久留在公开卡片的「N 张」角标与 FeaturedIDs 的 SUM(image_count) 热度上。
imageCount = countMainImages(images)
} else {
var fimgs []fetchedImage
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway")
imageCount = uint16(len(fimgs))
for i, fi := range fimgs {
images = append(images, model.BrandRunwayImage{
Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
IsDetail: 0,
ImageSize: fi.size,
})
vecs = append(vecs, fi.vec)
}
}
log.Printf("[ingest] job=%d runway 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing)
if imgFailed {
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
s.cleanupUploads(ctx, keys)
s.failOrRetry(ctx, job.ID, "image download failed")
return
}
// 5) 直写正式表(status=pending),等待后台审核通过(审核只改状态,不重建图片)
writeStart := time.Now()
rw := &model.BrandRunway{
JobID: job.ID,
BrandID: brandID,
TitleEn: p.TitleEn,
TitleCn: p.TitleCn,
DescriptionEn: p.DescriptionEn,
DescriptionCn: p.DescriptionCn,
Year: effYear,
Season: p.Season,
CollectionType: p.CollectionType,
SeasonCode: seasonCode,
PublishedAt: p.PublishedAt,
Cover: cover,
ImageCount: imageCount,
// 应有张数取「spider 上报值」与「本次实际入库数」的较大者:
// 去重会砍掉重复图,payload 里的总数可能偏高;而实际入库数就是本次能拿到的全部。
// 取较大者保证覆盖率判定不会因为去重而永远判「不完整」。
ExpectedImages: maxInt(p.ExpectedImages, int(imageCount)),
Status: model.StatusPending,
}
// 入库自动补全中文标题:spider 未给 title_cn 时,用翻译服务按「记忆优先、规则兜底」推导;
// 已给则保留(绝不覆盖爬虫提供/已有的翻译)。翻译服务未注入(nil)时跳过。
if rw.TitleCn == "" && s.translate != nil {
if cn, terr := s.translate.SuggestTitleCn(ctx, rw.TitleEn); terr == nil {
rw.TitleCn = cn
}
}
for i := range images {
images[i].BrandID = brandID
}
var saveErr error
if action == ingestReuseRejected {
saveErr = s.repo.ReuseRejectedRunway(ctx, reuseID, rw, images)
} else {
_, saveErr = s.repo.CreateRunwayWithImages(ctx, rw, images)
}
if saveErr != nil {
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
return
}
// 落库成功后写入语义向量(仅后台「疑似同款」用)。GORM 批量 Create 会把自增主键回填到
// images,因此此处 images[i].ID 即新建图片 id;写失败只记日志,绝不影响入库结果。
embedIDs := make([]uint32, 0, len(images))
for i := range images {
embedIDs = append(embedIDs, images[i].ID)
}
s.saveEmbeddings(ctx, dto.IngestKindRunway, embedIDs, vecs)
// 顺手把本场的「疑似同款」近邻算好落库(失败不影响入库)。
s.precomputeSimilar(ctx, dto.IngestKindRunway, embedIDs)
// 入库后按 sort_order 回填细节图的 parent_image_id,使新走秀自动成组(与街拍一致)。
runwayID := rw.ID
if runwayID == 0 {
runwayID = reuseID
}
if rerr := s.repo.RepairRunwayDetailParents(ctx, runwayID); rerr != nil {
log.Printf("[ingest] job=%d runway 回填 parent 失败: %v", job.ID, rerr)
}
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
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 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
// BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
//
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
// 「下载 → 上传」这段 IO 密集操作;随后再按下标顺序串行做批次内去重与组装。
// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, [][]float32, []string, bool, *fetchTiming) {
start := time.Now()
timing := &fetchTiming{}
cover := ""
rows := make([]model.BrandRunwayImage, 0)
// vecs 与 rows 下标一一对应(nil 表示该张未产出向量),供落库后写入 image_embeddings。
vecs := make([][]float32, 0)
keys := make([]string, 0)
seen := make(map[string]bool)
failed := false
// 摊平:顺序 = 逐个 look 先主图、再其细节图,与原串行遍历顺序完全一致。
// 每张图优先取「媒体机已上传的 key」,没有 key 才回落到「下载原始 URL」。
tasks := make([]lookImageTask, 0, len(looks))
for li, look := range looks {
lookIdx := li + 1
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})
}
// 细节图数量取两个数组的较大者:媒体机直传时可能只给 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
}
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)
})
// 串行段:按原始顺序累计耗时、做批次内去重、组装行。
order := 0
for i, r := range results {
task := tasks[i]
timing.download += r.download
timing.upload += r.upload
timing.embed += r.embed
if r.err != nil {
failed = true
continue
}
// 只把「本次任务自己上传的 key」纳入回滚清单。
// 媒体机直传的 key 不进清单:它是内容寻址的,可能被别的图集共享,
// 失败时删掉会连带弄坏别人已入库的图;这类孤儿由存储侧清理策略负责。
if task.key == "" {
keys = append(keys, r.key)
}
if seen[r.sha1] {
continue
}
seen[r.sha1] = true
order++
if cover == "" && !task.isDetail {
cover = r.url
}
name := fmt.Sprintf("Look %d", task.lookIdx)
isDetail := uint8(0)
if task.isDetail {
name = fmt.Sprintf("Look %d — Detail %d", task.lookIdx, task.detailNo)
isDetail = 1
}
rows = append(rows, model.BrandRunwayImage{
Image: r.url,
Name: name,
SortOrder: uint32(order),
IsDetail: isDetail,
ImageSize: r.size,
})
vecs = append(vecs, r.vec)
}
timing.images = len(rows)
timing.total = time.Since(start)
return cover, rows, vecs, keys, failed, timing
}
// maxInt 取两数较大者(expected_images 只增不减)。
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// mergeMissingImages 把 payload 里「库里还没有」的图片补进一场已存在的秀,返回补了几张。
//
// 为什么不用下载:媒体机直传时 payload 带的是 `main_key` / `detail_keys`(内容 sha1),
// 库里的 `image` 列存的也是同一个 key,所以「这张图有没有」是一次纯内存查表。
// 图片本来就已经在对象存储里了,补的是**数据库行**,不产生任何网络流量。
//
// 老爬虫只上报 URL(没有 key)时无法比对——那种情况直接返回 0,
// 由「覆盖率判定」保证新爬虫一定上报 key,从而让补齐这条路可用。
func (s *IngestService) mergeMissingImages(ctx context.Context, runwayID, brandID uint32, p dto.RunwayIngest) (int, error) {
if runwayID == 0 || len(p.Looks) == 0 {
return 0, nil
}
st, err := s.repo.RunwayImageState(ctx, runwayID)
if err != nil {
return 0, err
}
added := 0
order := st.MaxSort
for li, look := range p.Looks {
lookNo := li + 1
// 主图:已有就复用 id(细节图要挂它),没有就新建一行。
var parentID uint32
if look.MainKey != "" {
if id, ok := st.Keys[look.MainKey]; ok {
parentID = id
} else {
order++
img := model.BrandRunwayImage{
RunwayID: runwayID,
BrandID: brandID,
Image: look.MainKey,
Name: fmt.Sprintf("Look %d", lookNo),
SortOrder: order,
IsDetail: 0,
ImageSize: look.MainSize,
}
if err := s.repo.CreateRunwayImage(ctx, &img); err != nil {
return added, err
}
parentID = img.ID
st.Keys[look.MainKey] = img.ID
added++
}
}
// 细节图:必须能挂到父主图,否则宁可跳过(挂不上的细节图在后台无法展开)。
if parentID == 0 {
continue
}
for di, dk := range look.DetailKeys {
if dk == "" {
continue
}
if _, ok := st.Keys[dk]; ok {
continue
}
order++
img := model.BrandRunwayImage{
RunwayID: runwayID,
BrandID: brandID,
Image: dk,
Name: fmt.Sprintf("Look %d — Detail %d", lookNo, di+1),
SortOrder: order,
IsDetail: 1,
ParentImageID: parentID,
ImageSize: sizeAt(look.DetailSizes, di),
}
if err := s.repo.CreateRunwayImage(ctx, &img); err != nil {
return added, err
}
st.Keys[dk] = img.ID
added++
}
}
if added > 0 || p.ExpectedImages > 0 {
if err := s.repo.SyncRunwayImageStats(ctx, runwayID, p.ExpectedImages); err != nil {
return added, err
}
}
return added, nil
}
// sizeAt 越界时返回 0(spider 可能只上报部分 size)。
func sizeAt(sizes []int64, i int) int64 {
if i >= 0 && i < len(sizes) {
return sizes[i]
}
return 0
}
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。
func countMainImages(images []model.BrandRunwayImage) uint16 {
var n uint16
for i := range images {
if images[i].IsDetail == 0 {
n++
}
}
return n
}
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 实体键查重并决定分支(单表模型,同 runway)。
// 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致,
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
title := repository.NormalizeStreetTitle(p.TitleEn)
reuseID, existStatus, exist, isDeleted, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title)
if err != nil {
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
return
}
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
if exist && isDeleted {
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", 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, model.IngestOutcomeSkippedExisting, "街拍已存在(status="+existStatus+"),跳过重下")
return
}
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street")
log.Printf("[ingest] job=%d street 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing)
if len(p.Images) > 0 && imgFailed {
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
s.cleanupUploads(ctx, keys)
s.failOrRetry(ctx, job.ID, "image download failed")
return
}
// 3) 直写正式表(status=pending)
writeStart := time.Now()
// 街拍入库自动补全中文标题(同 runway:spider 未给才补,且翻译服务需注入)。
titleCn := p.TitleCn
if titleCn == "" && s.translate != nil {
if cn, terr := s.translate.SuggestTitleCn(ctx, title); terr == nil {
titleCn = cn
}
}
snap := &model.StreetSnap{
JobID: job.ID,
Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
TitleCn: titleCn,
Year: p.Year,
City: p.City,
Cover: cover,
ImageCount: uint16(len(fimgs)),
Status: model.StatusPending,
}
rows := make([]model.StreetSnapImage, 0, len(fimgs))
// vecs 与 rows 下标一一对应(nil = 该张未产出向量);落库拿到图片 id 后写入 image_embeddings。
vecs := make([][]float32, 0, len(fimgs))
for i, fi := range fimgs {
rows = append(rows, model.StreetSnapImage{
Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
ImageSize: fi.size,
})
vecs = append(vecs, fi.vec)
}
var saveErr error
if action == ingestReuseRejected {
saveErr = s.repo.ReuseRejectedStreetSnap(ctx, reuseID, snap, rows)
} else {
_, saveErr = s.repo.CreateStreetSnapWithImages(ctx, snap, rows)
}
if saveErr != nil {
s.failOrRetry(ctx, job.ID, "save street snap: "+saveErr.Error())
return
}
// 落库成功后写入语义向量(同走秀:GORM 批量 Create 回填自增主键,故 rows[i].ID 即新建图片 id)。
embedIDs := make([]uint32, 0, len(rows))
for i := range rows {
embedIDs = append(embedIDs, rows[i].ID)
}
s.saveEmbeddings(ctx, dto.IngestKindStreet, embedIDs, vecs)
// 顺手把本专辑的「疑似同款」近邻算好落库(失败不影响入库)。
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)
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 列表, 是否有任意一张失败)。
// prefix 为S4 key 前缀(runway/ 或 street/)。
// 只要任意一张下载/上传失败,failed 即置 true,调用方据此把整条任务判为失败并回滚本批已上传的对象,
// 符合「单图失败=整任务失败」策略。
//
// 实现:先用 concurrentFetch 并发完成全部图片的「下载 → 上传」,再按下标顺序串行
// 做批次内去重与组装,保证 cover / 顺序 / 批次内去重与原串行实现一致。
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []fetchedImage, []string, bool, *fetchTiming) {
start := time.Now()
timing := &fetchTiming{}
cover := ""
out := make([]fetchedImage, 0, len(urls))
keys := make([]string, 0, len(urls))
seen := make(map[string]bool)
failed := false
// 并发段:下载 + 上传(耗时大头)。
results := concurrentFetch(ctx, len(urls), func(c context.Context, i int) downloadResult {
return s.downloadAndUpload(c, urls[i], prefix)
})
// 串行段:按原始顺序累计耗时、做批次内去重、组装结果。
for _, r := range results {
timing.download += r.download
timing.upload += r.upload
timing.embed += r.embed
if r.err != nil {
failed = true
continue
}
keys = append(keys, r.key)
if seen[r.sha1] {
continue
}
seen[r.sha1] = true
if cover == "" {
cover = r.url
}
out = append(out, fetchedImage{
url: r.url,
key: r.key,
size: r.size,
vec: r.vec,
})
}
timing.images = len(out)
timing.total = time.Since(start)
return cover, out, keys, failed, timing
}
// precomputeSimilar 写完向量后顺手预计算「疑似同款」近邻并落库。
//
// 为什么放在这里:原先是在**打开图集页时**对每张图实时做向量近邻检索,一个 116 张图的
// 图集要发 116 次查询、每次约 20KB SQL;本机 PostgreSQL 跑在 WSL2 里、Windows 经 wslrelay
// 访问,该链路大 payload 吞吐实测约 0.47MB/s,合计 5 秒。挪到入库时算一次(每个图集只付一次),
// 页面就退化成一次小查询。
//
// 失败只记日志:预计算是增值数据,绝不能让「算不出同款」影响入库结果。
// 注意:这里统计的是本次新入库的图片;它们与**其他图集存量图**的配对由预计算内部双向写入,
// 因此对方图集的页面也能立刻看到提示,无需重算对方。
func (s *IngestService) precomputeSimilar(ctx context.Context, kind string, imageIDs []uint32) {
if s.similar == nil || len(imageIDs) == 0 {
return
}
start := time.Now()
if err := s.similar.PrecomputeForGallery(ctx, kind, imageIDs); err != nil {
log.Printf("[ingest] 「疑似同款」预计算失败 kind=%s 图=%d err=%v(不影响入库)", kind, len(imageIDs), err)
return
}
log.Printf("[ingest] 「疑似同款」预计算耗时 kind=%s 图=%d 耗时=%v", kind, len(imageIDs), time.Since(start).Round(time.Millisecond))
}
// saveEmbeddings 落库成功后写入一批图片的语义向量(仅后台「疑似同款」提示用)。
//
// 向量是增值数据、不是入库的必要条件:仓储未注入(未启用视觉服务)、无向量、
// 或写入失败,都只记日志并返回,绝不影响入库结果。ids[i] 与 vecs[i] 一一对应。
func (s *IngestService) saveEmbeddings(ctx context.Context, kind string, ids []uint32, vecs [][]float32) {
if s.embedRep == nil || len(ids) == 0 || len(vecs) == 0 {
return
}
if err := s.embedRep.BulkUpsertEmbedding(ctx, kind, ids, vecs); err != nil {
log.Printf("[ingest] 写向量失败 kind=%s 数量=%d err=%v(不影响入库)", kind, len(ids), err)
}
}
// cleanupUploads 删除一批本批次成功上传的对象(S4 + 本地兜底),用于任务失败回滚:
// 「单图失败=整任务失败」时,前面已成功上传的图若放任不管就成了孤儿,永远堆在存储里。
// 对两种存储都尝试删除(任一不存在即按幂等成功处理);删除失败仅告警,不阻断任务置失败。
// 注意:key 为 sha1 内容寻址,若恰与其他已晋升图集共享同一内容哈希会被一并移除,重试会重新上传补齐。
func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
for _, k := range keys {
if d, ok := s.uploader.(storage.Deleter); ok {
if err := d.Delete(ctx, k); err != nil {
log.Printf("[warn] ingest cleanup: S4删除失败 key=%s err=%v", k, err)
}
}
if s.local != nil {
if err := s.local.Delete(ctx, k); err != nil {
log.Printf("[warn] ingest cleanup: 本地删除失败 key=%s err=%v", k, err)
}
}
}
}
// fetchedImage 是单张已下载图片的暂存结构。
type fetchedImage struct {
url string
key string
size int64 // 原图源文件字节数,随结果带回供落库写入 image_size
vec []float32 // FashionSigLIP 向量(nil = 未产出),供落库后写入 image_embeddings
}
// slowImageThreshold 单张图各阶段合计超过该阈值时单独打一条告警,便于从大量图里定位异常慢图。
const slowImageThreshold = 2 * time.Second
// imageFetchConcurrency 单条入库任务内「下载 + 上传」的并发度。
//
// 默认 1(等效串行),可用环境变量 INGEST_IMAGE_CONCURRENCY(1~16)覆盖。
//
// 之所以默认 1:早期实测 theimpression.com(59 张图)显示源站对同 IP 并发有强限流——
// 并发 1 时单张 ~3.7s / 整批 3m54s;并发 5 时单张涨到 ~23s,5m24s 只完成 12/59 张
// 就因超时失败,吞吐从 ~0.27 张/秒掉到 ~0.04 张/秒。结论:并发会把任务拖垮,故取 1。
//
// 但该结论**只对 theimpression 成立,不能外推到所有源站**。2026-10-13 对 assets.vogue.com
// 的冷图(未命中 CDN 边缘)实测:串行 1582ms/张,8 并发降到 661ms/张(2.4x),并发是有收益的。
// 冷图慢是因为 CDN 要回源,属延迟受限而非带宽受限——正好是并发能掩盖的那一类。
//
// 因此把它做成可配置项:换源站时先小批量实测再调(本文件顶部注释记录了两组实测数据)。
var imageFetchConcurrency = func() int {
v := strings.TrimSpace(os.Getenv("INGEST_IMAGE_CONCURRENCY"))
if v == "" {
return 1
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 16 {
log.Printf("[ingest] INGEST_IMAGE_CONCURRENCY=%q 非法(应为 1~16 的整数),回退并发 1", v)
return 1
}
return n
}()
// fetchTiming 汇总一次图集拉取各阶段的累计耗时,用于定位「慢在哪一步」。
// 下载与上传是网络 IO,embed 是视觉服务调用(HTTP + 模型推理),分开计时才能对症下药。
type fetchTiming struct {
images int // 成功入行的图片数
total time.Duration // 整个拉取过程的墙钟耗时
download time.Duration // 各张 HTTP 下载耗时之和
upload time.Duration // 各张上传存储耗时之和
embed time.Duration // 各张视觉服务编码耗时之和
}
// String 输出各阶段耗时(毫秒精度),一条日志看清瓶颈。
// 注意:下载 / 上传 / embed 是「各张图耗时之和」,并发执行下其总和会大于墙钟总耗时,
// 因此三者与「总耗时」的比值不再等于时间占比——它们用于横向对比哪个阶段最重。
func (t *fetchTiming) String() string {
if t == nil {
return ""
}
return fmt.Sprintf("图片=%d 并发=%d 下载=%v 上传=%v embed=%v(各张累计值)",
t.images,
imageFetchConcurrency,
t.download.Round(time.Millisecond),
t.upload.Round(time.Millisecond),
t.embed.Round(time.Millisecond),
)
}
// downloadResult 是单张图「下载 → 上传」的结果,供并发执行后按下标串行组装。
// 各阶段耗时随结果一并带回、由调用方在串行段汇总(而非直接累加到共享 timing),
// 这样并发路径上无需加锁即可保证计时准确。
type downloadResult struct {
url string // 存储访问地址(失败为空)
key string // 对象 key(内容寻址)
sha1 string // 内容 sha1,用于批次内去重
download time.Duration // 本张下载耗时
upload time.Duration // 本张上传耗时
size int64 // 原图源文件字节数(io.ReadAll 读到的下载响应体长度)
vec []float32 // FashionSigLIP 向量(nil = 未产出:服务未启用/不可用/失败)
embed time.Duration // 本张向量编码耗时
err error // 下载或上传失败原因(非空即该张失败)
}
// concurrentFetch 以 imageFetchConcurrency 路并发执行 fn(ctx, i)(i ∈ [0,n)),返回长度 n、
// 下标与入参一一对齐的结果切片。
//
// 下标对齐是刻意的:调用方随后按原始顺序串行组装,使 cover / sort_order / 批次内去重结果
// 与串行实现完全一致,只把「下载+上传」这段 IO 并行化。
// 每个下标只由一个 goroutine 写入,故无需加锁。
func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) downloadResult) []downloadResult {
results := make([]downloadResult, n)
if n == 0 {
return results
}
sem := make(chan struct{}, imageFetchConcurrency)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{} // 占坑:超过并发度即在此排队
defer func() { <-sem }() // 释放坑位
results[i] = fn(ctx, i)
}(i)
}
wg.Wait()
return results
}
// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、
// 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。
type lookImageTask struct {
url string
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优先,失败兜底本地)。
// 内容 sha1 用于生成内容寻址的存储 key(重爬幂等)与批次内去重;访问地址可直接写入数据库
// (S4为完整 https URL,本地为相对 /uploads 路径)。
//
// 本函数是并发调用点:每次调用各自返回一份独立的 downloadResult,不共享可变状态;
// 耗时汇总与去重留痕由调用方在串行段统一完成。
func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string) downloadResult {
res := downloadResult{}
// ① 下载
dlStart := time.Now()
resp, err := s.httpClient.Get(u)
if err != nil {
res.err = err
return res
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
res.err = fmt.Errorf("status %d", resp.StatusCode)
return res
}
data, err := io.ReadAll(resp.Body)
if err != nil {
res.err = err
return res
}
res.download = time.Since(dlStart)
res.size = int64(len(data))
ext := path.Ext(u)
if ext == "" || len(ext) > 5 {
ext = ".jpg"
}
// 内容哈希作 key:同一张图(无论来自哪篇文章/job)永远得到相同 key,
// S4覆盖写即天然幂等,不会因重复采集、崩溃重试、reject 重爬而累积孤儿文件。
h := sha1.Sum(data)
res.sha1 = hex.EncodeToString(h[:])
res.key = fmt.Sprintf("%s/%s%s", prefix, res.sha1, ext)
ct := resp.Header.Get("Content-Type")
// ② 视觉服务编码(可选):产出后台「疑似同款」用的语义向量。
// 未启用 / 不可用 / 超时 / 维度不符一律留空 vec —— 绝不阻断入库,也不在此重试
// (重试会拖慢整批;熔断由客户端统一处理)。
if s.vision != nil && s.vision.Enabled() {
embedStart := time.Now()
vecs, _, eerr := s.vision.Embed(ctx, [][]byte{data})
res.embed = time.Since(embedStart)
if eerr == nil && len(vecs) > 0 {
res.vec = vecs[0]
} else if eerr != nil {
log.Printf("[ingest] embed 失败 url=%s err=%v(不写向量,继续入库)", u, eerr)
}
}
// ③ 上传(主存储 S4,失败兜底本地)
upStart := time.Now()
if url, uerr := s.uploader.Upload(ctx, res.key, data, ct); uerr == nil {
res.url = url
} else if s.local != nil {
// 兜底本地,避免图片完全丢失
if lurl, lerr := s.local.Upload(ctx, res.key, data, ct); lerr == nil {
res.url = lurl
} else {
res.err = uerr
}
} else {
res.err = uerr
}
res.upload = time.Since(upStart)
// 单图异常慢(合计超阈值)单独告警:便于从几十上百张图里一眼锁定是哪张、卡在哪一段。
if total := res.download + res.embed + res.upload; total > slowImageThreshold {
log.Printf("[ingest] 慢图 url=%s 合计=%v 下载=%v embed=%v 上传=%v",
u, total.Round(time.Millisecond),
res.download.Round(time.Millisecond), res.embed.Round(time.Millisecond),
res.upload.Round(time.Millisecond))
}
return res
}
// ingestActionKind 入库分支决策结果(单表发布模型)。
type ingestActionKind int
const (
ingestCreate ingestActionKind = iota // 实体键未命中 → 新建记录
ingestSkip // 命中 pending/published → 放弃本次入库
ingestReuseRejected // 命中 rejected → 复用该行重审
)
// decideIngestAction 按实体键命中情况与既有行状态决定入库分支。
// 抽成纯函数,是为了让「驳回不是永久黑名单」这条规则有独立的、不需要数据库的测试。
func decideIngestAction(found bool, status string) ingestActionKind {
switch {
case !found:
return ingestCreate
case status == model.StatusRejected:
return ingestReuseRejected
default:
return ingestSkip
}
}