513 lines
20 KiB
Go
513 lines
20 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// SimilarImage 是一张「疑似同款」的近邻结果。
|
||
//
|
||
// 除 id 与分数外,还带回对方的图片 key、名称与所属图集标题:
|
||
// 后台必须能直接看出「和哪张图像」,只给一个分数等于让人在全库里翻找。
|
||
type SimilarImage struct {
|
||
ImageID uint32 // 近邻图片 id
|
||
OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转)
|
||
Similar float64 // 余弦相似度 0~1
|
||
Image string // 近邻图片存储 key(前端据此显示缩略图)
|
||
Name string // 近邻图片名(如 "Look 12")
|
||
OwnerTitle string // 近邻所属图集标题(跨图集命中时用来指明在哪个图集)
|
||
}
|
||
|
||
// SimilarPair 是 image_similar_pairs 的写入行:只带 id 与分数,不带描述。
|
||
//
|
||
// 描述靠读侧 join 图片表/图集表取得,这样图片改名、换图集后不会留下陈旧文案。
|
||
type SimilarPair struct {
|
||
SelfID uint32
|
||
OtherID uint32
|
||
Similar float64
|
||
}
|
||
|
||
// SimilarNeighbor 是读出来的近邻:在 SimilarImage 基础上补齐对方的描述字段。
|
||
type SimilarNeighbor struct {
|
||
OtherID uint32 // 近邻图片 id
|
||
OwnerID uint32 // 近邻所属图集 id(供前端跳转)
|
||
Similar float64 // 余弦相似度 0~1
|
||
Image string // 近邻图片存储 key(前端显示缩略图)
|
||
Name string // 近邻图片名(如 "Look 12")
|
||
OwnerTitle string // 近邻所属图集标题
|
||
}
|
||
|
||
// DupStat 是一个图集的「跨图集重复」概况,供文章列表直接标出哪些图集有重复。
|
||
//
|
||
// Total 由图片表实时统计得出,**刻意不用 brand_runways.image_count** ——
|
||
// 那一列是过期缓存(实测 runway 124 实际 240 张、该列却写 87),拿它当分母会把重复率算错。
|
||
type DupStat struct {
|
||
Total int // 该图集存活图片总数(实时统计)
|
||
Images int // 其中有跨图集重复的图片数
|
||
FirstImageID uint32 // 其中 id 最小的一张,列表点进去可直接锚点定位
|
||
}
|
||
|
||
// EmbeddingRepository 读写 image_embeddings 并做近邻检索。
|
||
//
|
||
// 该表存 FashionSigLIP 产出的 768 维归一化向量,仅用于后台「疑似同款」提示,
|
||
// **不参与任何自动去重**。表无 is_deleted 列,因此读侧一律 join 图片表过滤软删行。
|
||
//
|
||
// 近邻结果走 image_similar_pairs 预计算表:入库时(或 dbtool similarbackfill 回填时)
|
||
// 算一次存表,后台页面只读结果。原因见 ReplaceSimilarPairs 注释。
|
||
type EmbeddingRepository interface {
|
||
// UpsertEmbedding 写入或更新一张图的向量((kind, image_id) 唯一)。
|
||
UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error
|
||
// BulkUpsertEmbedding 批量写入;vecs[i] 对应 ids[i],vecs[i] 为 nil 则跳过该行。
|
||
BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error
|
||
// ListEmbeddings 批量取一批图片的已存向量;无向量的图片不出现在结果中。
|
||
ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error)
|
||
// FindSimilarImages 返回该图的近邻(已排除自身与 excludeIDs、已过滤软删图片,按相似度降序)。
|
||
//
|
||
// excludeIDs 用于排除「同图集的图」:当前需求只看「本图集的图与其他图集有没有重复」,
|
||
// 同图集内所有照片共享背景/灯光/模特/造型,相似度天然偏高(实测同场中位 0.69、最高 0.91),
|
||
// 参与比对只会产生噪音。
|
||
FindSimilarImages(ctx context.Context, kind string, imageID uint32, excludeIDs []uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error)
|
||
// ReplaceSimilarPairs 用 rows 整体替换 selfIDs 这些图的近邻对(先删后插,同一事务)。
|
||
//
|
||
// rows 必须**双向**(A→B 与 B→A 各一行):这样打开任一侧所在的图集都能看到提示,
|
||
// 读侧无需 UNION。入库写完向量后调用。
|
||
ReplaceSimilarPairs(ctx context.Context, kind string, selfIDs []uint32, rows []SimilarPair) error
|
||
// ListSimilarPairs 读一批图的近邻(含对方的图片 key / 名称 / 所属图集标题,已过滤软删)。
|
||
ListSimilarPairs(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]SimilarNeighbor, error)
|
||
// DupStatsByOwner 按图集汇总跨图集重复情况(含每组存活图片总数),供文章列表标注。
|
||
//
|
||
// 返回「图集 id -> 概况」;没有任何图片的图集不会出现(调用方按缺失处理为 0)。
|
||
DupStatsByOwner(ctx context.Context, kind string) (map[uint32]DupStat, error)
|
||
}
|
||
|
||
// FormatEmbeddingVector 把向量格式化为 pgvector 的文本字面量(`[x,y,...]`)。
|
||
//
|
||
// 用 'g' + -1 位精度:float32 的每个值都能精确往返,避免向量被截断后相似度漂移。
|
||
func FormatEmbeddingVector(vec []float32) string {
|
||
var b strings.Builder
|
||
b.Grow(len(vec)*12 + 2)
|
||
b.WriteByte('[')
|
||
for i, v := range vec {
|
||
if i > 0 {
|
||
b.WriteByte(',')
|
||
}
|
||
b.WriteString(strconv.FormatFloat(float64(v), 'g', -1, 32))
|
||
}
|
||
b.WriteByte(']')
|
||
return b.String()
|
||
}
|
||
|
||
// ParseEmbeddingVector 把 pgvector 的文本表示(`[x,y,...]`)解析回 float32 切片。
|
||
// 解析失败返回 nil(调用方据此跳过该行,不阻断渲染)。
|
||
func ParseEmbeddingVector(s string) []float32 {
|
||
s = strings.TrimSpace(s)
|
||
if len(s) < 2 || s[0] != '[' || s[len(s)-1] != ']' {
|
||
return nil
|
||
}
|
||
s = s[1 : len(s)-1]
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(s, ",")
|
||
out := make([]float32, 0, len(parts))
|
||
for _, p := range parts {
|
||
f, err := strconv.ParseFloat(strings.TrimSpace(p), 32)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
out = append(out, float32(f))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// imageOwner 描述一种图片所属关系的表名、外键列名,以及主表用于展示的标题列。
|
||
//
|
||
// 上述表名与列名均为代码内常量,不接受外部输入,因此拼接进 SQL 无注入面。
|
||
type imageOwner struct {
|
||
table string // 图片表
|
||
fk string // 图片表里指向主表的外键列
|
||
titleTable string // 主表(图集)
|
||
titleCol string // 主表的标题列
|
||
}
|
||
|
||
// OwnerTable 返回某类图片的「图片表名 / 指向图集的外键列名」,供 dbtool 按图集遍历(回填近邻)使用。
|
||
func OwnerTable(kind string) (table, fk string, ok bool) {
|
||
o, ok := ownerOf(kind)
|
||
if !ok {
|
||
return "", "", false
|
||
}
|
||
return o.table, o.fk, true
|
||
}
|
||
|
||
// ownerOf 按 kind 返回对应图片表的所属关系;未知 kind 返回 ok=false。
|
||
func ownerOf(kind string) (imageOwner, bool) {
|
||
switch kind {
|
||
case "runway":
|
||
// 走秀主表的英文标题列是 title_en(原通用 title 列已由 i18n 迁移删除)。
|
||
return imageOwner{
|
||
table: "brand_runway_images", fk: "runway_id",
|
||
titleTable: "brand_runways", titleCol: "title_en",
|
||
}, true
|
||
case "street":
|
||
return imageOwner{
|
||
table: "street_snap_images", fk: "snap_id",
|
||
titleTable: "street_snaps", titleCol: "title",
|
||
}, true
|
||
default:
|
||
return imageOwner{}, false
|
||
}
|
||
}
|
||
|
||
type embeddingRepository struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// NewEmbeddingRepository 创建向量仓储。
|
||
func NewEmbeddingRepository(db *gorm.DB) EmbeddingRepository {
|
||
return &embeddingRepository{db: db}
|
||
}
|
||
|
||
func (r *embeddingRepository) UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error {
|
||
return r.BulkUpsertEmbedding(ctx, kind, []uint32{imageID}, [][]float32{vec})
|
||
}
|
||
|
||
// BulkUpsertEmbedding 逐行写入而非 unnest 批量:日入库仅几百张,量级极小,
|
||
// 换来的是可读性与低出错概率。ON CONFLICT 保证同一图重复入库不会写出多行。
|
||
func (r *embeddingRepository) BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
now := uint32(time.Now().Unix())
|
||
for i, id := range ids {
|
||
if i >= len(vecs) || vecs[i] == nil {
|
||
continue
|
||
}
|
||
if err := r.db.WithContext(ctx).Exec(`
|
||
INSERT INTO image_embeddings (kind, image_id, embedding, created_at)
|
||
VALUES (?, ?, ?::vector, ?)
|
||
ON CONFLICT (kind, image_id) DO UPDATE
|
||
SET embedding = EXCLUDED.embedding, created_at = EXCLUDED.created_at`,
|
||
kind, id, FormatEmbeddingVector(vecs[i]), now).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *embeddingRepository) ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error) {
|
||
if len(imageIDs) == 0 {
|
||
return nil, nil
|
||
}
|
||
var rows []struct {
|
||
ImageID uint32 `gorm:"column:image_id"`
|
||
Embedding string `gorm:"column:embedding"`
|
||
}
|
||
if err := r.db.WithContext(ctx).
|
||
Table("image_embeddings").
|
||
Select("image_id, embedding").
|
||
Where("kind = ? AND image_id IN ?", kind, imageIDs).
|
||
Scan(&rows).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
out := make(map[uint32][]float32, len(rows))
|
||
for _, row := range rows {
|
||
if vec := ParseEmbeddingVector(row.Embedding); vec != nil {
|
||
out[row.ImageID] = vec
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// FindSimilarImages 两步走:先走 HNSW 取候选(只查 image_embeddings,索引用得上),
|
||
// 再按 kind join 图片表过滤软删行并取所属文章 id(这一步不走索引,但候选集很小)。
|
||
//
|
||
// ⚠️ 第一步必须用 Raw 写死参数顺序,不能用 GORM 的 Table/Select/Where/Order 链式组装:
|
||
// 链式写法里 `Select(..., vecStr)` 与 `Order(gorm.Expr("embedding <=> ?::vector", vecStr))`
|
||
// 各带一个同名参数,实测 GORM 会把 ORDER BY 的那个参数绑到错误位置,
|
||
// 结果 `ORDER BY embedding <=> ...` 退化为按垃圾值排序 —— 候选集变成随机若干行,
|
||
// 真正的近邻被大量漏掉(相似度阈值那一步仍用正确的向量,因此现象是「提示的并不是最像的那张」)。
|
||
func (r *embeddingRepository) FindSimilarImages(ctx context.Context, kind string, imageID uint32, excludeIDs []uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error) {
|
||
if len(vec) == 0 || limit <= 0 {
|
||
return nil, nil
|
||
}
|
||
owner, ok := ownerOf(kind)
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
// 放大候选倍数:部分候选会因图片已软删被第二步过滤掉。
|
||
candidate := limit * 5
|
||
if candidate < 20 {
|
||
candidate = 20
|
||
}
|
||
vecStr := FormatEmbeddingVector(vec)
|
||
|
||
// 排除同图集的图必须写在候选查询里、而不是留到第二步过滤:
|
||
// 若留到第二步,同图集的图会先占满候选名额(同场相似度天然偏高),
|
||
// 真正要看的跨图集近邻反而被 LIMIT 截断在外面。
|
||
//
|
||
// ⚠️ 已知性能瓶颈(2026-10-13 实测):本函数每张图调用一次,一条 116 张图的图集要跑
|
||
// 116 次,而每次往返要传约 20KB(768 维向量文本在 SELECT 与 ORDER BY 各出现一次)。
|
||
// 本机 PostgreSQL 跑在 WSL2 里、Windows 经 wslrelay 访问,该链路大 payload 吞吐极差
|
||
// (实测约 0.47MB/s):服务端执行这条查询只要 0.45ms,客户端往返却要 ~45ms,
|
||
// 合计 116×45ms ≈ 5 秒。**这是页面慢的真正原因**,且与 GORM/参数化/PG 日志/连接池均无关
|
||
// (已逐项实测排除:原生 database/sql 同样 45ms、SELECT 1 只要 1ms、池等待为 0)。
|
||
// 后续方向见 docs 里的讨论:把向量改为二进制参数、或整图集一条 LATERAL 查询、或用内容哈希替代。
|
||
where := "kind = ? AND image_id <> ?"
|
||
args := []any{kind, imageID}
|
||
if len(excludeIDs) > 0 {
|
||
ids := make([]string, 0, len(excludeIDs))
|
||
for _, id := range excludeIDs {
|
||
ids = append(ids, strconv.FormatUint(uint64(id), 10))
|
||
}
|
||
where += " AND image_id NOT IN (" + strings.Join(ids, ",") + ")"
|
||
}
|
||
args = append(args, candidate)
|
||
sqlText := "SELECT image_id, 1 - (embedding <=> '" + vecStr + "'::vector) AS sim " +
|
||
"FROM image_embeddings " +
|
||
"WHERE " + where + " " +
|
||
"ORDER BY embedding <=> '" + vecStr + "'::vector LIMIT ?"
|
||
|
||
type cand struct {
|
||
ImageID uint32 `gorm:"column:image_id"`
|
||
Sim float64 `gorm:"column:sim"`
|
||
}
|
||
var cands []cand
|
||
if err := r.db.WithContext(ctx).Raw(sqlText, args...).Scan(&cands).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if len(cands) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
ids := make([]uint32, 0, len(cands))
|
||
for _, c := range cands {
|
||
ids = append(ids, c.ImageID)
|
||
}
|
||
// 第二步:join 图集主表,一次性取回「对方的图片 key / 名称 / 所属图集标题」。
|
||
// 有了这三项,后台角标就能显示缩略图并指明是哪张图、在哪个图集,
|
||
// 而不是只给一个相似度分数让人自己去找。
|
||
var neighbors []struct {
|
||
ID uint32 `gorm:"column:id"`
|
||
Image string `gorm:"column:image"`
|
||
Name string `gorm:"column:name"`
|
||
Owner uint32 `gorm:"column:owner"`
|
||
OwnerTitle string `gorm:"column:owner_title"`
|
||
}
|
||
// 显式展开占位符,不写 `IN ?`:Raw 路径下切片展开依赖 GORM 内部实现,
|
||
// 显式 `IN (?,?,…)` 才能确定逐值绑定。占位符是常量、表名列名来自 ownerOf 常量,
|
||
// 值一律走参数绑定,无注入面。
|
||
placeholders := make([]string, len(ids))
|
||
joinArgs := make([]any, len(ids))
|
||
for i, id := range ids {
|
||
placeholders[i] = "?"
|
||
joinArgs[i] = id
|
||
}
|
||
if err := r.db.WithContext(ctx).Raw(
|
||
"SELECT i.id AS id, i.image AS image, i.name AS name, "+
|
||
"i."+owner.fk+" AS owner, t."+owner.titleCol+" AS owner_title "+
|
||
"FROM "+owner.table+" AS i "+
|
||
"JOIN "+owner.titleTable+" AS t ON t.id = i."+owner.fk+" "+
|
||
"WHERE i.id IN ("+strings.Join(placeholders, ",")+") AND i.is_deleted = 0",
|
||
joinArgs...).Scan(&neighbors).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
type neighborMeta struct {
|
||
image string
|
||
name string
|
||
owner uint32
|
||
title string
|
||
}
|
||
alive := make(map[uint32]neighborMeta, len(neighbors))
|
||
for _, n := range neighbors {
|
||
alive[n.ID] = neighborMeta{image: n.Image, name: n.Name, owner: n.Owner, title: n.OwnerTitle}
|
||
}
|
||
|
||
res := make([]SimilarImage, 0, limit)
|
||
for _, c := range cands {
|
||
meta, ok := alive[c.ImageID]
|
||
if !ok {
|
||
continue // 图片已软删,不作为「疑似同款」参照
|
||
}
|
||
if c.Sim < minSim {
|
||
continue
|
||
}
|
||
res = append(res, SimilarImage{
|
||
ImageID: c.ImageID,
|
||
OwnerID: meta.owner,
|
||
Similar: c.Sim,
|
||
Image: meta.image,
|
||
Name: meta.name,
|
||
OwnerTitle: meta.title,
|
||
})
|
||
if len(res) >= limit {
|
||
break
|
||
}
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// ReplaceSimilarPairs 用 rows 整体替换 selfIDs 这些图的近邻对。
|
||
//
|
||
// 为什么要有这张预计算表:原实现是在**打开图集页时**对每张图实时做一次向量近邻检索,
|
||
// 一个 116 张图的图集要发 116 次查询、每次约 20KB SQL。本机 PostgreSQL 跑在 WSL2 里、
|
||
// Windows 经 wslrelay 访问,该链路大 payload 吞吐实测约 0.47MB/s,合计要 5 秒。
|
||
// 改成入库时算一次存表后,页面只读结果(一次几百字节的小查询)。
|
||
//
|
||
// 排除 id 用内联字面量而非参数:这些 id 是 uint32,格式化为十进制串后字符集受限,无注入面。
|
||
func (r *embeddingRepository) ReplaceSimilarPairs(ctx context.Context, kind string, selfIDs []uint32, rows []SimilarPair) error {
|
||
if len(selfIDs) == 0 {
|
||
return nil
|
||
}
|
||
ids := make([]string, 0, len(selfIDs))
|
||
for _, id := range selfIDs {
|
||
ids = append(ids, strconv.FormatUint(uint64(id), 10))
|
||
}
|
||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
// 先删掉这批图的旧近邻:图集重爬/重算时避免残留过期对。
|
||
if err := tx.Exec(
|
||
"DELETE FROM image_similar_pairs WHERE kind = ? AND self_id IN ("+strings.Join(ids, ",")+")",
|
||
kind).Error; err != nil {
|
||
return err
|
||
}
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
now := uint32(time.Now().Unix())
|
||
vals := make([]string, 0, len(rows))
|
||
args := make([]any, 0, len(rows)*5)
|
||
for _, p := range rows {
|
||
vals = append(vals, "(?, ?, ?, ?, ?)")
|
||
args = append(args, kind, p.SelfID, p.OtherID, p.Similar, now)
|
||
}
|
||
return tx.Exec(
|
||
"INSERT INTO image_similar_pairs (kind, self_id, other_id, sim, created_at) VALUES "+
|
||
strings.Join(vals, ",")+
|
||
" ON CONFLICT (kind, self_id, other_id) DO UPDATE SET sim = EXCLUDED.sim, created_at = EXCLUDED.created_at",
|
||
args...).Error
|
||
})
|
||
}
|
||
|
||
// ListSimilarPairs 批量读近邻,并 join 图片表与图集主表补齐对方描述。
|
||
//
|
||
// 两条查询、payload 极小(这是页面变快的关键):先按 (kind, self_id) 主键前缀取对,
|
||
// 再按对方 id 一次性取描述。软删图片在此被过滤掉——预计算表里的行可能残留,
|
||
// 不级联清理,靠这里过滤即可。
|
||
func (r *embeddingRepository) ListSimilarPairs(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]SimilarNeighbor, error) {
|
||
if len(imageIDs) == 0 {
|
||
return nil, nil
|
||
}
|
||
owner, ok := ownerOf(kind)
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
var pairs []struct {
|
||
SelfID uint32 `gorm:"column:self_id"`
|
||
OtherID uint32 `gorm:"column:other_id"`
|
||
Sim float64 `gorm:"column:sim"`
|
||
}
|
||
if err := r.db.WithContext(ctx).
|
||
Table("image_similar_pairs").
|
||
Select("self_id, other_id, sim").
|
||
Where("kind = ? AND self_id IN ?", kind, imageIDs).
|
||
Scan(&pairs).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if len(pairs) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
otherIDs := make([]uint32, 0, len(pairs))
|
||
seen := make(map[uint32]bool, len(pairs))
|
||
for _, p := range pairs {
|
||
if !seen[p.OtherID] {
|
||
seen[p.OtherID] = true
|
||
otherIDs = append(otherIDs, p.OtherID)
|
||
}
|
||
}
|
||
ph := make([]string, len(otherIDs))
|
||
args := make([]any, len(otherIDs))
|
||
for i, id := range otherIDs {
|
||
ph[i] = "?"
|
||
args[i] = id
|
||
}
|
||
var metas []struct {
|
||
ID uint32 `gorm:"column:id"`
|
||
Image string `gorm:"column:image"`
|
||
Name string `gorm:"column:name"`
|
||
Owner uint32 `gorm:"column:owner"`
|
||
OwnerTitle string `gorm:"column:owner_title"`
|
||
}
|
||
if err := r.db.WithContext(ctx).Raw(
|
||
"SELECT i.id AS id, i.image AS image, i.name AS name, "+
|
||
"i."+owner.fk+" AS owner, t."+owner.titleCol+" AS owner_title "+
|
||
"FROM "+owner.table+" AS i "+
|
||
"JOIN "+owner.titleTable+" AS t ON t.id = i."+owner.fk+" "+
|
||
"WHERE i.id IN ("+strings.Join(ph, ",")+") AND i.is_deleted = 0",
|
||
args...).Scan(&metas).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
metaByID := make(map[uint32]SimilarNeighbor, len(metas))
|
||
for _, m := range metas {
|
||
metaByID[m.ID] = SimilarNeighbor{
|
||
OtherID: m.ID, OwnerID: m.Owner,
|
||
Image: m.Image, Name: m.Name, OwnerTitle: m.OwnerTitle,
|
||
}
|
||
}
|
||
out := make(map[uint32][]SimilarNeighbor, len(pairs))
|
||
for _, p := range pairs {
|
||
m, ok := metaByID[p.OtherID]
|
||
if !ok {
|
||
continue // 对方已软删或不存在,不作为提示
|
||
}
|
||
m.Similar = p.Sim
|
||
out[p.SelfID] = append(out[p.SelfID], m)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// DupStatsByOwner 按图集汇总跨图集重复情况,一次查完供列表页使用。
|
||
//
|
||
// 用 LEFT JOIN 同时拿到两件事:每组存活图片总数(做分母)与其中有重复的图片数。
|
||
// 分母刻意实时统计而非读 brand_runways.image_count —— 那列是**只记主图**的口径(见 AdminRecordStats)。
|
||
//
|
||
// 必须同时校验「对端那张图还活着」:image_similar_pairs 是**预计算表**,删除图集只会软删
|
||
// 图片行、不会删近邻对,于是对端已删的陈旧近邻会继续留在表里。编辑页的提示路径(ListSimilarPairs)
|
||
// 会过滤掉这些死链,列表汇总若不过滤就会长期显示已不存在的重复(实测:图集 391 的 88 个近邻
|
||
// 对端全部已删,列表仍显示「88 张 · 37%」)。恢复图集(is_deleted 置回 0)后会自动重新计入。
|
||
//
|
||
// 表名/列名来自 ownerOf 常量,kind 走参数绑定,无注入面。
|
||
func (r *embeddingRepository) DupStatsByOwner(ctx context.Context, kind string) (map[uint32]DupStat, error) {
|
||
owner, ok := ownerOf(kind)
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
var rows []struct {
|
||
Owner uint32 `gorm:"column:owner"`
|
||
Total int `gorm:"column:total"`
|
||
Images int `gorm:"column:images"`
|
||
FirstImg uint32 `gorm:"column:first_img"`
|
||
}
|
||
if err := r.db.WithContext(ctx).Raw(
|
||
"SELECT i."+owner.fk+" AS owner, "+
|
||
"count(DISTINCT i.id) AS total, "+
|
||
"count(DISTINCT p.self_id) AS images, "+
|
||
"coalesce(min(p.self_id), 0) AS first_img "+
|
||
"FROM "+owner.table+" AS i "+
|
||
"LEFT JOIN image_similar_pairs AS p ON p.kind = ? AND p.self_id = i.id "+
|
||
" AND EXISTS (SELECT 1 FROM "+owner.table+" AS o WHERE o.id = p.other_id AND o.is_deleted = 0) "+
|
||
"WHERE i.is_deleted = 0 "+
|
||
"GROUP BY i."+owner.fk,
|
||
kind).Scan(&rows).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
out := make(map[uint32]DupStat, len(rows))
|
||
for _, r := range rows {
|
||
out[r.Owner] = DupStat{Total: r.Total, Images: r.Images, FirstImageID: r.FirstImg}
|
||
}
|
||
return out, nil
|
||
}
|