update
This commit is contained in:
@ -30,8 +30,6 @@ type ArticleService interface {
|
||||
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
||||
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||||
// GetRunwayDupSources 批量反查走秀重复源(见仓储注释),供编辑页标记重复图。
|
||||
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||
// UpdateRunway 审核/编辑后更新字段(字段白名单在仓储层强制)。
|
||||
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteImage 软删单张走秀图片。
|
||||
@ -241,11 +239,6 @@ func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.Bran
|
||||
return rw, orderRunwayImagesForEdit(imgs), nil
|
||||
}
|
||||
|
||||
// GetRunwayDupSources 批量反查走秀重复源(见仓储注释)。
|
||||
func (s *articleService) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||
return s.articles.GetRunwayDupSources(ctx, imageIDs)
|
||||
}
|
||||
|
||||
// orderRunwayImagesForEdit 走秀图片按 parent_image_id 重排为「主图后紧跟其细节图」的扁平顺序。
|
||||
// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。
|
||||
func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage {
|
||||
@ -294,17 +287,6 @@ func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error
|
||||
if key != "" {
|
||||
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||
}
|
||||
// 删图后重算本画廊去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
|
||||
if runwayID, err := s.articles.GetRunwayImageRunwayID(ctx, imageID); err == nil && runwayID != 0 {
|
||||
if err := s.articles.ReresolveRunwayDedup(ctx, runwayID); err != nil {
|
||||
log.Printf("[warn] 重算走秀 %d 去重失败(重复标记可能陈旧): %v", runwayID, err)
|
||||
}
|
||||
}
|
||||
// 跨文章清理:本图若是其它文章近重复的「源」,那些文章的 dup_of 需重算/清除
|
||||
// (ReresolveRunwayDedup 只算本集内,不会碰其它文章指向本图的标记)。
|
||||
if err := s.articles.ReresolveRunwayDupOfSources(ctx, []uint32{imageID}); err != nil {
|
||||
log.Printf("[warn] 跨文章重算走秀图 %d 去重失败(重复标记可能陈旧): %v", imageID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -334,11 +316,6 @@ func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint
|
||||
// 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警,
|
||||
// 图集已下架、图片已软删,S4文件留待后续手动清理或周期性兜底扫描。
|
||||
func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
// 下架前先取存活图片 id:删整篇后,其它文章中 dup_of 指向本篇图片的标记需跨文章重算/清除。
|
||||
delIDs, err := s.articles.GetRunwayLiveImageIDs(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := s.articles.SoftDeleteRunwayImages(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -351,12 +328,6 @@ func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
log.Printf("[warn] 下架图集 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||
}
|
||||
}
|
||||
// 跨文章去重清理:删除整篇后,其它文章中 dup_of 指向本篇图片的标记重算/清除。
|
||||
if len(delIDs) > 0 {
|
||||
if err := s.articles.ReresolveRunwayDupOfSources(ctx, delIDs); err != nil {
|
||||
log.Printf("[warn] 跨文章重算走秀 %d 去重失败(重复标记可能陈旧): %v", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -372,11 +343,11 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub
|
||||
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||
for _, im := range imgs {
|
||||
out = append(out, dto.PublicArticleImage{
|
||||
ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage),
|
||||
Image: s.img.Compose(im.Image),
|
||||
Thumb: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage),
|
||||
Image: s.img.Compose(im.Image),
|
||||
Thumb: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
})
|
||||
}
|
||||
return out
|
||||
@ -391,10 +362,10 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ
|
||||
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||
for _, im := range imgs {
|
||||
out = append(out, dto.PublicArticleImage{
|
||||
ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage),
|
||||
Image: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage),
|
||||
Image: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
@ -29,6 +30,9 @@ type BrandService interface {
|
||||
Create(ctx context.Context, nameEn, nameCn string) (string, error)
|
||||
// Update 按编码 id 更新品牌名称。
|
||||
Update(ctx context.Context, uid, nameEn, nameCn string) error
|
||||
// Delete 软删品牌(is_deleted=1)。品牌下仍有未删除的走秀档案时拒绝删除,
|
||||
// 避免留下 brand_id 指不到品牌的图集(无外键级联)。
|
||||
Delete(ctx context.Context, uid string) error
|
||||
// CrawlTasks 返回爬虫抓取所需的品牌列表(编码 brand_uid + 英文品牌名)。
|
||||
// brandID>0 时仅返回该品牌(对应 --brand 单品牌调试)。
|
||||
CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error)
|
||||
@ -170,6 +174,31 @@ func (s *brandService) Update(ctx context.Context, uid, nameEn, nameCn string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 软删品牌:先确认该品牌下没有未删除的走秀档案,再置 is_deleted=1。
|
||||
//
|
||||
// 护栏的理由:brand_runways.brand_id 没有外键级联,直接删品牌会留下一批指不到品牌的图集,
|
||||
// 前台品牌页、热门榜、图集列表都会出现异常数据。宁可让运营先处理档案,也不引入隐式级联删除。
|
||||
func (s *brandService) Delete(ctx context.Context, uid string) error {
|
||||
id, err := hashid.Decode(uid)
|
||||
if err != nil {
|
||||
return ErrBrandNotFound
|
||||
}
|
||||
n, err := s.brands.CountRunwaysByBrand(ctx, id)
|
||||
if err != nil {
|
||||
return internalErr(err.Error())
|
||||
}
|
||||
if n > 0 {
|
||||
return NewError(http.StatusConflict, fmt.Sprintf("该品牌下还有 %d 个图集,请先删除或下架后再删品牌", n))
|
||||
}
|
||||
if err := s.brands.SetDeleted(ctx, id, 1); err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return ErrBrandNotFound
|
||||
}
|
||||
return internalErr(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CrawlTasks 返回爬虫取任务所需的品牌列表:编码 brand_uid + 英文品牌名。
|
||||
// 爬虫直接拿 brand_uid 上送 ingest,无需自己编码;name 用于拼 vogue 抓取 URL。
|
||||
func (s *brandService) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
|
||||
|
||||
73
internal/service/brand_service_test.go
Normal file
73
internal/service/brand_service_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// stubBrandRepo 只实现删除链路上用到的两个方法;其余方法通过嵌入的接口转发
|
||||
// (本测试不会调用它们,因此不必为 7 个无关方法写样板)。
|
||||
type stubBrandRepo struct {
|
||||
repository.BrandRepository
|
||||
runways int64
|
||||
deleted uint8
|
||||
setCalled bool
|
||||
}
|
||||
|
||||
func (s *stubBrandRepo) CountRunwaysByBrand(_ context.Context, _ uint32) (int64, error) {
|
||||
return s.runways, nil
|
||||
}
|
||||
|
||||
func (s *stubBrandRepo) SetDeleted(_ context.Context, _ uint32, deleted uint8) error {
|
||||
s.setCalled = true
|
||||
s.deleted = deleted
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestBrandDeleteBlockedByRunways 品牌下还有图集时必须拒绝删除,且绝不能写 is_deleted。
|
||||
// 这是删除功能唯一的护栏:brand_runways.brand_id 没有外键级联,误删会留下指不到品牌的图集。
|
||||
func TestBrandDeleteBlockedByRunways(t *testing.T) {
|
||||
repo := &stubBrandRepo{runways: 3}
|
||||
svc := NewBrandService(repo)
|
||||
|
||||
err := svc.Delete(context.Background(), hashid.Encode(1))
|
||||
if err == nil {
|
||||
t.Fatal("品牌下还有图集时应拒绝删除")
|
||||
}
|
||||
if repo.setCalled {
|
||||
t.Fatal("被拒绝时不得写入 is_deleted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "3") {
|
||||
t.Fatalf("错误信息应带上图集数量,便于运营处理,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrandDeleteAllowedWhenNoRunways 没有图集时可以软删,且写入的必须是 is_deleted=1。
|
||||
func TestBrandDeleteAllowedWhenNoRunways(t *testing.T) {
|
||||
repo := &stubBrandRepo{}
|
||||
svc := NewBrandService(repo)
|
||||
|
||||
if err := svc.Delete(context.Background(), hashid.Encode(1)); err != nil {
|
||||
t.Fatalf("无图集时应允许删除,实际: %v", err)
|
||||
}
|
||||
if !repo.setCalled || repo.deleted != 1 {
|
||||
t.Fatalf("应写入 is_deleted=1,实际 called=%v deleted=%d", repo.setCalled, repo.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrandDeleteInvalidUID 非法 id 直接报错,不得触达数据库。
|
||||
func TestBrandDeleteInvalidUID(t *testing.T) {
|
||||
repo := &stubBrandRepo{}
|
||||
svc := NewBrandService(repo)
|
||||
|
||||
if err := svc.Delete(context.Background(), "!!!not-a-hashid"); err == nil {
|
||||
t.Fatal("非法 id 应返回错误")
|
||||
}
|
||||
if repo.setCalled {
|
||||
t.Fatal("非法 id 不应写库")
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/pkg/storage"
|
||||
)
|
||||
|
||||
@ -31,7 +30,7 @@ func TestFetchImagesCleansUpOnFailure(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
urls := []string{srv.URL + "/ok1.jpg", srv.URL + "/ok2.jpg", srv.URL + "/bad.jpg"}
|
||||
_, out, keys, failed, _ := s.fetchImages(context.Background(), urls, "runway", dto.IngestKindRunway)
|
||||
_, out, keys, failed, _ := s.fetchImages(context.Background(), urls, "runway")
|
||||
if !failed {
|
||||
t.Fatalf("expected failed=true when one image errors")
|
||||
}
|
||||
|
||||
@ -35,10 +35,6 @@ func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *mode
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
||||
return 0, false, nil // 近重复不影响本用例
|
||||
}
|
||||
|
||||
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
|
||||
|
||||
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
|
||||
|
||||
@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -11,18 +10,20 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/pkg/phash"
|
||||
"fashionapi/internal/pkg/season"
|
||||
"fashionapi/internal/pkg/storage"
|
||||
"fashionapi/internal/repository"
|
||||
"fashionapi/internal/vision"
|
||||
)
|
||||
|
||||
// IngestService 爬虫入库管线的业务逻辑层:Submit 入队,worker 异步处理。
|
||||
@ -39,14 +40,26 @@ type IngestService struct {
|
||||
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) *IngestService {
|
||||
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 影响)。
|
||||
@ -76,6 +89,8 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
|
||||
del: del,
|
||||
local: local,
|
||||
translate: translateSvc,
|
||||
vision: vis,
|
||||
embedRep: embedRep,
|
||||
// 图片下载是并发执行的(见 imageFetchConcurrency),连接池须匹配并发度:
|
||||
// Go 默认 MaxIdleConnsPerHost=2,多余连接会在每轮下载时反复重建、白付 TLS 握手开销。
|
||||
httpClient: &http.Client{
|
||||
@ -319,12 +334,14 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
// 内容哈希(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, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||
cover, images, vecs, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||
// image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks):
|
||||
// fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。
|
||||
// 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks),
|
||||
@ -332,19 +349,17 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
imageCount = countMainImages(images)
|
||||
} else {
|
||||
var fimgs []fetchedImage
|
||||
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
|
||||
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,
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||
ImageSize: fi.size,
|
||||
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)
|
||||
@ -396,6 +411,15 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
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 {
|
||||
@ -414,16 +438,17 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
|
||||
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
|
||||
// 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。
|
||||
//
|
||||
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
|
||||
// 「下载 → phash → 上传」这段 IO 密集操作;随后再按下标顺序串行去重与组装。
|
||||
// 「下载 → 上传」这段 IO 密集操作;随后再按下标顺序串行做批次内去重与组装。
|
||||
// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。
|
||||
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, []string, bool, *fetchTiming) {
|
||||
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
|
||||
@ -443,7 +468,7 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
}
|
||||
}
|
||||
|
||||
// 并发段:下载 + phash + 上传(耗时大头)。
|
||||
// 并发段:下载 + 上传(耗时大头)。
|
||||
results := concurrentFetch(ctx, len(tasks), func(c context.Context, i int) downloadResult {
|
||||
return s.downloadAndUpload(c, tasks[i].url, prefix)
|
||||
})
|
||||
@ -453,8 +478,8 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
for i, r := range results {
|
||||
task := tasks[i]
|
||||
timing.download += r.download
|
||||
timing.phash += r.phash
|
||||
timing.upload += r.upload
|
||||
timing.embed += r.embed
|
||||
if r.err != nil {
|
||||
failed = true
|
||||
continue
|
||||
@ -464,7 +489,6 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
continue
|
||||
}
|
||||
seen[r.sha1] = true
|
||||
dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, r.phashBits, timing)
|
||||
order++
|
||||
if cover == "" && !task.isDetail {
|
||||
cover = r.url
|
||||
@ -476,19 +500,17 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
isDetail = 1
|
||||
}
|
||||
rows = append(rows, model.BrandRunwayImage{
|
||||
Image: r.url,
|
||||
Name: name,
|
||||
SortOrder: uint32(order),
|
||||
IsDetail: isDetail,
|
||||
Phash: sqlNull(r.phashBits),
|
||||
IsDuplicate: isDup,
|
||||
DupOf: strconv.FormatUint(uint64(dupID), 10),
|
||||
ImageSize: r.size,
|
||||
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, keys, failed, timing
|
||||
return cover, rows, vecs, keys, failed, timing
|
||||
}
|
||||
|
||||
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
|
||||
@ -530,7 +552,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
}
|
||||
|
||||
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
|
||||
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
|
||||
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 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
@ -562,16 +584,16 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
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),
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||
ImageSize: fi.size,
|
||||
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 {
|
||||
@ -583,19 +605,27 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
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)
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// prefix 为S4 key 前缀(runway/ 或 street/)。kind 用于选择去重比对表。
|
||||
// prefix 为S4 key 前缀(runway/ 或 street/)。
|
||||
// 只要任意一张下载/上传失败,failed 即置 true,调用方据此把整条任务判为失败并回滚本批已上传的对象,
|
||||
// 符合「单图失败=整任务失败」策略。每张图入库前做去重(近重复标记)。
|
||||
// 符合「单图失败=整任务失败」策略。
|
||||
//
|
||||
// 实现:先用 concurrentFetch 并发完成全部图片的「下载 → phash → 上传」,再按下标顺序串行
|
||||
// 做去重与组装,保证 cover / 顺序 / 批次内去重与原串行实现一致。
|
||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix, kind string) (string, []fetchedImage, []string, bool, *fetchTiming) {
|
||||
// 实现:先用 concurrentFetch 并发完成全部图片的「下载 → 上传」,再按下标顺序串行
|
||||
// 做批次内去重与组装,保证 cover / 顺序 / 批次内去重与原串行实现一致。
|
||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []fetchedImage, []string, bool, *fetchTiming) {
|
||||
start := time.Now()
|
||||
timing := &fetchTiming{}
|
||||
cover := ""
|
||||
@ -604,7 +634,7 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix,
|
||||
seen := make(map[string]bool)
|
||||
failed := false
|
||||
|
||||
// 并发段:下载 + phash + 上传(耗时大头)。
|
||||
// 并发段:下载 + 上传(耗时大头)。
|
||||
results := concurrentFetch(ctx, len(urls), func(c context.Context, i int) downloadResult {
|
||||
return s.downloadAndUpload(c, urls[i], prefix)
|
||||
})
|
||||
@ -612,8 +642,8 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix,
|
||||
// 串行段:按原始顺序累计耗时、做批次内去重、组装结果。
|
||||
for _, r := range results {
|
||||
timing.download += r.download
|
||||
timing.phash += r.phash
|
||||
timing.upload += r.upload
|
||||
timing.embed += r.embed
|
||||
if r.err != nil {
|
||||
failed = true
|
||||
continue
|
||||
@ -623,17 +653,14 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix,
|
||||
continue
|
||||
}
|
||||
seen[r.sha1] = true
|
||||
dupID, isDup := s.dedupImage(ctx, kind, r.phashBits, timing)
|
||||
if cover == "" {
|
||||
cover = r.url
|
||||
}
|
||||
out = append(out, fetchedImage{
|
||||
url: r.url,
|
||||
key: r.key,
|
||||
phash: r.phashBits,
|
||||
dupID: dupID,
|
||||
isDup: isDup,
|
||||
size: r.size,
|
||||
url: r.url,
|
||||
key: r.key,
|
||||
size: r.size,
|
||||
vec: r.vec,
|
||||
})
|
||||
}
|
||||
timing.images = len(out)
|
||||
@ -641,6 +668,41 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix,
|
||||
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 + 本地兜底),用于任务失败回滚:
|
||||
// 「单图失败=整任务失败」时,前面已成功上传的图若放任不管就成了孤儿,永远堆在存储里。
|
||||
// 对两种存储都尝试删除(任一不存在即按幂等成功处理);删除失败仅告警,不阻断任务置失败。
|
||||
@ -660,14 +722,12 @@ func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// fetchedImage 是单张已下载图片的暂存结构,携带去重所需的指纹信息。
|
||||
// fetchedImage 是单张已下载图片的暂存结构。
|
||||
type fetchedImage struct {
|
||||
url string
|
||||
key string
|
||||
phash string // dHash 的 pgvector 二进制向量串,空串表示无法解码
|
||||
dupID uint32 // 命中近重复时的参考图 id
|
||||
isDup uint8 // 是否标记为近重复(供审核留痕)
|
||||
size int64 // 原图源文件字节数,随结果带回供落库写入 image_size
|
||||
url string
|
||||
key string
|
||||
size int64 // 原图源文件字节数,随结果带回供落库写入 image_size
|
||||
vec []float32 // FashionSigLIP 向量(nil = 未产出),供落库后写入 image_embeddings
|
||||
}
|
||||
|
||||
// slowImageThreshold 单张图各阶段合计超过该阈值时单独打一条告警,便于从大量图里定位异常慢图。
|
||||
@ -675,90 +735,69 @@ const slowImageThreshold = 2 * time.Second
|
||||
|
||||
// imageFetchConcurrency 单条入库任务内「下载 + 上传」的并发度。
|
||||
//
|
||||
// 实测结论(theimpression.com,59 张图):
|
||||
// - 并发 1:单张下载 ~3.7s,整批 59 张约 3m54s,任务成功。
|
||||
// - 并发 5:单张下载涨到 ~23s(约 6 倍),5m24s 仅完成 12/59 张便因 30s 超时失败。
|
||||
// 默认 1(等效串行),可用环境变量 INGEST_IMAGE_CONCURRENCY(1~16)覆盖。
|
||||
//
|
||||
// 即源站对同 IP 的并发连接有强限流:并发不仅不提速,反而把吞吐从 ~0.27 张/秒
|
||||
// 打到 ~0.04 张/秒并直接拖垮任务。因此默认取 1(等效串行)。
|
||||
// 换到不限流的源站时,可把这个值调大(机制已就绪),但务必先做小批量实测。
|
||||
const imageFetchConcurrency = 1
|
||||
// 之所以默认 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,phash 是 CPU(解码 + 哈希),近重复是 DB(HNSW 检索)——
|
||||
// 三类瓶颈成因不同,分开计时才能对症下药。
|
||||
// 下载与上传是网络 IO,embed 是视觉服务调用(HTTP + 模型推理),分开计时才能对症下药。
|
||||
type fetchTiming struct {
|
||||
images int // 成功入行的图片数
|
||||
total time.Duration // 整个拉取过程的墙钟耗时
|
||||
download time.Duration // 各张 HTTP 下载耗时之和
|
||||
phash time.Duration // 各张 dHash 指纹计算耗时之和
|
||||
upload time.Duration // 各张上传存储耗时之和
|
||||
dedup time.Duration // 近重复查询累计
|
||||
embed time.Duration // 各张视觉服务编码耗时之和
|
||||
}
|
||||
|
||||
// String 输出各阶段耗时(毫秒精度),一条日志看清瓶颈。
|
||||
// 注意:下载 / phash / 上传是「各张图耗时之和」,并发执行下其总和会大于墙钟总耗时,
|
||||
// 注意:下载 / 上传 / embed 是「各张图耗时之和」,并发执行下其总和会大于墙钟总耗时,
|
||||
// 因此三者与「总耗时」的比值不再等于时间占比——它们用于横向对比哪个阶段最重。
|
||||
func (t *fetchTiming) String() string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("图片=%d 并发=%d 下载=%v phash=%v 上传=%v 近重复=%v(下载/phash/上传为各张累计值)",
|
||||
return fmt.Sprintf("图片=%d 并发=%d 下载=%v 上传=%v embed=%v(各张累计值)",
|
||||
t.images,
|
||||
imageFetchConcurrency,
|
||||
t.download.Round(time.Millisecond),
|
||||
t.phash.Round(time.Millisecond),
|
||||
t.upload.Round(time.Millisecond),
|
||||
t.dedup.Round(time.Millisecond),
|
||||
t.embed.Round(time.Millisecond),
|
||||
)
|
||||
}
|
||||
|
||||
// sqlNull 把 phash 字符串转成可空向量字段:空串 → NULL(不参与近邻检索)。
|
||||
func sqlNull(ph string) sql.NullString {
|
||||
return sql.NullString{String: ph, Valid: ph != ""}
|
||||
}
|
||||
|
||||
// dedupImage 判断单张图是否近重复(dHash 汉明距离 ≤ 阈值):命中则仍入库,但标记 is_duplicate + dup_of。
|
||||
func (s *IngestService) dedupImage(ctx context.Context, kind, phashBits string, timing *fetchTiming) (dupID uint32, isDup uint8) {
|
||||
// repo 未注入(如离线单测)时跳过去重,不阻断主流程。生产环境 repo 必不为空。
|
||||
if s.repo == nil {
|
||||
return 0, 0
|
||||
}
|
||||
var tables []string
|
||||
switch kind {
|
||||
case dto.IngestKindRunway:
|
||||
tables = []string{"brand_runway_images"}
|
||||
case dto.IngestKindStreet:
|
||||
tables = []string{"street_snap_images"}
|
||||
default:
|
||||
return 0, 0
|
||||
}
|
||||
if phashBits != "" {
|
||||
start := time.Now()
|
||||
id, found, err := s.repo.FindNearDuplicateImage(ctx, tables, phashBits, phash.DefaultThreshold)
|
||||
if timing != nil {
|
||||
timing.dedup += time.Since(start)
|
||||
}
|
||||
if err == nil && found {
|
||||
return id, 1
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// downloadResult 是单张图「下载 → phash → 上传」的结果,供并发执行后按下标串行组装。
|
||||
// downloadResult 是单张图「下载 → 上传」的结果,供并发执行后按下标串行组装。
|
||||
// 各阶段耗时随结果一并带回、由调用方在串行段汇总(而非直接累加到共享 timing),
|
||||
// 这样并发路径上无需加锁即可保证计时准确。
|
||||
type downloadResult struct {
|
||||
url string // 存储访问地址(失败为空)
|
||||
key string // 对象 key(内容寻址)
|
||||
sha1 string // 内容 sha1,用于批次内去重
|
||||
phashBits string // dHash 向量串,空串表示无法解码
|
||||
download time.Duration // 本张下载耗时
|
||||
phash time.Duration // 本张指纹计算耗时
|
||||
upload time.Duration // 本张上传耗时
|
||||
size int64 // 原图源文件字节数(io.ReadAll 读到的下载响应体长度)
|
||||
err error // 下载或上传失败原因(非空即该张失败)
|
||||
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、
|
||||
@ -836,10 +875,19 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string)
|
||||
res.key = fmt.Sprintf("%s/%s%s", prefix, res.sha1, ext)
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
|
||||
// ② dHash 指纹(仅用于近重复检索;解码失败返回空串 → NULL,不参与检索)。
|
||||
phStart := time.Now()
|
||||
res.phashBits = phash.ToVectorBits(phash.Of(data))
|
||||
res.phash = time.Since(phStart)
|
||||
// ② 视觉服务编码(可选):产出后台「疑似同款」用的语义向量。
|
||||
// 未启用 / 不可用 / 超时 / 维度不符一律留空 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()
|
||||
@ -858,10 +906,11 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string)
|
||||
res.upload = time.Since(upStart)
|
||||
|
||||
// 单图异常慢(合计超阈值)单独告警:便于从几十上百张图里一眼锁定是哪张、卡在哪一段。
|
||||
if total := res.download + res.phash + res.upload; total > slowImageThreshold {
|
||||
log.Printf("[ingest] 慢图 url=%s 合计=%v 下载=%v phash=%v 上传=%v",
|
||||
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.phash.Round(time.Millisecond), res.upload.Round(time.Millisecond))
|
||||
res.download.Round(time.Millisecond), res.embed.Round(time.Millisecond),
|
||||
res.upload.Round(time.Millisecond))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ func TestProcessMediaCleanup(t *testing.T) {
|
||||
t.Fatalf("预置文件失败: %v", err)
|
||||
}
|
||||
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "", nil)
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "", nil, nil, nil)
|
||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
||||
svc.processMediaCleanup(context.Background(), job)
|
||||
|
||||
@ -60,7 +60,7 @@ func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
|
||||
del := &countingDeleter{LocalUploader: up}
|
||||
|
||||
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "", nil)
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "", nil, nil, nil)
|
||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
||||
svc.processMediaCleanup(context.Background(), job)
|
||||
|
||||
|
||||
351
internal/service/manual_article_service.go
Normal file
351
internal/service/manual_article_service.go
Normal file
@ -0,0 +1,351 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/season"
|
||||
"fashionapi/internal/pkg/storage"
|
||||
"fashionapi/internal/repository"
|
||||
"fashionapi/internal/vision"
|
||||
)
|
||||
|
||||
// ManualImage 一张待上传的图片(后台表单读入的原始字节)。
|
||||
type ManualImage struct {
|
||||
Filename string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// ManualArticleInput 后台「手工新建文章」的输入:元数据 + 图片字节。
|
||||
type ManualArticleInput struct {
|
||||
Kind string // runway | street
|
||||
|
||||
// 走秀
|
||||
BrandName string // 品牌英文名,精确匹配(不区分大小写)
|
||||
TitleEn string
|
||||
TitleCn string
|
||||
Year uint16
|
||||
Season string // spring | fall | resort | pre_fall
|
||||
CollectionType string // rtw | menswear | couture | resort | pre_fall
|
||||
|
||||
// 街拍
|
||||
City string
|
||||
|
||||
Images []ManualImage
|
||||
}
|
||||
|
||||
const (
|
||||
manualKindRunway = "runway"
|
||||
manualKindStreet = "street"
|
||||
manualMaxImageBytes = 10 << 20 // 单张 10MB
|
||||
)
|
||||
|
||||
// ManualArticleService 后台手工建稿:上传图片到存储 + 写正式表(status=pending)。
|
||||
//
|
||||
// 与爬虫入库的唯一区别是「图片来源」:那边拿 URL 由 worker 下载,这里直接拿字节。
|
||||
// 因此复用同一套存储层 Uploader(sha1 内容寻址)与同一组入库仓储写入方法,
|
||||
// 保证两条路径产出的对象 key / 行结构完全一致——后台不需要为它们维护第二套口径。
|
||||
type ManualArticleService struct {
|
||||
ingest repository.IngestRepository
|
||||
brands repository.BrandRepository
|
||||
uploader storage.Uploader
|
||||
local *storage.LocalUploader // S4 上传失败时的本地兜底(可为 nil)
|
||||
vision *vision.Client // 可为 nil:未启用视觉服务时跳过向量
|
||||
embedRep repository.EmbeddingRepository // 可为 nil
|
||||
}
|
||||
|
||||
// NewManualArticleService 创建手工建稿服务。
|
||||
func NewManualArticleService(ingest repository.IngestRepository, brands repository.BrandRepository, uploader storage.Uploader, local *storage.LocalUploader, vis *vision.Client, embedRep repository.EmbeddingRepository) *ManualArticleService {
|
||||
return &ManualArticleService{ingest: ingest, brands: brands, uploader: uploader, local: local, vision: vis, embedRep: embedRep}
|
||||
}
|
||||
|
||||
// Create 建稿:校验 → 上传图片(sha1 内容寻址、同内容自动去重)→ 写正式表 → 补向量。
|
||||
// 返回新建记录的数字主键。
|
||||
func (s *ManualArticleService) Create(ctx context.Context, in ManualArticleInput) (uint32, error) {
|
||||
if len(in.Images) == 0 {
|
||||
return 0, NewError(http.StatusBadRequest, "至少上传 1 张图片")
|
||||
}
|
||||
switch in.Kind {
|
||||
case manualKindRunway:
|
||||
return s.createRunway(ctx, in)
|
||||
case manualKindStreet:
|
||||
return s.createStreet(ctx, in)
|
||||
default:
|
||||
return 0, NewError(http.StatusBadRequest, "类型无效(仅支持走秀 / 街拍)")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ManualArticleService) createRunway(ctx context.Context, in ManualArticleInput) (uint32, error) {
|
||||
titleEn := strings.TrimSpace(in.TitleEn)
|
||||
if titleEn == "" {
|
||||
return 0, NewError(http.StatusBadRequest, "走秀标题(英文)必填")
|
||||
}
|
||||
if in.Year == 0 || strings.TrimSpace(in.Season) == "" || strings.TrimSpace(in.CollectionType) == "" {
|
||||
return 0, NewError(http.StatusBadRequest, "年份、季节、系列均必填")
|
||||
}
|
||||
brand, err := s.findBrand(ctx, in.BrandName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seasonCode := season.Derive(in.Year, in.CollectionType, in.Season)
|
||||
if seasonCode == "" {
|
||||
return 0, NewError(http.StatusBadRequest, "无法推导季节码,请检查年份")
|
||||
}
|
||||
// 实体键预检:与库里的唯一索引同口径,先给出人话提示,避免把 23505 甩给运营。
|
||||
if _, _, found, deleted, e := s.ingest.RunwayEntityState(ctx, brand.ID, seasonCode, in.CollectionType); e != nil {
|
||||
return 0, internalErr(e.Error())
|
||||
} else if found && !deleted {
|
||||
return 0, NewError(http.StatusConflict, "该品牌在同一季节/系列下已有图集,请直接编辑那一条,不要重复新建")
|
||||
}
|
||||
|
||||
uploaded, err := s.uploadAll(ctx, manualKindRunway, in.Images)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := uint32(time.Now().Unix())
|
||||
rw := &model.BrandRunway{
|
||||
JobID: 0, // 手工建稿没有入库任务;该列 NOT NULL,用 0 表示「无任务」
|
||||
BrandID: brand.ID,
|
||||
TitleEn: titleEn,
|
||||
TitleCn: strings.TrimSpace(in.TitleCn),
|
||||
Year: in.Year,
|
||||
Season: in.Season,
|
||||
CollectionType: in.CollectionType,
|
||||
SeasonCode: seasonCode,
|
||||
Cover: uploaded[0].key,
|
||||
ImageCount: uint16(len(uploaded)),
|
||||
Status: model.StatusPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
imgs := make([]model.BrandRunwayImage, 0, len(uploaded))
|
||||
for i, u := range uploaded {
|
||||
imgs = append(imgs, model.BrandRunwayImage{
|
||||
Image: u.key,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
IsDetail: 0,
|
||||
ImageSize: u.size,
|
||||
})
|
||||
}
|
||||
id, err := s.ingest.CreateRunwayWithImages(ctx, rw, imgs)
|
||||
if err != nil {
|
||||
return 0, s.writeErr(err)
|
||||
}
|
||||
s.embedUploaded(ctx, manualKindRunway, runwayImageIDs(imgs), uploaded)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *ManualArticleService) createStreet(ctx context.Context, in ManualArticleInput) (uint32, error) {
|
||||
title := strings.TrimSpace(in.TitleEn)
|
||||
titleCn := strings.TrimSpace(in.TitleCn)
|
||||
if title == "" {
|
||||
title = titleCn
|
||||
}
|
||||
if title == "" {
|
||||
return 0, NewError(http.StatusBadRequest, "街拍标题必填")
|
||||
}
|
||||
city := strings.TrimSpace(in.City)
|
||||
if city == "" {
|
||||
return 0, NewError(http.StatusBadRequest, "城市必填")
|
||||
}
|
||||
if in.Year == 0 {
|
||||
return 0, NewError(http.StatusBadRequest, "年份必填")
|
||||
}
|
||||
if _, _, found, deleted, e := s.ingest.StreetSnapEntityState(ctx, city, in.Year, title); e != nil {
|
||||
return 0, internalErr(e.Error())
|
||||
} else if found && !deleted {
|
||||
return 0, NewError(http.StatusConflict, "同一城市、年份、标题下已有街拍,请直接编辑那一条,不要重复新建")
|
||||
}
|
||||
|
||||
uploaded, err := s.uploadAll(ctx, manualKindStreet, in.Images)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := uint32(time.Now().Unix())
|
||||
snap := &model.StreetSnap{
|
||||
JobID: 0,
|
||||
Title: title,
|
||||
TitleCn: titleCn,
|
||||
City: city,
|
||||
Year: in.Year,
|
||||
Cover: uploaded[0].key,
|
||||
ImageCount: uint16(len(uploaded)),
|
||||
Status: model.StatusPending,
|
||||
// source / source_url 不设:模型未映射这两列,由库默认值兜底(爬虫入库同样不设)。
|
||||
// 唯一索引 uq_ss_source 只对非空 source_url 生效,因此手工建稿天然不会与来源去重冲突。
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
imgs := make([]model.StreetSnapImage, 0, len(uploaded))
|
||||
for i, u := range uploaded {
|
||||
imgs = append(imgs, model.StreetSnapImage{
|
||||
Image: u.key,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
ImageSize: u.size,
|
||||
})
|
||||
}
|
||||
id, err := s.ingest.CreateStreetSnapWithImages(ctx, snap, imgs)
|
||||
if err != nil {
|
||||
return 0, s.writeErr(err)
|
||||
}
|
||||
s.embedUploaded(ctx, manualKindStreet, streetImageIDs(imgs), uploaded)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// uploadedManualImage 一张已上传成功的图片:内容寻址 key + 原始字节数 + 原始字节。
|
||||
// data 保留下来只为随后补向量,避免再向存储回读一次。
|
||||
type uploadedManualImage struct {
|
||||
key string
|
||||
size int64
|
||||
data []byte
|
||||
}
|
||||
|
||||
// uploadAll 逐张校验 → 内容寻址 key → 上传(S4 失败回退本地)→ 同内容去重。
|
||||
//
|
||||
// 类型用 http.DetectContentType 按真实字节判断,不看扩展名(可伪造);
|
||||
// key 用 sha1,与爬虫入库完全同一口径,因此「同一张图」无论来自哪条路径都落在同一个对象上。
|
||||
func (s *ManualArticleService) uploadAll(ctx context.Context, prefix string, images []ManualImage) ([]uploadedManualImage, error) {
|
||||
out := make([]uploadedManualImage, 0, len(images))
|
||||
seen := make(map[string]struct{}, len(images))
|
||||
for _, img := range images {
|
||||
if len(img.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(img.Data) > manualMaxImageBytes {
|
||||
return nil, NewError(http.StatusBadRequest, fmt.Sprintf("图片 %q 超过 10MB", img.Filename))
|
||||
}
|
||||
ct := http.DetectContentType(img.Data)
|
||||
ext, ok := manualImageExt(ct)
|
||||
if !ok {
|
||||
return nil, NewError(http.StatusBadRequest, fmt.Sprintf("图片 %q 不是 JPG/PNG/WEBP(实际识别为 %s)", img.Filename, ct))
|
||||
}
|
||||
sum := sha1.Sum(img.Data)
|
||||
key := prefix + "/" + hex.EncodeToString(sum[:]) + ext
|
||||
if _, dup := seen[key]; dup {
|
||||
continue // 同一次提交里的同内容图片只留一份
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, err := s.uploader.Upload(ctx, key, img.Data, ct); err != nil {
|
||||
if s.local == nil {
|
||||
return nil, internalErr("图片上传失败: " + err.Error())
|
||||
}
|
||||
if _, lerr := s.local.Upload(ctx, key, img.Data, ct); lerr != nil {
|
||||
return nil, internalErr("图片上传失败(本地兜底也失败): " + err.Error())
|
||||
}
|
||||
}
|
||||
out = append(out, uploadedManualImage{key: key, size: int64(len(img.Data)), data: img.Data})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, NewError(http.StatusBadRequest, "没有可用的图片(全部为空或重复)")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// manualImageExt 把真实 content-type 映射为扩展名;不支持的格式返回 ok=false。
|
||||
func manualImageExt(contentType string) (string, bool) {
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
return ".jpg", true
|
||||
case "image/png":
|
||||
return ".png", true
|
||||
case "image/webp":
|
||||
return ".webp", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// findBrand 按英文名精确匹配(不区分大小写、忽略首尾空格)。
|
||||
//
|
||||
// 用 AdminList 的关键词查询而不是新增仓储方法:关键词路径本身就会跳过「必须有图集」
|
||||
// 的过滤,因此能命中导入的 2 万+ 无图集品牌。
|
||||
func (s *ManualArticleService) findBrand(ctx context.Context, name string) (*model.Brand, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, NewError(http.StatusBadRequest, "品牌名必填")
|
||||
}
|
||||
items, _, err := s.brands.ListAdmin(ctx, dto.BrandQuery{Keyword: name, Size: 50}, nil)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
for i := range items {
|
||||
if strings.EqualFold(strings.TrimSpace(items[i].NameEn), name) {
|
||||
return &items[i], nil
|
||||
}
|
||||
}
|
||||
return nil, NewError(http.StatusBadRequest, "找不到品牌「"+name+"」,请先在品牌管理里新建(英文名需完全一致)")
|
||||
}
|
||||
|
||||
// writeErr 把唯一键冲突翻译成人话;其它错误按内部错误处理。
|
||||
// 预检之外仍有并发窗口,DB 层的兜底错误不该原样甩给运营。
|
||||
func (s *ManualArticleService) writeErr(err error) error {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "uq_br_entity") || strings.Contains(msg, "uq_ss_entity") ||
|
||||
strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key") {
|
||||
return NewError(http.StatusConflict, "该实体已存在未删除的记录(走秀=品牌+季节+系列;街拍=城市+年份+标题)")
|
||||
}
|
||||
return internalErr(msg)
|
||||
}
|
||||
|
||||
// embedUploaded 给刚写入的图片补向量,供后台「疑似同款」使用。
|
||||
//
|
||||
// 任何一步不可用(未注入视觉服务 / 仓储为 nil / 熔断中 / 批量调用失败 / 图片 id 未回填)
|
||||
// 都静默跳过:建稿成功与否绝不能取决于向量产出。
|
||||
//
|
||||
// 只处理「仓储回填了主键」的图片(id == 0 视为未回填),并且只在「返回的向量数与输入一一对应」
|
||||
// 时才写入——有失败项时映射关系不再可靠,宁可少写向量,也不写错位的向量。
|
||||
func (s *ManualArticleService) embedUploaded(ctx context.Context, kind string, imageIDs []uint32, uploaded []uploadedManualImage) {
|
||||
if s.vision == nil || s.embedRep == nil || !s.vision.Enabled() {
|
||||
return
|
||||
}
|
||||
batch := make([][]byte, 0, len(uploaded))
|
||||
slot := make([]uint32, 0, len(uploaded))
|
||||
for i, u := range uploaded {
|
||||
if i >= len(imageIDs) || imageIDs[i] == 0 || len(u.data) == 0 {
|
||||
continue
|
||||
}
|
||||
batch = append(batch, u.data)
|
||||
slot = append(slot, imageIDs[i])
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
vecs, idxs, err := s.vision.Embed(ctx, batch)
|
||||
if err != nil || len(vecs) != len(batch) || len(idxs) != len(batch) {
|
||||
return
|
||||
}
|
||||
for i := range batch {
|
||||
if len(vecs[i]) == 0 {
|
||||
continue
|
||||
}
|
||||
_ = s.embedRep.UpsertEmbedding(ctx, kind, slot[i], vecs[i])
|
||||
}
|
||||
}
|
||||
|
||||
// imageIDsOf 取出仓储回填到图片行上的主键(未回填时为 0,调用方据此跳过)。
|
||||
func runwayImageIDs(imgs []model.BrandRunwayImage) []uint32 {
|
||||
ids := make([]uint32, len(imgs))
|
||||
for i := range imgs {
|
||||
ids[i] = imgs[i].ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// streetImageIDs 同上,街拍版。
|
||||
func streetImageIDs(imgs []model.StreetSnapImage) []uint32 {
|
||||
ids := make([]uint32, len(imgs))
|
||||
for i := range imgs {
|
||||
ids[i] = imgs[i].ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
136
internal/service/manual_article_service_test.go
Normal file
136
internal/service/manual_article_service_test.go
Normal file
@ -0,0 +1,136 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// stubManualIngest 只实现建稿用到的两个方法(其余经嵌入接口转发,本测试不会调用)。
|
||||
type stubManualIngest struct {
|
||||
repository.IngestRepository
|
||||
entityHit bool
|
||||
created bool
|
||||
gotRW *model.BrandRunway
|
||||
gotImgs []model.BrandRunwayImage
|
||||
}
|
||||
|
||||
func (s *stubManualIngest) RunwayEntityState(_ context.Context, _ uint32, _, _ string) (uint32, string, bool, bool, error) {
|
||||
return 0, "", s.entityHit, false, nil
|
||||
}
|
||||
|
||||
func (s *stubManualIngest) CreateRunwayWithImages(_ context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
||||
s.created, s.gotRW, s.gotImgs = true, rw, imgs
|
||||
return 99, nil
|
||||
}
|
||||
|
||||
// stubManualBrands 提供品牌精确匹配所需的一行。
|
||||
type stubManualBrands struct {
|
||||
repository.BrandRepository
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *stubManualBrands) ListAdmin(_ context.Context, _ dto.BrandQuery, _ []uint32) ([]model.Brand, int64, error) {
|
||||
if s.name == "" {
|
||||
return []model.Brand{}, 0, nil
|
||||
}
|
||||
return []model.Brand{{ID: 7, NameEn: s.name}}, 1, nil
|
||||
}
|
||||
|
||||
// stubManualUploader 记录上传的 key,用于断言 sha1 内容寻址与去重。
|
||||
type stubManualUploader struct{ keys []string }
|
||||
|
||||
func (s *stubManualUploader) Upload(_ context.Context, key string, _ []byte, _ string) (string, error) {
|
||||
s.keys = append(s.keys, key)
|
||||
return "https://cdn/" + key, nil
|
||||
}
|
||||
func (s *stubManualUploader) Enabled() bool { return true }
|
||||
|
||||
func manualRunwayInput(images []ManualImage) ManualArticleInput {
|
||||
return ManualArticleInput{
|
||||
Kind: manualKindRunway, BrandName: "Gucci", TitleEn: "Spring 2027 RTW",
|
||||
Year: 2027, Season: "spring", CollectionType: "rtw",
|
||||
Images: images,
|
||||
}
|
||||
}
|
||||
|
||||
// 一张最小 JPEG 头,http.DetectContentType 会识别为 image/jpeg。
|
||||
var fakeJPEG = append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte("manual-test-payload")...)
|
||||
|
||||
// TestManualCreateRequiresImages 没有图片必须直接拒绝,且既不上传也不写库。
|
||||
func TestManualCreateRequiresImages(t *testing.T) {
|
||||
up := &stubManualUploader{}
|
||||
ing := &stubManualIngest{}
|
||||
svc := NewManualArticleService(ing, &stubManualBrands{name: "Gucci"}, up, nil, nil, nil)
|
||||
|
||||
if _, err := svc.Create(context.Background(), manualRunwayInput(nil)); err == nil {
|
||||
t.Fatal("没有图片应报错")
|
||||
}
|
||||
if len(up.keys) != 0 || ing.created {
|
||||
t.Fatalf("校验失败不应上传/写库,实际 uploads=%d created=%v", len(up.keys), ing.created)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManualCreateBrandNotFound 品牌名匹配不到时必须报错且不写库。
|
||||
func TestManualCreateBrandNotFound(t *testing.T) {
|
||||
up := &stubManualUploader{}
|
||||
ing := &stubManualIngest{}
|
||||
svc := NewManualArticleService(ing, &stubManualBrands{name: "SomeOtherBrand"}, up, nil, nil, nil)
|
||||
|
||||
in := manualRunwayInput([]ManualImage{{Filename: "a.jpg", Data: fakeJPEG}})
|
||||
if _, err := svc.Create(context.Background(), in); err == nil {
|
||||
t.Fatal("品牌不存在应报错")
|
||||
}
|
||||
if ing.created || len(up.keys) != 0 {
|
||||
t.Fatalf("品牌不存在不应写库/上传,实际 created=%v uploads=%d", ing.created, len(up.keys))
|
||||
}
|
||||
}
|
||||
|
||||
// TestManualCreateRunwayUploadsAndInserts 正常提交:sha1 内容寻址、同内容去重、
|
||||
// 封面取第一张、image_count 与实际行数一致。
|
||||
func TestManualCreateRunwayUploadsAndInserts(t *testing.T) {
|
||||
up := &stubManualUploader{}
|
||||
ing := &stubManualIngest{}
|
||||
svc := NewManualArticleService(ing, &stubManualBrands{name: "gucci"}, up, nil, nil, nil)
|
||||
|
||||
// 两张同内容(应去重成 1 张)+ 一张不同内容 → 共 2 张
|
||||
other := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte("another-payload")...)
|
||||
in := manualRunwayInput([]ManualImage{
|
||||
{Filename: "a.jpg", Data: fakeJPEG},
|
||||
{Filename: "a-copy.jpg", Data: fakeJPEG},
|
||||
{Filename: "b.jpg", Data: other},
|
||||
})
|
||||
id, err := svc.Create(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("正常提交不应报错: %v", err)
|
||||
}
|
||||
if id != 99 {
|
||||
t.Fatalf("应返回仓储给的主键,实际 %d", id)
|
||||
}
|
||||
if len(up.keys) != 2 {
|
||||
t.Fatalf("同内容图片应去重为 2 张上传,实际 %d: %v", len(up.keys), up.keys)
|
||||
}
|
||||
for _, k := range up.keys {
|
||||
if len(k) < len("runway/")+40+len(".jpg") || k[:7] != "runway/" {
|
||||
t.Fatalf("key 应为 runway/<sha1>.jpg,实际 %q", k)
|
||||
}
|
||||
}
|
||||
if !ing.created {
|
||||
t.Fatal("应写入正式表")
|
||||
}
|
||||
if len(ing.gotImgs) != 2 || ing.gotRW.ImageCount != 2 {
|
||||
t.Fatalf("图片行数与 image_count 都应等于 2,实际 rows=%d count=%d", len(ing.gotImgs), ing.gotRW.ImageCount)
|
||||
}
|
||||
if ing.gotRW.Cover != up.keys[0] {
|
||||
t.Fatalf("封面应取第一张,实际 cover=%q keys=%v", ing.gotRW.Cover, up.keys)
|
||||
}
|
||||
if ing.gotRW.Status != model.StatusPending {
|
||||
t.Fatalf("新建记录必须是 pending(走审核),实际 %s", ing.gotRW.Status)
|
||||
}
|
||||
if ing.gotRW.BrandID != 7 {
|
||||
t.Fatalf("品牌应按英文名匹配到 id=7(不区分大小写),实际 %d", ing.gotRW.BrandID)
|
||||
}
|
||||
}
|
||||
189
internal/service/similar_service.go
Normal file
189
internal/service/similar_service.go
Normal file
@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// SimilarHint 是一条「疑似同款」提示:某张图片的一个近邻。
|
||||
//
|
||||
// 带上近邻的图片 key / 名称 / 所属图集标题,后台才能直接显示「和哪张图像」。
|
||||
type SimilarHint struct {
|
||||
ImageID uint32 // 近邻图片 id
|
||||
OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转)
|
||||
Similar float64 // 余弦相似度 0~1
|
||||
Image string // 近邻图片存储 key(前端显示缩略图)
|
||||
Name string // 近邻图片名(如 "Look 12")
|
||||
OwnerTitle string // 近邻所属图集标题
|
||||
}
|
||||
|
||||
// DupStat 是一个图集的「跨图集重复」概况,供文章列表直接标出哪些图集有重复。
|
||||
type DupStat struct {
|
||||
Total int // 该图集存活图片总数(实时统计,不用过期的 image_count 缓存列)
|
||||
Images int // 其中有跨图集重复的图片数
|
||||
FirstImageID uint32 // 其中 id 最小的一张,列表点进去可直接锚点定位
|
||||
}
|
||||
|
||||
// SimilarService 组装后台「疑似同款」提示。
|
||||
//
|
||||
// 定位(对应规格 §8/§9):**只提示、不隐藏、不计入重复率、不参与任何去重**。
|
||||
//
|
||||
// 分工:
|
||||
// - PrecomputeForGallery 在**入库时**(或 dbtool similarbackfill 回填时)算好近邻并落库;
|
||||
// - HintsForImages 只读结果表,供页面渲染。
|
||||
//
|
||||
// 为什么必须预计算:原实现在**打开图集页时**对每张图实时检索,一个 116 张图的图集要发
|
||||
// 116 次查询、每次约 20KB SQL(768 维向量文本)。本机 PostgreSQL 跑在 WSL2 里、Windows 经
|
||||
// wslrelay 访问,该链路大 payload 吞吐实测约 0.47MB/s —— 服务端执行单条只要 0.45ms,
|
||||
// 客户端往返却要 45ms,合计 116×45ms ≈ 5 秒。挪到入库时后页面只读一次小查询。
|
||||
type SimilarService struct {
|
||||
embedRep repository.EmbeddingRepository
|
||||
threshold float64
|
||||
}
|
||||
|
||||
// NewSimilarService 构造 SimilarService。threshold 为余弦相似度阈值。
|
||||
// embedRep 可为 nil(视觉服务未启用),此时所有查询返回空结果。
|
||||
func NewSimilarService(embedRep repository.EmbeddingRepository, threshold float64) *SimilarService {
|
||||
return &SimilarService{embedRep: embedRep, threshold: threshold}
|
||||
}
|
||||
|
||||
// Enabled 报告是否可用(仓储已注入)。
|
||||
func (s *SimilarService) Enabled() bool {
|
||||
return s != nil && s.embedRep != nil
|
||||
}
|
||||
|
||||
// Threshold 返回当前的「疑似同款」相似度阈值,供页面展示。
|
||||
func (s *SimilarService) Threshold() float64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.threshold
|
||||
}
|
||||
|
||||
// HintsForImages 读取一个图集全部图片的「疑似同款」提示(只读结果表,不计算)。
|
||||
//
|
||||
// 返回 map[图片 id] -> 近邻提示列表(按相似度降序、每张最多 similarNeighborLimit 条)。
|
||||
// **不返回 error**:提示是增值信息,读取失败只记日志并返回空,
|
||||
// 绝不能让「查不到同款」影响后台页面渲染。
|
||||
func (s *SimilarService) HintsForImages(ctx context.Context, kind string, imageIDs []uint32) map[uint32][]SimilarHint {
|
||||
if !s.Enabled() || len(imageIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
pairs, err := s.embedRep.ListSimilarPairs(ctx, kind, imageIDs)
|
||||
if err != nil {
|
||||
log.Printf("[similar] 读近邻失败 kind=%s 数量=%d err=%v(页面不显示同款提示)", kind, len(imageIDs), err)
|
||||
return nil
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[uint32][]SimilarHint, len(pairs))
|
||||
for selfID, ns := range pairs {
|
||||
if len(ns) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Slice(ns, func(i, j int) bool { return ns[i].Similar > ns[j].Similar })
|
||||
if len(ns) > similarNeighborLimit {
|
||||
ns = ns[:similarNeighborLimit]
|
||||
}
|
||||
hints := make([]SimilarHint, 0, len(ns))
|
||||
for _, n := range ns {
|
||||
hints = append(hints, SimilarHint{
|
||||
ImageID: n.OtherID,
|
||||
OwnerID: n.OwnerID,
|
||||
Similar: n.Similar,
|
||||
Image: n.Image,
|
||||
Name: n.Name,
|
||||
OwnerTitle: n.OwnerTitle,
|
||||
})
|
||||
}
|
||||
out[selfID] = hints
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DupStats 汇总 runway / street 两类图集的跨图集重复情况(kind -> 图集 id -> 概况)。
|
||||
//
|
||||
// 供文章列表直接标出「哪些图集有重复」——否则只能一个个点进图集才能发现。
|
||||
//
|
||||
// **不返回 error**:与提示同理,汇总失败只记日志并跳过该类,
|
||||
// 绝不能让列表页因为查不到重复而渲染失败。
|
||||
func (s *SimilarService) DupStats(ctx context.Context) map[string]map[uint32]DupStat {
|
||||
if !s.Enabled() {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]map[uint32]DupStat, 2)
|
||||
for _, kind := range []string{"runway", "street"} {
|
||||
raw, err := s.embedRep.DupStatsByOwner(ctx, kind)
|
||||
if err != nil {
|
||||
log.Printf("[similar] 汇总跨图集重复失败 kind=%s err=%v(列表不显示重复列)", kind, err)
|
||||
continue
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
m := make(map[uint32]DupStat, len(raw))
|
||||
for ownerID, st := range raw {
|
||||
m[ownerID] = DupStat{Total: st.Total, Images: st.Images, FirstImageID: st.FirstImageID}
|
||||
}
|
||||
out[kind] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PrecomputeForGallery 计算并落库一个图集全部图片的跨图集近邻。
|
||||
//
|
||||
// imageIDs 须为该图集的全部图片 id:它同时充当「同图集排除集」——需求是
|
||||
// 「本图集的图与其他图集有没有重复」,同图集内所有照片共享背景/灯光/模特/造型,
|
||||
// 相似度天然偏高(实测同场中位 0.69、最高 0.91),参与比对只会淹没真正的跨图集重复。
|
||||
//
|
||||
// 结果**双向**写入(A→B 与 B→A):这样打开任一侧所在的图集都能看到提示。
|
||||
//
|
||||
// 说明:单张查询失败只记日志并跳过,整体不中断——预计算是增值数据,
|
||||
// 绝不能让「算不出同款」影响入库或回填。
|
||||
func (s *SimilarService) PrecomputeForGallery(ctx context.Context, kind string, imageIDs []uint32) error {
|
||||
if !s.Enabled() || len(imageIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
vecByImage, err := s.embedRep.ListEmbeddings(ctx, kind, imageIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("取向量: %w", err)
|
||||
}
|
||||
if len(vecByImage) == 0 {
|
||||
return nil // 这批图还没有向量(视觉服务未启用),无需预计算
|
||||
}
|
||||
|
||||
rows := make([]repository.SimilarPair, 0, len(vecByImage)*similarNeighborLimit*2)
|
||||
done := 0
|
||||
for imageID, vec := range vecByImage {
|
||||
if len(vec) == 0 {
|
||||
continue
|
||||
}
|
||||
neighbors, ferr := s.embedRep.FindSimilarImages(
|
||||
ctx, kind, imageID, imageIDs, vec, s.threshold, similarNeighborLimit)
|
||||
if ferr != nil {
|
||||
log.Printf("[similar] 预计算近邻失败 kind=%s 图片=%d err=%v", kind, imageID, ferr)
|
||||
continue
|
||||
}
|
||||
done++
|
||||
for _, n := range neighbors {
|
||||
rows = append(rows,
|
||||
repository.SimilarPair{SelfID: imageID, OtherID: n.ImageID, Similar: n.Similar},
|
||||
repository.SimilarPair{SelfID: n.ImageID, OtherID: imageID, Similar: n.Similar},
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := s.embedRep.ReplaceSimilarPairs(ctx, kind, imageIDs, rows); err != nil {
|
||||
return fmt.Errorf("写入近邻: %w", err)
|
||||
}
|
||||
log.Printf("[similar] 预计算完成 kind=%s 图=%d 已算=%d 对=%d 阈值=%.2f",
|
||||
kind, len(imageIDs), done, len(rows), s.threshold)
|
||||
return nil
|
||||
}
|
||||
|
||||
// similarNeighborLimit 每张图最多展示的「疑似同款」条数:只作人工参考,多了是噪音。
|
||||
const similarNeighborLimit = 5
|
||||
243
internal/service/similar_service_test.go
Normal file
243
internal/service/similar_service_test.go
Normal file
@ -0,0 +1,243 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// fakeEmbeddingRepo 是 EmbeddingRepository 的可控替身,记录被调用的参数。
|
||||
type fakeEmbeddingRepo struct {
|
||||
vecs map[uint32][]float32
|
||||
neighbors map[uint32][]repository.SimilarImage
|
||||
pairs map[uint32][]repository.SimilarNeighbor
|
||||
dupStats map[string]map[uint32]repository.DupStat
|
||||
listErr error
|
||||
|
||||
gotKind string
|
||||
gotQueryID uint32
|
||||
gotExclude []uint32
|
||||
gotMinSim float64
|
||||
gotLimit int
|
||||
|
||||
replacedSelfIDs []uint32
|
||||
replacedRows []repository.SimilarPair
|
||||
replaceErr error
|
||||
}
|
||||
|
||||
func (f *fakeEmbeddingRepo) UpsertEmbedding(context.Context, string, uint32, []float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbeddingRepo) BulkUpsertEmbedding(context.Context, string, []uint32, [][]float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbeddingRepo) ListEmbeddings(_ context.Context, _ string, imageIDs []uint32) (map[uint32][]float32, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
out := map[uint32][]float32{}
|
||||
for _, id := range imageIDs {
|
||||
if v, ok := f.vecs[id]; ok {
|
||||
out[id] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbeddingRepo) FindSimilarImages(_ context.Context, kind string, imageID uint32, excludeIDs []uint32, _ []float32, minSim float64, limit int) ([]repository.SimilarImage, error) {
|
||||
f.gotKind = kind
|
||||
f.gotQueryID = imageID
|
||||
f.gotExclude = excludeIDs
|
||||
f.gotMinSim = minSim
|
||||
f.gotLimit = limit
|
||||
return f.neighbors[imageID], nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbeddingRepo) ReplaceSimilarPairs(_ context.Context, _ string, selfIDs []uint32, rows []repository.SimilarPair) error {
|
||||
f.replacedSelfIDs = selfIDs
|
||||
f.replacedRows = rows
|
||||
return f.replaceErr
|
||||
}
|
||||
|
||||
func (f *fakeEmbeddingRepo) ListSimilarPairs(_ context.Context, _ string, imageIDs []uint32) (map[uint32][]repository.SimilarNeighbor, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
out := map[uint32][]repository.SimilarNeighbor{}
|
||||
for _, id := range imageIDs {
|
||||
if ns, ok := f.pairs[id]; ok {
|
||||
out[id] = ns
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbeddingRepo) DupStatsByOwner(_ context.Context, kind string) (map[uint32]repository.DupStat, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
return f.dupStats[kind], nil
|
||||
}
|
||||
|
||||
// TestSimilarDisabledReturnsNothing 未启用(仓储为 nil)时必须安全返回空结果,不 panic。
|
||||
func TestSimilarDisabledReturnsNothing(t *testing.T) {
|
||||
svc := NewSimilarService(nil, 0.93)
|
||||
if svc.Enabled() {
|
||||
t.Fatal("未注入仓储时应为未启用")
|
||||
}
|
||||
if got := svc.HintsForImages(context.Background(), "runway", []uint32{1, 2}); got != nil {
|
||||
t.Fatalf("未启用应返回 nil,得到 %v", got)
|
||||
}
|
||||
if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil {
|
||||
t.Fatalf("未启用时预计算应直接返回 nil,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHintsReadsStoredPairs 页面渲染只读结果表:验证分组、降序、每张上限,以及字段透传。
|
||||
func TestHintsReadsStoredPairs(t *testing.T) {
|
||||
nb := func(id uint32, sim float64) repository.SimilarNeighbor {
|
||||
return repository.SimilarNeighbor{
|
||||
OtherID: id, OwnerID: id * 10, Similar: sim,
|
||||
Image: "runway/x.jpg", Name: "Look 1", OwnerTitle: "Fall 2019 Menswear",
|
||||
}
|
||||
}
|
||||
repo := &fakeEmbeddingRepo{pairs: map[uint32][]repository.SimilarNeighbor{
|
||||
// 故意乱序且超过上限(6 条),应被降序 + 截到 5 条。
|
||||
7: {nb(71, 0.95), nb(72, 0.99), nb(73, 0.91), nb(74, 0.98), nb(75, 0.92), nb(76, 0.90)},
|
||||
}}
|
||||
svc := NewSimilarService(repo, 0.93)
|
||||
|
||||
got := svc.HintsForImages(context.Background(), "runway", []uint32{7, 8})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应只有图片 7 有提示,得到 %d 项", len(got))
|
||||
}
|
||||
hints := got[7]
|
||||
if len(hints) != 5 {
|
||||
t.Fatalf("每张最多 5 条提示,得到 %d", len(hints))
|
||||
}
|
||||
for i := 1; i < len(hints); i++ {
|
||||
if hints[i].Similar > hints[i-1].Similar {
|
||||
t.Fatalf("提示应按相似度降序,实际 %+v", hints)
|
||||
}
|
||||
}
|
||||
if hints[0].ImageID != 72 || hints[0].Similar != 0.99 {
|
||||
t.Fatalf("第一条应为相似度最高的 72,实际 %+v", hints[0])
|
||||
}
|
||||
if hints[0].Image != "runway/x.jpg" || hints[0].Name != "Look 1" || hints[0].OwnerTitle != "Fall 2019 Menswear" {
|
||||
t.Fatalf("近邻描述字段未透传: %+v", hints[0])
|
||||
}
|
||||
if hints[0].OwnerID != 720 {
|
||||
t.Fatalf("近邻所属图集 id 未透传: %+v", hints[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHintsReadErrorDegradesQuietly 读结果表失败绝不能让页面渲染失败:返回空、不 panic。
|
||||
func TestHintsReadErrorDegradesQuietly(t *testing.T) {
|
||||
repo := &fakeEmbeddingRepo{listErr: errors.New("db down")}
|
||||
svc := NewSimilarService(repo, 0.93)
|
||||
if got := svc.HintsForImages(context.Background(), "street", []uint32{1}); got != nil {
|
||||
t.Fatalf("读取失败应返回 nil,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrecomputeWritesBidirectionalPairs 预计算:逐张查近邻,并把结果**双向**落库;
|
||||
// 同时验证阈值、排除集(整个图集)与上限都透传给仓储。
|
||||
func TestPrecomputeWritesBidirectionalPairs(t *testing.T) {
|
||||
repo := &fakeEmbeddingRepo{
|
||||
vecs: map[uint32][]float32{1: {0.1, 0.2}, 2: {0.3, 0.4}},
|
||||
neighbors: map[uint32][]repository.SimilarImage{
|
||||
1: {{ImageID: 9, OwnerID: 100, Similar: 0.97}},
|
||||
},
|
||||
}
|
||||
svc := NewSimilarService(repo, 0.93)
|
||||
|
||||
// 图集含图片 1、2;其中只有 1 有近邻 9(属于别的图集)。
|
||||
if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil {
|
||||
t.Fatalf("预计算失败: %v", err)
|
||||
}
|
||||
|
||||
if repo.gotMinSim != 0.93 {
|
||||
t.Fatalf("阈值应透传给仓储,实际 %v", repo.gotMinSim)
|
||||
}
|
||||
if repo.gotLimit != 5 {
|
||||
t.Fatalf("每张最多取 5 条近邻,实际 %d", repo.gotLimit)
|
||||
}
|
||||
// 排除集必须是整个图集:同图集内不比(同场共享背景/灯光/造型,相似度天然偏高)。
|
||||
if len(repo.gotExclude) != 2 || repo.gotExclude[0] != 1 || repo.gotExclude[1] != 2 {
|
||||
t.Fatalf("同图集排除集应为整个图集的图片 id,实际 %v", repo.gotExclude)
|
||||
}
|
||||
|
||||
// 双向:1→9 与 9→1 各一行。
|
||||
if len(repo.replacedRows) != 2 {
|
||||
t.Fatalf("应写入双向共 2 行,实际 %+v", repo.replacedRows)
|
||||
}
|
||||
forward, backward := false, false
|
||||
for _, r := range repo.replacedRows {
|
||||
switch {
|
||||
case r.SelfID == 1 && r.OtherID == 9:
|
||||
forward = true
|
||||
case r.SelfID == 9 && r.OtherID == 1:
|
||||
backward = true
|
||||
}
|
||||
}
|
||||
if !forward || !backward {
|
||||
t.Fatalf("必须双向写入(A→B 与 B→A),实际 %+v", repo.replacedRows)
|
||||
}
|
||||
// 替换范围是本图集的图片 id(先删后插用)。
|
||||
if len(repo.replacedSelfIDs) != 2 {
|
||||
t.Fatalf("替换范围应为整个图集的图片 id,实际 %v", repo.replacedSelfIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrecomputeNoVectorsSkips 图集还没有向量(视觉服务未启用)时不做任何计算与写入。
|
||||
func TestPrecomputeNoVectorsSkips(t *testing.T) {
|
||||
repo := &fakeEmbeddingRepo{vecs: map[uint32][]float32{}}
|
||||
svc := NewSimilarService(repo, 0.93)
|
||||
if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil {
|
||||
t.Fatalf("无向量时应静默返回,实际 %v", err)
|
||||
}
|
||||
if repo.replacedRows != nil || repo.replacedSelfIDs != nil {
|
||||
t.Fatalf("无向量时不应写库,实际 rows=%v selfIDs=%v", repo.replacedRows, repo.replacedSelfIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDupStatsByKind 列表页要用的汇总:按 kind 分组返回图集的重复概况与总图数。
|
||||
func TestDupStatsByKind(t *testing.T) {
|
||||
repo := &fakeEmbeddingRepo{dupStats: map[string]map[uint32]repository.DupStat{
|
||||
"runway": {7: {Total: 120, Images: 3, FirstImageID: 55}},
|
||||
}}
|
||||
svc := NewSimilarService(repo, 0.93)
|
||||
|
||||
got := svc.DupStats(context.Background())
|
||||
st, ok := got["runway"][7]
|
||||
if !ok {
|
||||
t.Fatalf("应返回 runway 图集 7 的概况,实际 %+v", got)
|
||||
}
|
||||
if st.Total != 120 || st.Images != 3 || st.FirstImageID != 55 {
|
||||
t.Fatalf("概况字段不正确: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDupStatsErrorDegradesQuietly 汇总失败只能记日志:列表页必须照常渲染。
|
||||
func TestDupStatsErrorDegradesQuietly(t *testing.T) {
|
||||
repo := &fakeEmbeddingRepo{listErr: errors.New("db down")}
|
||||
svc := NewSimilarService(repo, 0.93)
|
||||
if got := svc.DupStats(context.Background()); len(got) != 0 {
|
||||
t.Fatalf("汇总失败应返回空,得到 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDupStatsDisabled 未启用时直接返回 nil,不触发任何查询。
|
||||
func TestDupStatsDisabled(t *testing.T) {
|
||||
svc := NewSimilarService(nil, 0.93)
|
||||
if got := svc.DupStats(context.Background()); got != nil {
|
||||
t.Fatalf("未启用应返回 nil,得到 %+v", got)
|
||||
}
|
||||
}
|
||||
@ -27,8 +27,6 @@ type StreetSnapService interface {
|
||||
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
|
||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||
// GetStreetSnapDupSources 批量反查街拍重复源(见仓储注释),供编辑页标记重复图。
|
||||
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
|
||||
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteImage 软删单张街拍图片。
|
||||
@ -338,11 +336,6 @@ func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.S
|
||||
return snap, orderStreetImagesForEdit(imgs), nil
|
||||
}
|
||||
|
||||
// GetStreetSnapDupSources 批量反查街拍重复源(见仓储注释)。
|
||||
func (s *streetSnapService) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||
return s.repo.GetStreetSnapDupSources(ctx, imageIDs)
|
||||
}
|
||||
|
||||
// orderStreetImagesForEdit 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。
|
||||
// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。
|
||||
func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage {
|
||||
@ -395,16 +388,6 @@ func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) err
|
||||
if key != "" {
|
||||
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||
}
|
||||
// 删图后重算本专辑去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
|
||||
if snapID, err := s.repo.GetSnapImageRunwayID(ctx, imageID); err == nil && snapID != 0 {
|
||||
if err := s.repo.ReresolveSnapDedup(ctx, snapID); err != nil {
|
||||
log.Printf("[warn] 重算街拍 %d 去重失败(重复标记可能陈旧): %v", snapID, err)
|
||||
}
|
||||
}
|
||||
// 跨文章清理:本图若是其它街拍近重复的「源」,那些街拍的 dup_of 需重算/清除。
|
||||
if err := s.repo.ReresolveSnapDupOfSources(ctx, []uint32{imageID}); err != nil {
|
||||
log.Printf("[warn] 跨文章重算街拍图 %d 去重失败(重复标记可能陈旧): %v", imageID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -429,11 +412,6 @@ func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted u
|
||||
// PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 articleService.PurgeGallery,
|
||||
// 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。
|
||||
func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
// 下架前先取存活图片 id:删整篇后,其它街拍中 dup_of 指向本篇图片的标记需跨文章重算/清除。
|
||||
delIDs, err := s.repo.GetSnapLiveImageIDs(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := s.repo.SoftDeleteSnapImages(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -446,11 +424,5 @@ func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
log.Printf("[warn] 下架街拍 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||
}
|
||||
}
|
||||
// 跨文章去重清理:删除整篇后,其它街拍中 dup_of 指向本篇图片的标记重算/清除。
|
||||
if len(delIDs) > 0 {
|
||||
if err := s.repo.ReresolveSnapDupOfSources(ctx, delIDs); err != nil {
|
||||
log.Printf("[warn] 跨文章重算街拍 %d 去重失败(重复标记可能陈旧): %v", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user