update
This commit is contained in:
@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
@ -235,36 +237,31 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
|
||||
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
|
||||
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
||||
// 同时算出每张图的 pHash(downloadOne 内基于原始字节),供后续全局近似去重标记。
|
||||
var cover string
|
||||
var cover string
|
||||
var draftImages []model.BrandRunwayDraftImage
|
||||
var keys []string
|
||||
var imgFailed bool
|
||||
var imageCount uint16
|
||||
var phashList []uint64
|
||||
if len(p.Looks) > 0 {
|
||||
cover, draftImages, keys, imgFailed = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||
imageCount = uint16(len(p.Looks))
|
||||
phashList = make([]uint64, len(draftImages))
|
||||
for i, d := range draftImages {
|
||||
phashList[i] = d.Phash
|
||||
}
|
||||
} else {
|
||||
var imgs []string
|
||||
var phs []uint64
|
||||
cover, imgs, phs, keys, imgFailed = s.fetchImages(ctx, p.Images, "runway")
|
||||
imageCount = uint16(len(imgs))
|
||||
for i, img := range imgs {
|
||||
var fimgs []fetchedImage
|
||||
cover, fimgs, keys, imgFailed = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
|
||||
imageCount = uint16(len(fimgs))
|
||||
for i, fi := range fimgs {
|
||||
draftImages = append(draftImages, model.BrandRunwayDraftImage{
|
||||
Image: img,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: uint32(i + 1),
|
||||
IsDetail: 0,
|
||||
Phash: phs[i],
|
||||
Image: fi.url,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: uint32(i + 1),
|
||||
IsDetail: 0,
|
||||
ContentSha1: fi.sha1,
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||
})
|
||||
}
|
||||
phashList = phs
|
||||
}
|
||||
if imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
@ -274,12 +271,6 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
return
|
||||
}
|
||||
|
||||
// 4.5) 全局近似去重标记:与「已晋升图片」比对汉明距离,命中则留痕(is_duplicate=1 + dup_of),
|
||||
// 不丢弃、交后台人工裁决(只拦新增,不碰存量)。比对失败仅告警,不阻断入库。
|
||||
s.tagDuplicates(ctx, phashList, func(i int, dupOf string) {
|
||||
draftImages[i].IsDuplicate = 1
|
||||
draftImages[i].DupOf = dupOf
|
||||
})
|
||||
|
||||
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表
|
||||
draft := &model.BrandRunwayDraft{
|
||||
@ -316,31 +307,44 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
|
||||
// 每张图入库前做去重:content_sha1 已存在则整行跳过(精确重复);命中 dHash 近重复则仍入库但标记留痕。
|
||||
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool) {
|
||||
cover := ""
|
||||
rows := make([]model.BrandRunwayDraftImage, 0)
|
||||
keys := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
failed := false
|
||||
order := 0
|
||||
for li, look := range looks {
|
||||
lookIdx := li + 1
|
||||
if look.Main != "" {
|
||||
order++
|
||||
url, key, ph, err := s.downloadOne(ctx, look.Main, order, prefix)
|
||||
url, key, sha1h, ph, err := s.downloadOne(ctx, look.Main, order, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
} else {
|
||||
keys = append(keys, key)
|
||||
if seen[sha1h] {
|
||||
continue
|
||||
}
|
||||
seen[sha1h] = true
|
||||
skip, dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, sha1h, ph)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
order++
|
||||
if cover == "" {
|
||||
cover = url
|
||||
}
|
||||
keys = append(keys, key)
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d", lookIdx),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 0,
|
||||
Phash: ph,
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d", lookIdx),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 0,
|
||||
ContentSha1: sha1h,
|
||||
Phash: sqlNull(ph),
|
||||
IsDuplicate: isDup,
|
||||
DupOf: strconv.FormatUint(uint64(dupID), 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -348,20 +352,31 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
order++
|
||||
url, key, ph, err := s.downloadOne(ctx, d, order, prefix)
|
||||
url, key, sha1h, ph, err := s.downloadOne(ctx, d, order, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
if seen[sha1h] {
|
||||
continue
|
||||
}
|
||||
seen[sha1h] = true
|
||||
skip, dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, sha1h, ph)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
order++
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d — Detail %d", lookIdx, di+1),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 1,
|
||||
Phash: ph,
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d — Detail %d", lookIdx, di+1),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 1,
|
||||
ContentSha1: sha1h,
|
||||
Phash: sqlNull(ph),
|
||||
IsDuplicate: isDup,
|
||||
DupOf: strconv.FormatUint(uint64(dupID), 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -377,8 +392,8 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 下载图片并上传到存储(七牛优先,失败兜底本地);同时算 pHash 供近似去重。
|
||||
cover, imgs, phs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street")
|
||||
// 2) 下载图片并上传到存储(七牛优先,失败兜底本地)。
|
||||
cover, fimgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
|
||||
if len(p.Images) > 0 && imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
|
||||
@ -394,7 +409,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
Year: p.Year,
|
||||
City: p.City,
|
||||
Cover: cover,
|
||||
ImageCount: uint16(len(imgs)),
|
||||
ImageCount: uint16(len(fimgs)),
|
||||
Status: model.DraftStatusPending,
|
||||
}
|
||||
id, err := s.repo.CreateStreetSnapDraft(ctx, draft)
|
||||
@ -402,22 +417,20 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
|
||||
return
|
||||
}
|
||||
rows := make([]model.StreetSnapDraftImage, 0, len(imgs))
|
||||
for i, img := range imgs {
|
||||
rows := make([]model.StreetSnapDraftImage, 0, len(fimgs))
|
||||
for i, fi := range fimgs {
|
||||
rows = append(rows, model.StreetSnapDraftImage{
|
||||
DraftID: id,
|
||||
Image: img,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
Phash: phs[i],
|
||||
DraftID: id,
|
||||
Image: fi.url,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
ContentSha1: fi.sha1,
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||
})
|
||||
}
|
||||
|
||||
// 3.5) 全局近似去重标记(与走秀同逻辑,只拦新增、留痕不删)。
|
||||
s.tagDuplicates(ctx, phs, func(i int, dupOf string) {
|
||||
rows[i].IsDuplicate = 1
|
||||
rows[i].DupOf = dupOf
|
||||
})
|
||||
|
||||
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error())
|
||||
@ -426,30 +439,48 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 各图 pHash, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// prefix 为七牛 key 前缀(runway/ 或 street/)。只要任意一张下载/上传失败,failed 即置 true,
|
||||
// 调用方据此把整条任务判为失败(不再写草稿),并拿 keys 回滚本批已上传的对象,符合「单图失败=整任务失败」策略。
|
||||
// 返回的 pHash 与图片地址按索引对齐(phash=0 表示解码失败未计算,比对时跳过)。
|
||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []string, []uint64, []string, bool) {
|
||||
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// prefix 为七牛 key 前缀(runway/ 或 street/)。kind 用于选择去重比对表。
|
||||
// 只要任意一张下载/上传失败,failed 即置 true,调用方据此把整条任务判为失败并回滚本批已上传的对象,
|
||||
// 符合「单图失败=整任务失败」策略。每张图入库前做去重(精确跳过 + 近重复标记)。
|
||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix, kind string) (string, []fetchedImage, []string, bool) {
|
||||
cover := ""
|
||||
out := make([]string, 0, len(urls))
|
||||
phashes := make([]uint64, 0, len(urls))
|
||||
out := make([]fetchedImage, 0, len(urls))
|
||||
keys := make([]string, 0, len(urls))
|
||||
seen := make(map[string]bool)
|
||||
failed := false
|
||||
for i, u := range urls {
|
||||
url, key, ph, err := s.downloadOne(ctx, u, i, prefix)
|
||||
for _, u := range urls {
|
||||
url, key, sha1h, ph, err := s.downloadOne(ctx, u, 0, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
keys = append(keys, key)
|
||||
if seen[sha1h] {
|
||||
continue
|
||||
}
|
||||
seen[sha1h] = true
|
||||
skip, dupID, isDup := s.dedupImage(ctx, kind, sha1h, ph)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
if cover == "" {
|
||||
cover = url
|
||||
}
|
||||
out = append(out, url)
|
||||
phashes = append(phashes, ph)
|
||||
keys = append(keys, key)
|
||||
out = append(out, fetchedImage{
|
||||
url: url,
|
||||
key: key,
|
||||
sha1: sha1h,
|
||||
phash: ph,
|
||||
dupID: dupID,
|
||||
isDup: isDup,
|
||||
})
|
||||
}
|
||||
return cover, out, phashes, keys, failed
|
||||
return cover, out, keys, failed
|
||||
}
|
||||
|
||||
// cleanupUploads 删除一批本批次成功上传的对象(七牛 + 本地兜底),用于任务失败回滚:
|
||||
@ -471,21 +502,65 @@ func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// fetchedImage 是单张已下载图片的暂存结构,携带去重所需的指纹信息。
|
||||
type fetchedImage struct {
|
||||
url string
|
||||
key string
|
||||
sha1 string // 内容 sha1(与存储 key 同源)
|
||||
phash string // dHash 的 pgvector 二进制向量串,空串表示无法解码
|
||||
dupID uint32 // 命中近重复时的参考图 id
|
||||
isDup uint8 // 是否标记为近重复(供审核留痕)
|
||||
}
|
||||
|
||||
// sqlNull 把 phash 字符串转成可空向量字段:空串 → NULL(不参与近邻检索)。
|
||||
func sqlNull(ph string) sql.NullString {
|
||||
return sql.NullString{String: ph, Valid: ph != ""}
|
||||
}
|
||||
|
||||
// dedupImage 判断单张图是否重复:
|
||||
// - 精确重复(content_sha1 已在库)→ 返回 skip=true(不入库该行);
|
||||
// - 近重复(dHash 汉明距离 ≤ 阈值)→ 仍入库,但标记 is_duplicate + dup_of;
|
||||
// - 否则正常入库。
|
||||
func (s *IngestService) dedupImage(ctx context.Context, kind, sha1hash, phashBits string) (skip bool, dupID uint32, isDup uint8) {
|
||||
// repo 未注入(如离线单测)时跳过去重,不阻断主流程。生产环境 repo 必不为空。
|
||||
if s.repo == nil {
|
||||
return false, 0, 0
|
||||
}
|
||||
var tables []string
|
||||
switch kind {
|
||||
case dto.IngestKindRunway:
|
||||
tables = []string{"brand_runway_draft_images", "brand_runway_images"}
|
||||
case dto.IngestKindStreet:
|
||||
tables = []string{"street_snap_draft_images", "street_snap_images"}
|
||||
default:
|
||||
return false, 0, 0
|
||||
}
|
||||
if exists, err := s.repo.ImageExistsBySha1(ctx, tables, sha1hash); err == nil && exists {
|
||||
return true, 0, 0
|
||||
}
|
||||
if phashBits != "" {
|
||||
if id, found, err := s.repo.FindNearDuplicateImage(ctx, tables, phashBits, phash.DefaultThreshold); err == nil && found {
|
||||
return false, id, 1
|
||||
}
|
||||
}
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
// downloadOne 把单张远程图下载后上传到存储(七牛优先,失败兜底本地),
|
||||
// 返回 (访问地址, 对象 key, 感知哈希, error)。访问地址可直接写入数据库
|
||||
// (七牛为完整 https URL,本地为相对 /uploads 路径);感知哈希基于原始字节算一次,供入库时近似去重。
|
||||
func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, uint64, error) {
|
||||
// 返回 (访问地址, 对象 key, 内容 sha1, dHash 向量串, error)。访问地址可直接写入数据库
|
||||
// (七牛为完整 https URL,本地为相对 /uploads 路径)。
|
||||
func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, string, string, error) {
|
||||
resp, err := s.httpClient.Get(u)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
return "", "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", 0, fmt.Errorf("status %d", resp.StatusCode)
|
||||
return "", "", "", "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
return "", "", "", "", err
|
||||
}
|
||||
ext := path.Ext(u)
|
||||
if ext == "" || len(ext) > 5 {
|
||||
@ -498,74 +573,21 @@ func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, pref
|
||||
key := fmt.Sprintf("%s/%s%s", prefix, hash, ext)
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
|
||||
// 感知哈希:同一份字节在此算一次(decode 失败时 ph=0,比对时跳过,不阻断入库)。
|
||||
// 计算 dHash 指纹(仅用于近重复检索;解码失败返回空串 → NULL,不参与检索)。
|
||||
ph := phash.Of(data)
|
||||
if ph == 0 {
|
||||
log.Printf("[warn] ingest pHash 未计算(解码失败或非 jpeg/png/gif 格式) url=%s", u)
|
||||
}
|
||||
phashBits := phash.ToVectorBits(ph)
|
||||
|
||||
// 主上传器(七牛)
|
||||
if url, err := s.uploader.Upload(ctx, key, data, ct); err == nil {
|
||||
return url, key, ph, nil
|
||||
return url, key, hash, phashBits, nil
|
||||
} else if s.local != nil {
|
||||
// 兜底本地,避免图片完全丢失
|
||||
if lurl, lerr := s.local.Upload(ctx, key, data, ct); lerr == nil {
|
||||
return lurl, key, ph, nil
|
||||
return lurl, key, hash, phashBits, nil
|
||||
} else {
|
||||
return "", "", 0, err
|
||||
return "", "", "", "", err
|
||||
}
|
||||
}
|
||||
return "", "", 0, err
|
||||
return "", "", "", "", err
|
||||
}
|
||||
|
||||
// tagDuplicates 对一批图片(phashList 与 apply 下标对齐)做全局近似去重标记:
|
||||
// 与「已晋升图片」的 phash 库比汉明距离(≤ phash.DefaultThreshold 即近似重复),
|
||||
// 命中则调用 apply(i, dupOf) 由调用方把第 i 张图标 IsDuplicate=1、DupOf=命中图 uid。
|
||||
// 不丢弃任何图、仅留痕(交后台人工裁决),契合「只拦新增、全局跨所有图、标记不删」。
|
||||
// 比对库拉取失败仅告警并跳过(不阻断入库);phash=0 的图不比对、不误杀。
|
||||
func (s *IngestService) tagDuplicates(ctx context.Context, phashList []uint64, apply func(i int, dupOf string)) {
|
||||
uids, err := s.matchDuplicates(ctx, phashList)
|
||||
if err != nil {
|
||||
log.Printf("[warn] ingest 近似去重比对失败 err=%v(跳过标记,不阻断入库)", err)
|
||||
return
|
||||
}
|
||||
for i, u := range uids {
|
||||
if u != "" {
|
||||
apply(i, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// matchDuplicates 返回与 phashList 等长的 dup_of uid 切片(""=未命中近似重复)。
|
||||
// 对每张新图,在已晋升图片库里找汉明距离 ≤ 阈值的命中,取距离最近者,
|
||||
// 按其 kind(runway/street)编码成对应 hashid 类型作为 dup_of。
|
||||
func (s *IngestService) matchDuplicates(ctx context.Context, phashList []uint64) ([]string, error) {
|
||||
refs, err := s.repo.ListImagePHashes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, len(phashList))
|
||||
for i, ph := range phashList {
|
||||
if ph == 0 {
|
||||
continue // 未计算 phash 的图不比对(避免误杀)
|
||||
}
|
||||
bestUID := ""
|
||||
bestDist := phash.DefaultThreshold + 1
|
||||
for _, ref := range refs {
|
||||
if ref.Phash == 0 {
|
||||
continue
|
||||
}
|
||||
d := phash.Hamming(ph, ref.Phash)
|
||||
if d <= phash.DefaultThreshold && d < bestDist {
|
||||
var typ byte = hashid.TypeRunwayImage
|
||||
if ref.Kind == "street" {
|
||||
typ = hashid.TypeSnapImage
|
||||
}
|
||||
bestUID = hashid.EncodeWithType(ref.ID, typ)
|
||||
bestDist = d
|
||||
}
|
||||
}
|
||||
out[i] = bestUID
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user