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 注释。 // PendingEmbedding 是一张「缺向量」的图片,供回填 worker 拉取。 // 带 Key 而不是完整 URL:worker 自己按存储基址拼(后端可能换了域名,不该把地址写死在队列里)。 type PendingEmbedding struct { Kind string // runway | street ImageID uint32 Key string // 存储 key(S4)或本地相对路径(/uploads/...) } type EmbeddingRepository interface { // ListPendingEmbeddings 取一批「还活着、没有向量、失败次数未达上限」的图片,按 id 升序。 // // 媒体拆分后这条查询是向量回填的唯一入口:媒体机直传只写 key,后端不碰图片字节, // 向量必须靠外部 worker 回传,而 worker 需要知道「还有哪些图缺向量」。 ListPendingEmbeddings(ctx context.Context, kind string, limit, maxAttempts int) ([]PendingEmbedding, error) // CountPendingEmbeddings 统计剩余待回填数量(worker 据此决定是否继续拉)。 CountPendingEmbeddings(ctx context.Context, kind string, maxAttempts int) (int, error) // RecordEmbeddingFailure 记一次失败(attempts+1)。达到 maxAttempts 后该图不再进队列。 RecordEmbeddingFailure(ctx context.Context, kind string, imageID uint32, reason string) error // 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}) } // ListPendingEmbeddings 取缺失向量的图片。 // // 三个 LEFT JOIN 各有明确目的: // - e 判「已有没有向量」; // - f 判「失败次数是否已到上限」——没有它,一张永远编不出来的坏图会让每轮回填都空转; // - i.is_deleted = 0 跳过软删图,避免为已删内容白算向量。 // // 表名/列名来自 ownerOf 常量,kind 走参数绑定,无注入面。 func (r *embeddingRepository) ListPendingEmbeddings(ctx context.Context, kind string, limit, maxAttempts int) ([]PendingEmbedding, error) { owner, ok := ownerOf(kind) if !ok { return nil, nil } if limit <= 0 { limit = 50 } if maxAttempts <= 0 { maxAttempts = 5 } var rows []struct { ImageID uint32 `gorm:"column:image_id"` Image string `gorm:"column:image"` } if err := r.db.WithContext(ctx).Raw( "SELECT i.id AS image_id, i.image AS image "+ "FROM "+owner.table+" AS i "+ "LEFT JOIN image_embeddings AS e ON e.kind = ? AND e.image_id = i.id "+ "LEFT JOIN image_embedding_failures AS f ON f.kind = ? AND f.image_id = i.id "+ "WHERE i.is_deleted = 0 AND i.image <> '' AND e.image_id IS NULL "+ " AND coalesce(f.attempts, 0) < ? "+ "ORDER BY i.id LIMIT ?", kind, kind, maxAttempts, limit).Scan(&rows).Error; err != nil { return nil, err } out := make([]PendingEmbedding, 0, len(rows)) for _, row := range rows { out = append(out, PendingEmbedding{Kind: kind, ImageID: row.ImageID, Key: row.Image}) } return out, nil } // CountPendingEmbeddings 统计剩余量(与 ListPendingEmbeddings 同口径,避免两边不一致)。 func (r *embeddingRepository) CountPendingEmbeddings(ctx context.Context, kind string, maxAttempts int) (int, error) { owner, ok := ownerOf(kind) if !ok { return 0, nil } if maxAttempts <= 0 { maxAttempts = 5 } var n int if err := r.db.WithContext(ctx).Raw( "SELECT count(*) FROM "+owner.table+" AS i "+ "LEFT JOIN image_embeddings AS e ON e.kind = ? AND e.image_id = i.id "+ "LEFT JOIN image_embedding_failures AS f ON f.kind = ? AND f.image_id = i.id "+ "WHERE i.is_deleted = 0 AND i.image <> '' AND e.image_id IS NULL "+ " AND coalesce(f.attempts, 0) < ?", kind, kind, maxAttempts).Scan(&n).Error; err != nil { return 0, err } return n, nil } // RecordEmbeddingFailure 累加失败次数。 // 用 upsert 而不是先查后写:worker 可能并发回传,两步写法会丢计数。 func (r *embeddingRepository) RecordEmbeddingFailure(ctx context.Context, kind string, imageID uint32, reason string) error { if len(reason) > 300 { reason = reason[:300] } return r.db.WithContext(ctx).Exec(` INSERT INTO image_embedding_failures (kind, image_id, attempts, last_error, updated_at) VALUES (?, ?, 1, ?, ?) ON CONFLICT (kind, image_id) DO UPDATE SET attempts = image_embedding_failures.attempts + 1, last_error = EXCLUDED.last_error, updated_at = EXCLUDED.updated_at`, kind, imageID, reason, uint32(time.Now().Unix())).Error } // 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 }