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

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