update
This commit is contained in:
@ -46,7 +46,13 @@ func TestAdminMixedListFilterAndOrder(t *testing.T) {
|
||||
|
||||
repo := NewAdminRepository(db)
|
||||
|
||||
all, total, err := repo.ListMixed(t.Context(), "", "", 1, 100)
|
||||
// 页大小必须显著大于库中总行数:本测试连的是**活库**(与开发库同一套 configs),
|
||||
// 重爬会持续写入 pending 行,而排序是「pending 优先、其次 id 倒序」——种子里的走秀是
|
||||
// published,恒排在所有 pending 之后。曾用固定 100,重爬把 pending 写到 115 行后,
|
||||
// 种子就被挤出第一页,断言随数据量随机失败(与被测逻辑无关)。
|
||||
const pageSize = 100000
|
||||
|
||||
all, total, err := repo.ListMixed(t.Context(), "", "", 1, pageSize)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMixed 全部失败: %v", err)
|
||||
}
|
||||
@ -67,7 +73,7 @@ func TestAdminMixedListFilterAndOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
// 待审核过滤:街拍(pending) 出现,走秀(published) 不出现。
|
||||
pending, _, err := repo.ListMixed(t.Context(), "pending", "", 1, 100)
|
||||
pending, _, err := repo.ListMixed(t.Context(), "pending", "", 1, pageSize)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMixed pending 失败: %v", err)
|
||||
}
|
||||
@ -164,11 +170,28 @@ func TestAdminMixedKindFilter(t *testing.T) {
|
||||
t.Fatalf("kind=street 至少应含 1 条")
|
||||
}
|
||||
|
||||
all, _, err := repo.ListMixed(ctx, "", "", 1, 50)
|
||||
// 注意:必须比对**总数**而非本页行数——三种查询都按 size=50 截断,
|
||||
// 库里 runway 超过 50 条时 len(all) 恒为 50,拿页长相加会假失败(与数据量相关的脆弱断言)。
|
||||
all, allTotal, err := repo.ListMixed(ctx, "", "", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("kind='' 失败: %v", err)
|
||||
}
|
||||
if len(all) != len(runwayOnly)+len(streetOnly) {
|
||||
t.Fatalf("全部应等于两类型之和:all=%d runway=%d street=%d", len(all), len(runwayOnly), len(streetOnly))
|
||||
_, runwayTotal, err := repo.ListMixed(ctx, "", "runway", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("kind=runway 计数失败: %v", err)
|
||||
}
|
||||
_, streetTotal, err := repo.ListMixed(ctx, "", "street", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("kind=street 计数失败: %v", err)
|
||||
}
|
||||
if allTotal != runwayTotal+streetTotal {
|
||||
t.Fatalf("全部总数应等于两类型之和:all=%d runway=%d street=%d", allTotal, runwayTotal, streetTotal)
|
||||
}
|
||||
// 本页行数不得超过分页上限,且 kind='' 的第一页应同时反映两种类型的存在。
|
||||
if len(all) == 0 {
|
||||
t.Fatal("kind='' 至少应含 1 条")
|
||||
}
|
||||
if len(all) > 50 {
|
||||
t.Fatalf("本页行数不应超过 size=50,实际 %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,10 +22,17 @@ type AdminRecordCard struct {
|
||||
Status string // pending / published / rejected
|
||||
}
|
||||
|
||||
// DupStat 某记录的图片去重统计(仅统计 is_deleted=0 的存活行)。
|
||||
type DupStat struct {
|
||||
Total int // 存活图片总数(含主图与细节图)
|
||||
Dup int // is_duplicate=1 的张数
|
||||
// AdminRecordStats 是一条记录(图集)在列表上要展示的派生统计:原图字节数之和 + 主图 / 副图行数。
|
||||
//
|
||||
// 为什么主副要分开、而不能直接用 brand_runways.image_count:
|
||||
// 那一列的口径**只数主图**(见 service.countMainImages),并驱动公开卡片的「N 张」角标
|
||||
// 与 FeaturedIDs 的 SUM(image_count) 热度,不能改。但后台「图数」列若照搬它,就会和编辑页
|
||||
// 看到的图片总数对不上(实测 image_count=49 的图集实际有 224 行:49 主图 + 175 细节图)。
|
||||
// 因此列表另取真实行数,主副分别展示。
|
||||
type AdminRecordStats struct {
|
||||
Size int64 // 原图字节数之和(image_size 列,worker 下载时写入)
|
||||
Main int // 主图行数(is_detail = 0)
|
||||
Detail int // 副图(细节图)行数(is_detail = 1)
|
||||
}
|
||||
|
||||
// AdminRepository 文章管理页所需的跨表只读查询。
|
||||
@ -36,12 +43,9 @@ type AdminRepository interface {
|
||||
ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error)
|
||||
// CountMixed 统计某状态 + 类型下的记录总数(用于 tab 角标)。
|
||||
CountMixed(ctx context.Context, status, kind string) (int64, error)
|
||||
// DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。
|
||||
// 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。
|
||||
DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error)
|
||||
// ImageSizeByRecords 批量返回若干记录的原图字节数之和(按 kind 分别查对应图片表);
|
||||
// 返回 owner id → 字节数。供文章管理混合列表展示「图集原图总大小」。
|
||||
ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]int64, error)
|
||||
// ImageSizeByRecords 批量返回若干记录的原图统计(字节数之和 + 主/副图行数,按 kind 分别查对应图片表);
|
||||
// 返回 owner id → 统计,不存在的 id 不出现在 map 中。供文章管理混合列表展示「图数」「原图总大小」。
|
||||
ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]AdminRecordStats, error)
|
||||
}
|
||||
|
||||
type adminRepository struct {
|
||||
@ -114,11 +118,13 @@ func (r *adminRepository) ListMixed(ctx context.Context, status, kind string, pa
|
||||
return cards, total, nil
|
||||
}
|
||||
|
||||
// DupRateByRecords 批量统计若干记录的图片去重情况。
|
||||
// kind=runway 查 brand_runway_images(外键 runway_id);kind=street 查 street_snap_images(外键 snap_id)。
|
||||
// 只统计 is_deleted=0 的存活行;命中阈值为 phash.DefaultThreshold(当前 4)。
|
||||
func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) {
|
||||
out := make(map[uint32]DupStat)
|
||||
// ImageSizeByRecords 批量返回若干记录的原图统计(字节数之和 + 主/副图行数,按 kind 分别查对应图片表)。
|
||||
// 返回 owner id → 统计,不存在的 id 不出现在 map 中。
|
||||
//
|
||||
// 字节数、主图数、副图数在**同一次扫描**里算完(都只依赖同一批图片行),避免为「图数」再查一遍库。
|
||||
// 一律过滤 is_deleted = 0:软删的图不参与展示,也不该计入图数。
|
||||
func (r *adminRepository) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]AdminRecordStats, error) {
|
||||
out := make(map[uint32]AdminRecordStats)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
@ -132,56 +138,24 @@ func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids
|
||||
return out, nil
|
||||
}
|
||||
type agg struct {
|
||||
Owner uint32 `gorm:"column:owner"`
|
||||
Total int `gorm:"column:total"`
|
||||
Dup int `gorm:"column:dup"`
|
||||
Owner uint32 `gorm:"column:owner"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
Main int `gorm:"column:main"`
|
||||
Detail int `gorm:"column:detail"`
|
||||
}
|
||||
var rows []agg
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table(table).
|
||||
Select(fk + " AS owner, COUNT(*) AS total, COALESCE(SUM(CASE WHEN is_duplicate = 1 THEN 1 ELSE 0 END), 0) AS dup").
|
||||
Select(fk + " AS owner, COALESCE(SUM(image_size), 0) AS total, " +
|
||||
"COUNT(*) FILTER (WHERE is_detail = 0) AS main, " +
|
||||
"COUNT(*) FILTER (WHERE is_detail = 1) AS detail").
|
||||
Where(fk+" IN ? AND is_deleted = 0", ids).
|
||||
Group(fk).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.Owner] = DupStat{Total: row.Total, Dup: row.Dup}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ImageSizeByRecords 批量返回若干记录的原图字节数之和(按 kind 分别查对应图片表)。
|
||||
// 返回 owner id → 字节数;不存在的 id 不出现在 map 中。供文章管理混合列表展示「图集原图总大小」。
|
||||
func (r *adminRepository) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]int64, error) {
|
||||
out := make(map[uint32]int64)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var table, fk string
|
||||
switch kind {
|
||||
case "runway":
|
||||
table, fk = "brand_runway_images", "runway_id"
|
||||
case "street":
|
||||
table, fk = "street_snap_images", "snap_id"
|
||||
default:
|
||||
return out, nil
|
||||
}
|
||||
type agg struct {
|
||||
Owner uint32 `gorm:"column:owner"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
}
|
||||
var rows []agg
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table(table).
|
||||
Select(fk + " AS owner, COALESCE(SUM(image_size), 0) AS total").
|
||||
Where(fk+" IN ? AND is_deleted = 0", ids).
|
||||
Group(fk).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.Owner] = row.Total
|
||||
out[row.Owner] = AdminRecordStats{Size: row.Total, Main: row.Main, Detail: row.Detail}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -3,13 +3,11 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/phash"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -39,17 +37,6 @@ type ArticleRepository interface {
|
||||
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
||||
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||
// GetRunwayDupSources 批量反查重复源:给定一组走秀图片 id(即 dup_of 指向的源图 id),
|
||||
// 返回每张源图及其所属走秀(标题、品牌)的上下文,供编辑页标记重复图时展示来源。
|
||||
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
|
||||
GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
||||
// ReresolveRunwayDedup 删图后重算本画廊去重,消除陈旧 dup_of(见方法注释)。
|
||||
ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error
|
||||
// ReresolveRunwayDupOfSources 删图后清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
||||
ReresolveRunwayDupOfSources(ctx context.Context, deletedImageIDs []uint32) error
|
||||
// GetRunwayLiveImageIDs 取某走秀全部存活图片 id(级联下架前用于跨文章去重清理)。
|
||||
GetRunwayLiveImageIDs(ctx context.Context, runwayID uint32) ([]uint32, error)
|
||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
||||
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
||||
@ -404,182 +391,6 @@ func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint3
|
||||
return img.Image, nil
|
||||
}
|
||||
|
||||
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,
|
||||
// 供编辑页删图后重算去重时定位所属画廊)。
|
||||
func (r *articleRepository) GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
|
||||
var img model.BrandRunwayImage
|
||||
err := r.db.WithContext(ctx).Select("runway_id").Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return img.RunwayID, nil
|
||||
}
|
||||
|
||||
// nearDupExceptRunway 在 brand_runway_images 内做全局近重复检索(is_deleted=0),
|
||||
// 但排除自身与「同 runway 且 sort_order 在后的图」——后者防止重算时后入者反成源导致互指环。
|
||||
// 命中返回源图 id,无则 0。
|
||||
func nearDupExceptRunway(db *gorm.DB, phashBits string, threshold int, excludeID, runwayID, sortOrder uint32) (uint32, error) {
|
||||
l2Limit := math.Sqrt(float64(threshold))
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
if err := db.Table("brand_runway_images").
|
||||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
||||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
||||
Where("is_deleted = 0").
|
||||
Where("id <> ?", excludeID).
|
||||
Where("(runway_id <> ? OR sort_order < ?)", runwayID, sortOrder).
|
||||
Order("dist ASC").
|
||||
Limit(1).
|
||||
Scan(&row).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
// ReresolveRunwayDedup 删除图片后重解本画廊去重:先清空全部存活图的 is_duplicate/dup_of,
|
||||
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
|
||||
//
|
||||
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;
|
||||
// 删除「源图」后,剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」,且「查看重复来源」指向已删
|
||||
// 源图(其 S4 文件可能已被清理)而打不开。重算后,被删源图不再作为参照,剩余近重复图重新确立
|
||||
// 「先入者为准」的源/副本关系,使「重复」标记始终反映当前真实图片。
|
||||
func (r *articleRepository) ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error {
|
||||
var imgs []model.BrandRunwayImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, im := range imgs {
|
||||
if !im.Phash.Valid || im.Phash.String == "" {
|
||||
continue
|
||||
}
|
||||
src, err := nearDupExceptRunway(tx, im.Phash.String, phash.DefaultThreshold, im.ID, runwayID, im.SortOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if src != 0 && src != im.ID {
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ReresolveRunwayDupOfSources 删除图片后,清理「以被删图为源」的跨文章重复标记。
|
||||
// 给定刚被软删的走秀图片 id 列表,找出所有仍存活(其它文章)且 dup_of 指向它们的图片,
|
||||
// 在全局存活图里重新近重复判定:找到新源则改指新源,否则清掉重复标记。
|
||||
// 为什么需要:删 B 文章/图时,A 文章中 dup_of 指向 B 图的行不会被 ReresolveRunwayDedup
|
||||
// (仅本集内重算)清理,导致 A 仍误显「重复」且「查看来源」指向已删图打不开。
|
||||
func (r *articleRepository) ReresolveRunwayDupOfSources(ctx context.Context, deletedImageIDs []uint32) error {
|
||||
if len(deletedImageIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
strIDs := make([]string, 0, len(deletedImageIDs))
|
||||
for _, id := range deletedImageIDs {
|
||||
strIDs = append(strIDs, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
var affected []model.BrandRunwayImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("is_deleted = 0 AND dup_of IN ?", strIDs).
|
||||
Find(&affected).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, im := range affected {
|
||||
if !im.Phash.Valid || im.Phash.String == "" {
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
src, err := nearDupExceptRunway(tx, im.Phash.String, phash.DefaultThreshold, im.ID, im.RunwayID, im.SortOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if src != 0 && src != im.ID {
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetRunwayDupSources 批量反查重复源(见接口注释)。一次 IN 查询 + JOIN 走秀与品牌,
|
||||
// 避免逐图 N+1;源图可能已被删(查不到则跳过,不报错)。
|
||||
func (r *articleRepository) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||
out := make(map[uint32]dto.DupSource, len(imageIDs))
|
||||
if len(imageIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
type srcRow struct {
|
||||
ImageID uint32
|
||||
Image string
|
||||
RunwayID uint32
|
||||
TitleEn string
|
||||
TitleCn string
|
||||
BrandEn string
|
||||
BrandCn string
|
||||
}
|
||||
var rows []srcRow
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("brand_runway_images bri").
|
||||
Select("bri.id AS image_id, bri.image AS image, bri.runway_id AS runway_id, br.title_en AS title_en, br.title_cn AS title_cn, b.name_en AS brand_en, b.name_cn AS brand_cn").
|
||||
Joins("JOIN brand_runways br ON br.id = bri.runway_id").
|
||||
Joins("LEFT JOIN brands b ON b.id = br.brand_id").
|
||||
Where("bri.id IN ?", imageIDs).
|
||||
Where("bri.is_deleted = 0").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rw := range rows {
|
||||
title := rw.TitleEn
|
||||
if title == "" {
|
||||
title = rw.TitleCn
|
||||
}
|
||||
brand := rw.BrandEn
|
||||
if brand == "" {
|
||||
brand = rw.BrandCn
|
||||
}
|
||||
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.RunwayID, Title: title, Brand: brand}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
|
||||
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
||||
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
|
||||
@ -612,18 +423,6 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetRunwayLiveImageIDs 取某走秀全部存活图片的 id(级联下架前取出,供跨文章去重清理)。
|
||||
func (r *articleRepository) GetRunwayLiveImageIDs(ctx context.Context, runwayID uint32) ([]uint32, error) {
|
||||
var ids []uint32
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
|
||||
@ -37,6 +37,11 @@ type BrandRepository interface {
|
||||
Create(ctx context.Context, b *model.Brand) (uint32, error)
|
||||
// Update 按数字主键更新品牌中英文名。
|
||||
Update(ctx context.Context, id uint32, nameEn, nameCn string) error
|
||||
// CountRunwaysByBrand 统计该品牌下「未删除」的走秀档案数(品牌删除的护栏依据)。
|
||||
CountRunwaysByBrand(ctx context.Context, brandID uint32) (int64, error)
|
||||
// SetDeleted 软删 / 恢复品牌(只改 is_deleted 与 updated_at,不动其他列)。
|
||||
// 用软删而非物理删除:品牌可能被历史数据引用,且需要可恢复。不存在返回 ErrNotFound。
|
||||
SetDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||
|
||||
// CrawlBrands 返回所有可被爬虫抓取的品牌(is_deleted=0 且 name_en 非空)。
|
||||
// 供爬虫取任务接口使用:不限制"是否已有走秀档案",因为爬虫本就要去补全尚无档案的品牌。
|
||||
@ -111,13 +116,13 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
|
||||
db = db.Where("name_en ~ ?", latinPrefixRegexp)
|
||||
}
|
||||
|
||||
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
||||
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效;
|
||||
// 唯一区别是"档案"的口径:公开=已发布(视图),后台=未删除(基表,含待审)。
|
||||
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
|
||||
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
|
||||
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
|
||||
if q.Keyword == "" && q.Letter != "OTHER" {
|
||||
// 字母索引默认只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
||||
// 不过滤会污染 A-Z 索引)。两个例外:
|
||||
// 1) 关键词搜索时不限制(用户按名字找品牌,即使暂无档案也应命中);
|
||||
// 2) 中文分桶(OTHER)同样列出全部中文名品牌便于检索;
|
||||
// 3) 后台显式开 All(q.All):品牌目录有 2 万+ 条,后台需要能翻到任意品牌
|
||||
// (否则导入的品牌在界面上等于"不存在")。公开侧永远忽略 All。
|
||||
if q.Keyword == "" && q.Letter != "OTHER" && !(admin && q.All) {
|
||||
if admin {
|
||||
// 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。
|
||||
db = db.Where("id IN (?)", r.hasArticlesSubQueryBase())
|
||||
@ -247,6 +252,39 @@ func (r *brandRepository) Create(ctx context.Context, b *model.Brand) (uint32, e
|
||||
return b.ID, nil
|
||||
}
|
||||
|
||||
// CountRunwaysByBrand 统计该品牌下「未删除」的走秀档案数。
|
||||
// 品牌删除的护栏依据:有档案时禁止删除,否则会留下一批 brand_id 指不到品牌的图集
|
||||
// (brand_runways.brand_id 无外键级联),前台品牌页 / 热门榜都会异常。
|
||||
func (r *brandRepository) CountRunwaysByBrand(ctx context.Context, brandID uint32) (int64, error) {
|
||||
var n int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Where("brand_id = ? AND is_deleted = 0", brandID).
|
||||
Count(&n).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SetDeleted 软删 / 恢复品牌:只改 is_deleted 与 updated_at。
|
||||
// 不存在返回 ErrNotFound(而不是静默成功),避免「点了删除但其实没删」。
|
||||
func (r *brandRepository) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.Brand{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"is_deleted": deleted,
|
||||
"updated_at": uint32(time.Now().Unix()),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update 按数字主键更新品牌中英文名(同时刷新 updated_at)。
|
||||
func (r *brandRepository) Update(ctx context.Context, id uint32, nameEn, nameCn string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:去重核心查询需要真实 PostgreSQL + pgvector 环境。
|
||||
//
|
||||
// 运行方式(先起库,见 scripts/pgvector):
|
||||
//
|
||||
// docker compose -f scripts/pgvector/docker-compose.yml up -d
|
||||
// go test -tags integration ./internal/repository/ -run TestDedup -v
|
||||
//
|
||||
// 默认 `go test ./...` 不会编译本文件(build tag 隔离),CI 无库环境不受影响。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/config"
|
||||
"fashionapi/internal/database"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/phash"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testDB 连真实库并校验去重所需的结构已就绪。
|
||||
//
|
||||
// 结构不再由 AutoMigrate 自动创建(已移除),须先自行准备好库,例如:
|
||||
//
|
||||
// ./bin/dbtool dump -out db_dump.sql
|
||||
// psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db_dump.sql
|
||||
//
|
||||
// 这里只做存在性校验并给出明确提示,避免后续查询报出难懂的错。
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
db, err := database.New(cfg.Database)
|
||||
if err != nil {
|
||||
t.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close(db) })
|
||||
|
||||
if err := db.Exec("SELECT 1 FROM brand_runway_images LIMIT 1").Error; err != nil {
|
||||
t.Fatalf("brand_runway_images 不可用(库结构未就绪?先 dbtool dump 再 psql -f 灌库): %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// insertRunwayImage 直接插入一条走秀图片行(无外键约束,runway_id / brand_id 可填占位值)。
|
||||
func insertRunwayImage(t *testing.T, db *gorm.DB, image, phashBits string) {
|
||||
t.Helper()
|
||||
var ph sql.NullString
|
||||
if phashBits != "" {
|
||||
ph = sql.NullString{String: phashBits, Valid: true}
|
||||
}
|
||||
row := model.BrandRunwayImage{
|
||||
RunwayID: 1,
|
||||
BrandID: 1,
|
||||
Image: image,
|
||||
Name: "look",
|
||||
SortOrder: 1,
|
||||
Phash: ph,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("插入图片失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Where("image = ?", image).Delete(&model.BrandRunwayImage{})
|
||||
})
|
||||
}
|
||||
|
||||
// TestDedupNearDuplicate 近重复:dHash 汉明距离 ≤ 阈值应被 FindNearDuplicateImage 命中。
|
||||
func TestDedupNearDuplicate(t *testing.T) {
|
||||
db := testDB(t)
|
||||
repo := NewIngestRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// 插入一张已有图片,phash 为 64 维全 1 向量。
|
||||
insertRunwayImage(t, db, "runway/near-dup.jpg", phash.ToVectorBits(^uint64(0)))
|
||||
|
||||
tables := []string{"brand_runway_images"}
|
||||
|
||||
// 探测向量:仅第 0 位为 0、其余为 1 → 与全 1 向量汉明距离 = 1,应命中。
|
||||
probe := ^uint64(0) &^ 1
|
||||
_, found, err := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(probe), phash.DefaultThreshold)
|
||||
if err != nil {
|
||||
t.Fatalf("FindNearDuplicateImage 出错: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("汉明距离 1 的近重复应被命中")
|
||||
}
|
||||
|
||||
// 全 1 向量(与库中那条距离 0)的取反不是合法探针(会有空串问题),
|
||||
// 这里改用与库中那条完全相同的全 1 探针(非空、合法)并先断言查询本身不报错。
|
||||
probeSame, foundSame, errSame := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(^uint64(0)), phash.DefaultThreshold)
|
||||
if errSame != nil {
|
||||
t.Fatalf("去重查询不应报错: %v", errSame)
|
||||
}
|
||||
if !foundSame {
|
||||
t.Fatalf("与库中那条完全相同的 phash 应命中")
|
||||
}
|
||||
if probeSame == 0 {
|
||||
t.Fatalf("命中的行 id 不应为 0")
|
||||
}
|
||||
}
|
||||
@ -13,14 +13,11 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/phash"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -93,86 +90,6 @@ func seedEditorSnapImage(t *testing.T, db *gorm.DB, snapID uint32, name string,
|
||||
}
|
||||
return img.ID
|
||||
}
|
||||
|
||||
// seedEditorRunwayImagePhash 插入一张带 phash 的走秀图(isDetail 表示主/细节),返回行 id。
|
||||
func seedEditorRunwayImagePhash(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8, phashBits string) uint32 {
|
||||
t.Helper()
|
||||
var ph sql.NullString
|
||||
if phashBits != "" {
|
||||
ph = sql.NullString{String: phashBits, Valid: true}
|
||||
}
|
||||
img := model.BrandRunwayImage{
|
||||
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
|
||||
SortOrder: order, IsDetail: isDetail, Phash: ph,
|
||||
}
|
||||
if err := db.Create(&img).Error; err != nil {
|
||||
t.Fatalf("插入带 phash 走秀图片失败: %v", err)
|
||||
}
|
||||
return img.ID
|
||||
}
|
||||
|
||||
// editorImageFlag 读某图片的 is_duplicate / dup_of(验证去重重算结果)。
|
||||
func editorImageFlag(t *testing.T, db *gorm.DB, id uint32) (uint8, string) {
|
||||
t.Helper()
|
||||
var row struct {
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of"`
|
||||
}
|
||||
if err := db.Table("brand_runway_images").Select("is_duplicate, dup_of").Where("id = ?", id).Scan(&row).Error; err != nil {
|
||||
t.Fatalf("读去重标记失败: %v", err)
|
||||
}
|
||||
return row.IsDuplicate, row.DupOf
|
||||
}
|
||||
|
||||
// TestEditorReresolveRunwayDedupAfterSourceDeleted 模拟「删掉源图后」重算去重应消除悬空 dup_of:
|
||||
// A 为源(被删),B、C 曾 dup_of=A;重算后 A 退出参照,B 成为新源(dup=0),C 重指 B(dup=1),
|
||||
// 不再有任何图指向已删的 A —— 对应线上「删了源图后仍显示重复、源图查看重复来源打不开」。
|
||||
func TestEditorReresolveRunwayDedupAfterSourceDeleted(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
id := seedEditorRunway(t, db, 3)
|
||||
// A=全1 向量;B 翻转 1 位(距 A=1);C 翻转 2 位(距 A=2)—— 三者互为近重复。
|
||||
allOne := phash.ToVectorBits(^uint64(0))
|
||||
bVec := phash.ToVectorBits(^uint64(0) &^ (uint64(1) << 0))
|
||||
cVec := phash.ToVectorBits(^uint64(0) &^ ((uint64(1) << 0) | (uint64(1) << 1)))
|
||||
a := seedEditorRunwayImagePhash(t, db, id, "A", 1, 0, allOne)
|
||||
b := seedEditorRunwayImagePhash(t, db, id, "B", 2, 0, bVec)
|
||||
c := seedEditorRunwayImagePhash(t, db, id, "C", 3, 0, cVec)
|
||||
|
||||
// 预置入库去重后的陈旧态:B、C 都 dup_of=A。
|
||||
if err := db.Model(&model.BrandRunwayImage{}).Where("id IN ?", []uint32{b, c}).
|
||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(a), 10)}).Error; err != nil {
|
||||
t.Fatalf("预置陈旧去重标记失败: %v", err)
|
||||
}
|
||||
|
||||
// 删掉源图 A(软删,模拟编辑页删除)。
|
||||
if err := repo.DeleteRunwayImage(ctx, a); err != nil {
|
||||
t.Fatalf("删源图失败: %v", err)
|
||||
}
|
||||
|
||||
// 重算去重(编辑页删图会自动调用,这里直接验证仓储方法)。
|
||||
if err := repo.ReresolveRunwayDedup(ctx, id); err != nil {
|
||||
t.Fatalf("重算去重失败: %v", err)
|
||||
}
|
||||
|
||||
diB, ofB := editorImageFlag(t, db, b)
|
||||
diC, ofC := editorImageFlag(t, db, c)
|
||||
if diB != 0 {
|
||||
t.Fatalf("B 应成为新源(is_duplicate=0),实际 %d", diB)
|
||||
}
|
||||
if ofB != "" {
|
||||
t.Fatalf("B 的 dup_of 应为空,实际 %q", ofB)
|
||||
}
|
||||
if diC != 1 {
|
||||
t.Fatalf("C 应标记为重复(is_duplicate=1),实际 %d", diC)
|
||||
}
|
||||
if ofC != strconv.FormatUint(uint64(b), 10) {
|
||||
t.Fatalf("C 的 dup_of 应指向 B(%d),实际 %q", b, ofC)
|
||||
}
|
||||
}
|
||||
|
||||
// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。
|
||||
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
|
||||
t.Helper()
|
||||
@ -409,48 +326,3 @@ func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorCrossArticleRunwayDupClearedOnSourceDeleted 回归「跨文章」去重清理:
|
||||
// A(重复方)的图 dup_of 指向 B(源方)的图;删掉整篇 B 后,A 的图不应再挂指向已删图的 dup_of,
|
||||
// 否则编辑页继续误显「重复」且「查看来源」打不开。对应线上「删了 B,A 的重复标记还在」。
|
||||
func TestEditorCrossArticleRunwayDupClearedOnSourceDeleted(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
// 两篇相互独立文章(不同实体键),各含一张近重复图(同 phash)。
|
||||
srcRunway := seedEditorRunway(t, db, 1) // 源方 B
|
||||
dupRunway := seedEditorRunway(t, db, 1) // 重复方 A
|
||||
allOne := phash.ToVectorBits(^uint64(0))
|
||||
srcImg := seedEditorRunwayImagePhash(t, db, srcRunway, "src", 1, 0, allOne)
|
||||
dupImg := seedEditorRunwayImagePhash(t, db, dupRunway, "dup", 1, 0, allOne)
|
||||
|
||||
// 预置陈旧态:A 的图标记为重复、dup_of 指向 B 的图(跨文章)。
|
||||
if err := db.Model(&model.BrandRunwayImage{}).Where("id = ?", dupImg).
|
||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(srcImg), 10)}).Error; err != nil {
|
||||
t.Fatalf("预置跨文章重复标记失败: %v", err)
|
||||
}
|
||||
|
||||
// 模拟后台删除整篇 B:软删其全部图片,再跨文章重算去重(PurgeGallery 的调用序列)。
|
||||
delIDs, err := repo.GetRunwayLiveImageIDs(ctx, srcRunway)
|
||||
if err != nil {
|
||||
t.Fatalf("取存活图片 id 失败: %v", err)
|
||||
}
|
||||
if _, err := repo.SoftDeleteRunwayImages(ctx, srcRunway); err != nil {
|
||||
t.Fatalf("下架删图失败: %v", err)
|
||||
}
|
||||
if err := repo.ReresolveRunwayDupOfSources(ctx, delIDs); err != nil {
|
||||
t.Fatalf("跨文章重算去重失败: %v", err)
|
||||
}
|
||||
|
||||
di, of := editorImageFlag(t, db, dupImg)
|
||||
if di != 0 {
|
||||
t.Fatalf("删掉源方 B 后,A 的图应清掉重复标记(is_duplicate=0),实际 %d", di)
|
||||
}
|
||||
if of != "" {
|
||||
t.Fatalf("删掉源方 B 后,A 的图 dup_of 应为空,实际 %q", of)
|
||||
}
|
||||
}
|
||||
|
||||
// 注:街拍跨文章去重清理(ReresolveSnapDupOfSources / GetSnapLiveImageIDs)为走秀版的逐行镜像,
|
||||
// 逻辑由 TestEditorCrossArticleRunwayDupClearedOnSourceDeleted 覆盖。本条对应的街拍集成测试因本地
|
||||
// street_snaps 表缺 title_cn 列(预存 schema 漂移,连既有街拍测试也跑不了)暂未加入,待本地库补迁移后补回。
|
||||
|
||||
180
internal/repository/embedding_integration_test.go
Normal file
180
internal/repository/embedding_integration_test.go
Normal file
@ -0,0 +1,180 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:在真实 PostgreSQL + pgvector 上验证 image_embeddings 的读写与余弦近邻检索。
|
||||
//
|
||||
// 单测(embedding_repository_test.go)只覆盖向量串的格式化/解析等纯逻辑;
|
||||
// 本测试覆盖真正跑 SQL 的部分——`?::vector` 转换、`<=>` 余弦算子、
|
||||
// 以及与图片表 join 过滤软删行的行为。这些在 fake 上无法验证。
|
||||
//
|
||||
// 运行:go test -tags integration ./internal/repository/ -run TestEmbeddingRepo -v
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
)
|
||||
|
||||
// TestEmbeddingRepoUpsertListAndSimilar 覆盖写入 → 批量读回 → 余弦近邻检索这一整条链路。
|
||||
func TestEmbeddingRepoUpsertListAndSimilar(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewEmbeddingRepository(db)
|
||||
|
||||
// 1) 造两条走秀图片(近邻检索要 join 图片表过滤软删,因此图片必须真实存在且 is_deleted=0)。
|
||||
now := uint32(1)
|
||||
runway := model.BrandRunway{BrandID: 1, TitleEn: "embedding-it", Status: model.StatusPending, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&runway).Error; err != nil {
|
||||
t.Fatalf("建走秀失败: %v", err)
|
||||
}
|
||||
imgs := []model.BrandRunwayImage{
|
||||
{RunwayID: runway.ID, BrandID: 1, Image: "it/emb-a.jpg", Name: "a", SortOrder: 1, CreatedAt: now, UpdatedAt: now},
|
||||
{RunwayID: runway.ID, BrandID: 1, Image: "it/emb-b.jpg", Name: "b", SortOrder: 2, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
if err := db.Create(&imgs).Error; err != nil {
|
||||
t.Fatalf("建图片失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM image_embeddings WHERE kind = 'runway' AND image_id IN (?, ?)", imgs[0].ID, imgs[1].ID)
|
||||
db.Delete(&model.BrandRunwayImage{}, imgs[0].ID)
|
||||
db.Delete(&model.BrandRunwayImage{}, imgs[1].ID)
|
||||
db.Delete(&model.BrandRunway{}, runway.ID)
|
||||
})
|
||||
|
||||
// 2) 两个几乎相同的 768 维向量(第二个与第一个只差一个分量),相似度应远高于阈值。
|
||||
va := make([]float32, 768)
|
||||
for i := range va {
|
||||
va[i] = 0.1
|
||||
}
|
||||
va[0] = 0.5
|
||||
vb := make([]float32, 768)
|
||||
copy(vb, va)
|
||||
vb[0] = 0.499
|
||||
|
||||
if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, va); err != nil {
|
||||
t.Fatalf("写向量 A 失败: %v", err)
|
||||
}
|
||||
if err := repo.UpsertEmbedding(ctx, "runway", imgs[1].ID, vb); err != nil {
|
||||
t.Fatalf("写向量 B 失败: %v", err)
|
||||
}
|
||||
|
||||
// 3) 批量读回:维度必须保持 768,且值能精确往返。
|
||||
got, err := repo.ListEmbeddings(ctx, "runway", []uint32{imgs[0].ID, imgs[1].ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListEmbeddings 失败: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应读回 2 个向量,得到 %d", len(got))
|
||||
}
|
||||
if len(got[imgs[0].ID]) != 768 {
|
||||
t.Fatalf("向量维度应为 768,得到 %d", len(got[imgs[0].ID]))
|
||||
}
|
||||
if got[imgs[0].ID][0] != 0.5 {
|
||||
t.Fatalf("向量分量往返后不一致:%v", got[imgs[0].ID][0])
|
||||
}
|
||||
|
||||
// 4) 近邻检索:不排除任何图时,A 应找到同图集的 B(高相似度),且结果里不含自身。
|
||||
neighbors, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID, nil, got[imgs[0].ID], 0.9, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("FindSimilarImages 失败: %v", err)
|
||||
}
|
||||
var found *SimilarImage
|
||||
for i := range neighbors {
|
||||
if neighbors[i].ImageID == imgs[0].ID {
|
||||
t.Fatalf("近邻结果不应包含自身 imageID=%d", imgs[0].ID)
|
||||
}
|
||||
if neighbors[i].ImageID == imgs[1].ID {
|
||||
found = &neighbors[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("应找到近邻 B(imageID=%d),实际结果 %+v", imgs[1].ID, neighbors)
|
||||
}
|
||||
if found.Similar < 0.99 {
|
||||
t.Fatalf("两向量几乎相同,相似度应接近 1,实际 %f", found.Similar)
|
||||
}
|
||||
if found.OwnerID != runway.ID {
|
||||
t.Fatalf("近邻所属记录应为 %d,实际 %d", runway.ID, found.OwnerID)
|
||||
}
|
||||
// 近邻的描述字段必须带回,后台靠它们显示「和哪张图像」。
|
||||
if found.Image != "it/emb-b.jpg" || found.Name != "b" || found.OwnerTitle != "embedding-it" {
|
||||
t.Fatalf("近邻描述字段不完整: %+v", found)
|
||||
}
|
||||
|
||||
// 5) 传入同图集排除集后,B 必须被排除——需求是只看「本图集 vs 其他图集」,
|
||||
// 同图集内所有照片共享背景/灯光/造型,相似度天然偏高,参与比对只会是噪音。
|
||||
excluded, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID,
|
||||
[]uint32{imgs[0].ID, imgs[1].ID}, got[imgs[0].ID], 0.9, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("排除同图集时 FindSimilarImages 失败: %v", err)
|
||||
}
|
||||
for _, n := range excluded {
|
||||
if n.ImageID == imgs[1].ID {
|
||||
t.Fatalf("同图集的图应被排除,但仍返回了 imageID=%d", imgs[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 重复写入同一 (kind, image_id) 应走 UPSERT 更新而非插入新行。
|
||||
if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, vb); err != nil {
|
||||
t.Fatalf("重复写向量失败: %v", err)
|
||||
}
|
||||
again, err := repo.ListEmbeddings(ctx, "runway", []uint32{imgs[0].ID})
|
||||
if err != nil {
|
||||
t.Fatalf("重复写后 ListEmbeddings 失败: %v", err)
|
||||
}
|
||||
if len(again) != 1 {
|
||||
t.Fatalf("同一 (kind,image_id) 应只有一行,得到 %d", len(again))
|
||||
}
|
||||
if again[imgs[0].ID][0] != 0.499 {
|
||||
t.Fatalf("UPSERT 应更新为新值,实际 %v", again[imgs[0].ID][0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbeddingRepoSkipsSoftDeletedImages 软删的图片不得作为「疑似同款」参照
|
||||
// (image_embeddings 无 is_deleted 列,靠 join 图片表过滤)。
|
||||
func TestEmbeddingRepoSkipsSoftDeletedImages(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewEmbeddingRepository(db)
|
||||
|
||||
now := uint32(1)
|
||||
runway := model.BrandRunway{BrandID: 1, TitleEn: "embedding-it-softdel", Status: model.StatusPending, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&runway).Error; err != nil {
|
||||
t.Fatalf("建走秀失败: %v", err)
|
||||
}
|
||||
imgs := []model.BrandRunwayImage{
|
||||
{RunwayID: runway.ID, BrandID: 1, Image: "it/sd-a.jpg", Name: "a", SortOrder: 1, CreatedAt: now, UpdatedAt: now},
|
||||
{RunwayID: runway.ID, BrandID: 1, Image: "it/sd-b.jpg", Name: "b", SortOrder: 2, IsDeleted: 1, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
if err := db.Create(&imgs).Error; err != nil {
|
||||
t.Fatalf("建图片失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM image_embeddings WHERE kind = 'runway' AND image_id IN (?, ?)", imgs[0].ID, imgs[1].ID)
|
||||
db.Delete(&model.BrandRunwayImage{}, imgs[0].ID)
|
||||
db.Delete(&model.BrandRunwayImage{}, imgs[1].ID)
|
||||
db.Delete(&model.BrandRunway{}, runway.ID)
|
||||
})
|
||||
|
||||
vec := make([]float32, 768)
|
||||
for i := range vec {
|
||||
vec[i] = 0.1
|
||||
}
|
||||
if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, vec); err != nil {
|
||||
t.Fatalf("写向量 A 失败: %v", err)
|
||||
}
|
||||
if err := repo.UpsertEmbedding(ctx, "runway", imgs[1].ID, vec); err != nil {
|
||||
t.Fatalf("写向量 B(软删图)失败: %v", err)
|
||||
}
|
||||
|
||||
neighbors, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID, nil, vec, 0.0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("FindSimilarImages 失败: %v", err)
|
||||
}
|
||||
for _, n := range neighbors {
|
||||
if n.ImageID == imgs[1].ID {
|
||||
t.Fatalf("已软删的图片(imageID=%d)不应作为同款参照", imgs[1].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
512
internal/repository/embedding_repository.go
Normal file
512
internal/repository/embedding_repository.go
Normal file
@ -0,0 +1,512 @@
|
||||
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
|
||||
}
|
||||
81
internal/repository/embedding_repository_test.go
Normal file
81
internal/repository/embedding_repository_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFormatEmbeddingVector 向量串格式化是写入正确性的关键:
|
||||
// 维度必须精确、float32 精度不能丢(截断会让相似度漂移)。
|
||||
func TestFormatEmbeddingVector(t *testing.T) {
|
||||
v := make([]float32, 768)
|
||||
v[0] = 0.5
|
||||
v[767] = -0.25
|
||||
|
||||
s := FormatEmbeddingVector(v)
|
||||
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
|
||||
t.Fatalf("应输出 [..] 形式的向量串,得到 %q", truncForTest(s))
|
||||
}
|
||||
if got := strings.Count(s, ",") + 1; got != 768 {
|
||||
t.Fatalf("维度应为 768,得到 %d", got)
|
||||
}
|
||||
if !strings.Contains(s, "0.5") || !strings.Contains(s, "-0.25") {
|
||||
t.Fatalf("首尾元素未按 float32 精度输出: %s", truncForTest(s))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatEmbeddingVectorEmpty 空向量必须能安全格式化,不能 panic。
|
||||
func TestFormatEmbeddingVectorEmpty(t *testing.T) {
|
||||
if got := FormatEmbeddingVector(nil); got != "[]" {
|
||||
t.Fatalf("nil 应格式化为 [],得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseEmbeddingVectorRoundTrip 格式化与解析必须能精确往返,
|
||||
// 否则「写进去的向量」与「读出来算相似度的向量」不是同一个,阈值校准会失真。
|
||||
func TestParseEmbeddingVectorRoundTrip(t *testing.T) {
|
||||
v := make([]float32, 768)
|
||||
for i := range v {
|
||||
v[i] = float32(i%17-8) * 0.0173
|
||||
}
|
||||
got := ParseEmbeddingVector(FormatEmbeddingVector(v))
|
||||
if len(got) != len(v) {
|
||||
t.Fatalf("往返后长度应一致:%d != %d", len(got), len(v))
|
||||
}
|
||||
for i := range v {
|
||||
if got[i] != v[i] {
|
||||
t.Fatalf("第 %d 维往返后不一致:%v != %v", i, got[i], v[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseEmbeddingVectorInvalid 非法输入返回 nil(调用方据此跳过该行,不阻断渲染)。
|
||||
func TestParseEmbeddingVectorInvalid(t *testing.T) {
|
||||
for _, bad := range []string{"", "[]", "0.1,0.2", "[0.1,abc]", "[", "null"} {
|
||||
if got := ParseEmbeddingVector(bad); got != nil {
|
||||
t.Fatalf("非法输入 %q 应返回 nil,得到 %v", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatEmbeddingVectorNormalized 归一化向量点积等于余弦相似度:
|
||||
// 用同一向量的自点积验证模长≈1(服务端已归一化的前提)。
|
||||
func TestFormatEmbeddingVectorNormalized(t *testing.T) {
|
||||
v := []float32{0.6, 0.8} // 模长为 1
|
||||
got := ParseEmbeddingVector(FormatEmbeddingVector(v))
|
||||
var dot float64
|
||||
for _, f := range got {
|
||||
dot += float64(f) * float64(f)
|
||||
}
|
||||
if math.Abs(dot-1) > 1e-6 {
|
||||
t.Fatalf("模长应≈1,得到 %f", dot)
|
||||
}
|
||||
}
|
||||
|
||||
func truncForTest(s string) string {
|
||||
if len(s) > 80 {
|
||||
return s[:80] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -63,8 +62,6 @@ type IngestRepository interface {
|
||||
CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
|
||||
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
|
||||
ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error
|
||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
|
||||
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
|
||||
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
|
||||
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
|
||||
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
|
||||
@ -409,13 +406,16 @@ func (r *ingestRepository) BackfillRunwayPublishedAt(ctx context.Context, runway
|
||||
return nil
|
||||
}
|
||||
// 仅当目标 season_code 不与同品牌其它行冲突时才补全 year / season_code。
|
||||
// 注意:占位符顺序必须是 (runwayID, targetSeasonCode) —— 本 Where 有两个 ?,
|
||||
// 漏传 runwayID 会让 season_code 落到 id 槽位,生成 `WHERE id = 'FW27'`
|
||||
// 这种必然语法错误的 SQL(该缺陷曾导致「重爬已存在走秀」的任务无限重试)。
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Where("id = ? AND year = 0 AND is_deleted = 0 "+
|
||||
"AND NOT EXISTS (SELECT 1 FROM brand_runways b "+
|
||||
"WHERE b.brand_id = brand_runways.brand_id AND b.season_code = ? "+
|
||||
"AND b.collection_type = brand_runways.collection_type AND b.id <> brand_runways.id AND b.is_deleted = 0)",
|
||||
targetSeasonCode).
|
||||
runwayID, targetSeasonCode).
|
||||
Updates(map[string]any{
|
||||
"year": year,
|
||||
"season_code": targetSeasonCode,
|
||||
@ -570,39 +570,6 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
||||
}).Error
|
||||
}
|
||||
|
||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。
|
||||
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
|
||||
// phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
|
||||
//
|
||||
// 只比对未软删的行(is_deleted = 0):已被软删(审核剔除 / 复用重审)的旧图不应再作为
|
||||
// 「重复」的参照。注意复用驳回行的时序 —— 去重发生在软删旧图**之前**,那一刻旧行仍是
|
||||
// is_deleted = 0、会被正常命中;dup_of 只是人工留痕,不承担可见性语义。
|
||||
// 本方法被 runway / street 两条入库路径共用,过滤对两者语义一致。
|
||||
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
||||
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
|
||||
l2Limit := math.Sqrt(float64(threshold))
|
||||
for _, t := range tables {
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Dist float64 `gorm:"column:dist"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).Table(t).
|
||||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
||||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
||||
Where("is_deleted = 0").
|
||||
Order("dist ASC").
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if row.ID != 0 {
|
||||
return row.ID, true, nil
|
||||
}
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
// isDuplicateKey 兜底:gorm 的 ErrDuplicatedKey 在不同驱动下的封装不一定一致,
|
||||
// 命中驱动返回的唯一约束冲突错误信息更稳(兼容 MySQL/PostgreSQL/SQLite 不同措辞)。
|
||||
func isDuplicateKey(err error) bool {
|
||||
|
||||
@ -5,12 +5,9 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/phash"
|
||||
)
|
||||
|
||||
func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType string, cover string) *model.BrandRunway {
|
||||
@ -181,40 +178,6 @@ func TestIngestReuseRejectedRefusesPublished(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupIgnoresSoftDeletedImage 去重只比对**未软删**的行(is_deleted = 0)。
|
||||
//
|
||||
// 别把它误读成「复用驳回行时不会自我命中」:复用路径里去重发生在软删旧图**之前**,那一刻
|
||||
// 旧行仍是 is_deleted = 0、仍会被命中——dup_of 只是给人工看的留痕,不参与任何可见性判定。
|
||||
// 本用例锁的是另一条独立保证:**已经**软删的行不再被当作重复参照。
|
||||
//
|
||||
// 用与探测向量完全相同的 phash(距离 0)插入软删行,保证它若被纳入就必然是「最近一条」,
|
||||
// 从而确定性地验证 is_deleted = 0 过滤生效,与库中其它数据无关。
|
||||
func TestDedupIgnoresSoftDeletedImage(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
repo := NewIngestRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
bits := phash.ToVectorBits(^uint64(0))
|
||||
now := uint32(time.Now().Unix())
|
||||
row := model.BrandRunwayImage{
|
||||
RunwayID: 1, BrandID: 1, Image: "ingest/soft-deleted.jpg", Name: "look",
|
||||
SortOrder: 1, IsDeleted: 1, CreatedAt: now, UpdatedAt: now,
|
||||
Phash: sql.NullString{String: bits, Valid: true},
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("插入软删图片失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Where("id = ?", row.ID).Delete(&model.BrandRunwayImage{}) })
|
||||
|
||||
dupID, found, err := repo.FindNearDuplicateImage(ctx, []string{"brand_runway_images"}, bits, phash.DefaultThreshold)
|
||||
if err != nil {
|
||||
t.Fatalf("FindNearDuplicateImage 出错: %v", err)
|
||||
}
|
||||
if found && dupID == row.ID {
|
||||
t.Fatalf("已软删的行(id=%d)不应参与近重复比对", row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestImageCountMatchesAliveMainRows 入库后 image_count 必须等于该记录存活主图行数(is_detail = 0)。
|
||||
//
|
||||
|
||||
@ -3,13 +3,11 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/phash"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -41,17 +39,6 @@ type StreetSnapRepository interface {
|
||||
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
||||
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||
// GetStreetSnapDupSources 批量反查重复源:给定一组街拍图片 id(dup_of 指向的源图 id),
|
||||
// 返回每张源图及其所属街拍的上下文,供编辑页标记重复图时展示来源。
|
||||
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
|
||||
GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
||||
// ReresolveSnapDedup 删图后重算本专辑去重,消除陈旧 dup_of(见方法注释)。
|
||||
ReresolveSnapDedup(ctx context.Context, snapID uint32) error
|
||||
// ReresolveSnapDupOfSources 删图后清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
||||
ReresolveSnapDupOfSources(ctx context.Context, deletedImageIDs []uint32) error
|
||||
// GetSnapLiveImageIDs 取某街拍全部存活图片 id(级联下架前用于跨文章去重清理)。
|
||||
GetSnapLiveImageIDs(ctx context.Context, snapID uint32) ([]uint32, error)
|
||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
||||
// SetSnapCover 更新街拍封面。
|
||||
@ -348,168 +335,6 @@ func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint
|
||||
return img.Image, nil
|
||||
}
|
||||
|
||||
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供编辑页删图后重算去重)。
|
||||
func (r *streetSnapRepository) GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
|
||||
var img model.StreetSnapImage
|
||||
err := r.db.WithContext(ctx).Select("snap_id").Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return img.SnapID, nil
|
||||
}
|
||||
|
||||
// nearDupExceptSnap 在 street_snap_images 内做全局近重复检索(is_deleted=0),但排除自身与
|
||||
// 「同 snap 且 sort_order 在后的图」——防止重算时后入者反成源导致互指环。命中返回源图 id,无则 0。
|
||||
func nearDupExceptSnap(db *gorm.DB, phashBits string, threshold int, excludeID, snapID, sortOrder uint32) (uint32, error) {
|
||||
l2Limit := math.Sqrt(float64(threshold))
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
if err := db.Table("street_snap_images").
|
||||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
||||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
||||
Where("is_deleted = 0").
|
||||
Where("id <> ?", excludeID).
|
||||
Where("(snap_id <> ? OR sort_order < ?)", snapID, sortOrder).
|
||||
Order("dist ASC").
|
||||
Limit(1).
|
||||
Scan(&row).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
// ReresolveSnapDedup 删除图片后重解本专辑去重:先清空全部存活图的 is_duplicate/dup_of,
|
||||
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
|
||||
//
|
||||
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;删除「源图」
|
||||
// 后剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」且「查看重复来源」指向已删源图而打不开。重算后
|
||||
// 被删源图不再作为参照,剩余近重复图重新确立「先入者为准」的源/副本关系。
|
||||
func (r *streetSnapRepository) ReresolveSnapDedup(ctx context.Context, snapID uint32) error {
|
||||
var imgs []model.StreetSnapImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.StreetSnapImage{}).
|
||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, im := range imgs {
|
||||
if !im.Phash.Valid || im.Phash.String == "" {
|
||||
continue
|
||||
}
|
||||
src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, snapID, im.SortOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if src != 0 && src != im.ID {
|
||||
if err := tx.Model(&model.StreetSnapImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ReresolveSnapDupOfSources 删除图片后,清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
||||
// 逻辑同 ReresolveRunwayDupOfSources,仅表换成 street_snap_images。
|
||||
func (r *streetSnapRepository) ReresolveSnapDupOfSources(ctx context.Context, deletedImageIDs []uint32) error {
|
||||
if len(deletedImageIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
strIDs := make([]string, 0, len(deletedImageIDs))
|
||||
for _, id := range deletedImageIDs {
|
||||
strIDs = append(strIDs, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
var affected []model.StreetSnapImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("is_deleted = 0 AND dup_of IN ?", strIDs).
|
||||
Find(&affected).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, im := range affected {
|
||||
if !im.Phash.Valid || im.Phash.String == "" {
|
||||
if err := tx.Model(&model.StreetSnapImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, im.SnapID, im.SortOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if src != 0 && src != im.ID {
|
||||
if err := tx.Model(&model.StreetSnapImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Model(&model.StreetSnapImage{}).
|
||||
Where("id = ? AND is_deleted = 0", im.ID).
|
||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetStreetSnapDupSources 批量反查重复源(见接口注释)。街拍无品牌关联;一次 IN 查询 + JOIN。
|
||||
func (r *streetSnapRepository) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||
out := make(map[uint32]dto.DupSource, len(imageIDs))
|
||||
if len(imageIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
type srcRow struct {
|
||||
ImageID uint32
|
||||
Image string
|
||||
SnapID uint32
|
||||
Title string
|
||||
TitleCn string
|
||||
}
|
||||
var rows []srcRow
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("street_snap_images ssi").
|
||||
Select("ssi.id AS image_id, ssi.image AS image, ssi.snap_id AS snap_id, ss.title AS title, ss.title_cn AS title_cn").
|
||||
Joins("JOIN street_snaps ss ON ss.id = ssi.snap_id").
|
||||
Where("ssi.id IN ?", imageIDs).
|
||||
Where("ssi.is_deleted = 0").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rw := range rows {
|
||||
title := rw.Title
|
||||
if title == "" {
|
||||
title = rw.TitleCn
|
||||
}
|
||||
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.SnapID, Title: title}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
|
||||
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
||||
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
|
||||
@ -542,18 +367,6 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetSnapLiveImageIDs 取某街拍全部存活图片的 id(级联下架前取出,供跨文章去重清理)。
|
||||
func (r *streetSnapRepository) GetSnapLiveImageIDs(ctx context.Context, snapID uint32) ([]uint32, error) {
|
||||
var ids []uint32
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapImage{}).
|
||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) SetSnapCover(ctx context.Context, snapID uint32, image string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
|
||||
46
internal/repository/testhelper_integration_test.go
Normal file
46
internal/repository/testhelper_integration_test.go
Normal file
@ -0,0 +1,46 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试的公共辅助:连接真实 PostgreSQL + pgvector。
|
||||
//
|
||||
// 运行方式(先起库,见 scripts/pgvector):
|
||||
//
|
||||
// docker compose -f scripts/pgvector/docker-compose.yml up -d
|
||||
// go test -tags integration ./internal/repository/ -run <TestName> -v
|
||||
//
|
||||
// 默认 `go test ./...` 不会编译本文件(build tag 隔离),CI 无库环境不受影响。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/config"
|
||||
"fashionapi/internal/database"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testDB 连真实库并校验所需结构已就绪。
|
||||
//
|
||||
// 结构不再由 AutoMigrate 自动创建(已移除),须先自行准备好库,例如:
|
||||
//
|
||||
// ./bin/dbtool dump -out db_dump.sql
|
||||
// psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db_dump.sql
|
||||
//
|
||||
// 这里只做存在性校验并给出明确提示,避免后续查询报出难懂的错。
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
db, err := database.New(cfg.Database)
|
||||
if err != nil {
|
||||
t.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close(db) })
|
||||
|
||||
if err := db.Exec("SELECT 1 FROM brand_runway_images LIMIT 1").Error; err != nil {
|
||||
t.Fatalf("brand_runway_images 不可用(库结构未就绪?先 dbtool dump 再 psql -f 灌库): %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
Reference in New Issue
Block a user