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

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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{}).

View File

@ -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).

View File

@ -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")
}
}

View File

@ -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 漂移,连既有街拍测试也跑不了)暂未加入,待本地库补迁移后补回。

View 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)
}
}
}

View 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
}

View 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
}

View File

@ -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 {

View File

@ -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)。
//

View File

@ -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{}).

View 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
}