This commit is contained in:
toom1996
2026-10-18 17:42:10 +08:00
parent 1b06bd5bda
commit 1de1ec46a6
58 changed files with 5281 additions and 19191 deletions

View 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