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