Files
backend_v2/docs/superpowers/plans/2026-10-12-drop-phash-add-siglip-similar.md
toom1996 1de1ec46a6 update
2026-10-18 17:42:10 +08:00

49 KiB
Raw Blame History

下线 pHash 去重 + FashionSigLIP「疑似同款」实现计划

面向 AI 代理的工作者: 必需子技能:使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 删除现有基于 dHash 的自动去重整条链路,并接入独立 Python 视觉服务产出的 768 维 FashionSigLIP 向量,在后台图集页展示「疑似同款」提示。

架构: Python 服务(D:\project\fashion-siglip,独立仓库)常驻加载 marqo-fashionSigLIP,暴露 POST /embed 与 GET /health。Go worker 在入库的并发抓取阶段并行调用 /embed,把归一化向量写入既有空表 image_embeddings(vector(768) + HNSW 余弦索引)。后台查看图集时按需做 HNSW top-k 近邻查询,现算现展示,不持久化任何「同款关系」。

技术栈: Go(gin / pgx / pgvector)、PostgreSQL + pgvector、Python 3.11 + FastAPI + uvicorn + torch/open_clip。

规格: docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md —— 执行前必须完整阅读,计划的论证依据来自规格。


全局约束

  • 向量维度固定为 768(M0 实测,非 512)。
  • kind 取值必须对齐 dto.IngestKind:"runway" / "street"。
  • image_embeddings.created_at 是 integer(Unix 秒),写入时传 time.Now().Unix()。
  • 视觉服务不可用 / 超时 / 维度不符时,一律不写向量且绝不阻断入库;不得因此让入库失败。
  • 视觉服务连续失败达 fail_threshold 次后熔断冷却 5 分钟,期间直接跳过 embed(不逐张等待超时)。
  • 生产无 GPU,CPU 推理;本地 .venv 有 CUDA 版 torch。两者算出的向量数值一致,不需按设备分支。
  • --workers 1:模型常驻进程内,多 worker 会各加载一份模型导致内存翻倍。
  • 迁移编号续接现有序列:2026-10-12-01-*.sql。
  • 本计划中每个任务的 commit 步骤可由用户统一执行或合并,不强制逐个提交。

文件结构

新建:

  • internal/vision/client.go —— 视觉服务 HTTP 客户端 + 熔断器(纯逻辑,可单测)
  • internal/vision/client_test.go
  • internal/repository/embedding_repository.go —— image_embeddings 的写入与近邻查询
  • internal/service/similar_service.go —— 「疑似同款」业务编排(阈值过滤 + 装配提示数据)
  • internal/service/similar_service_test.go
  • db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql

修改:

  • internal/config/config.go —— 新增 VisionConfig
  • configs/config.yml —— 新增 vision: 段
  • internal/service/ingest_service.go —— 删去重、加 embed 调用
  • internal/service/article_service.go —— 删 dup_of 重算
  • internal/service/street_snap_service.go —— 删 dup_of 重算
  • internal/model/runway_image.go、internal/model/street_snap.go —— 删字段
  • internal/repository/ingest_repository.go —— 删 FindNearDuplicateImage 与三列写入
  • internal/repository/article_repository.go —— 删 phash <-> 查询
  • internal/repository/street_snap_repository.go —— 删 phash <-> 查询
  • internal/repository/admin_repository.go —— 删重复率统计
  • internal/handler/backstage_handler.go —— 删重复徽章/统计,加「疑似同款」
  • internal/handler/templates/partials.html —— 删「重复」徽章
  • internal/handler/assets/admin.js —— 去掉重复徽章前端逻辑
  • cmd/server/main.go —— 装配 vision 客户端与 embedding 仓储
  • cmd/dbtool/main.go —— 删相关引用
  • README.md —— 更新去重描述

删除:

  • internal/pkg/phash/(整包)
  • cmd/diagdup/(整目录)
  • internal/repository/dedup_integration_test.go

阶段一:下线去重

任务 1:Schema 迁移

文件:

  • 创建:db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql

  • 修改:db/migrations/README.md

  • 步骤 1:确认备份存在

运行:ls db/backups/ 预期:存在 db_dump.sql。若不存在,先跑 ./bin/dbtool dump -clean -out db/backups/db_dump.sql 再继续(本迁移不可逆)。

  • 步骤 2:写迁移 SQL

创建 db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql:

-- 2026-10-12-01 下线 pHash 去重 +启用 image_embeddings 存 FashionSigLIP 向量
--
-- 对应规格:docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md §5.2 / §8
--
-- 不可逆:DROP COLUMN / DROP TABLE 无回滚手段,执行前务必确认 db/backups/ 有备份。

-- ---------------------------------------------------------------------------
-- ① 下线自动去重:DROP 三列 + 两个 HNSW 索引
-- ---------------------------------------------------------------------------
DROP INDEX IF EXISTS brand_runway_images_phash_hnsw;
DROP INDEX IF EXISTS street_snap_images_phash_hnsw;

ALTER TABLE brand_runway_images
  DROP COLUMN IF EXISTS phash,
  DROP COLUMN IF EXISTS is_duplicate,
  DROP COLUMN IF EXISTS dup_of;

ALTER TABLE street_snap_images
  DROP COLUMN IF EXISTS phash,
  DROP COLUMN IF EXISTS is_duplicate,
  DROP COLUMN IF EXISTS dup_of;

-- ---------------------------------------------------------------------------
-- ② 删死表 image_duplicates(2026-09-21 建表后全仓零引用)
-- ---------------------------------------------------------------------------
DROP TABLE IF EXISTS image_duplicates;

-- ---------------------------------------------------------------------------
-- ③ image_embeddings 改造为可用状态
--    该表 2026-09-21 建表后一直零引用、零数据,此处原地改造:
--      - kind: smallint -> varchar(8),对齐 dto.IngestKind("runway" / "street")
--      - embedding: vector(512) -> vector(768),M0 实测模型输出 768 维
--      - 补 (kind, image_id) 唯一索引:原表只有自增主键,
--        不加唯一约束会导致同一张图重复写入多行
-- ---------------------------------------------------------------------------
ALTER TABLE image_embeddings
  ALTER COLUMN kind TYPE varchar(8) USING kind::varchar(8),
  ALTER COLUMN embedding TYPE vector(768);

-- 旧向量维度与新维度不同,必须重建 HNSW 索引(pgvector 不允许改列类型后沿用旧索引)
DROP INDEX IF EXISTS image_embeddings_vec_idx;

CREATE UNIQUE INDEX IF NOT EXISTS uq_image_embeddings_kind_image
  ON image_embeddings (kind, image_id);

CREATE INDEX IF NOT EXISTS image_embeddings_vec_idx
  ON image_embeddings USING hnsw (embedding vector_cosine_ops);
  • 步骤 3:在库上执行并验证

运行:

psql "$DATABASE_URL" -f db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql
psql "$DATABASE_URL" -c "\d image_embeddings"

预期:image_embeddings 的 kind 为 character varying(8)、embedding 为 vector(768),且存在 uq_image_embeddings_kind_image 与 image_embeddings_vec_idx。

  • 步骤 4:更新 migrations/README.md

在表格中追加一行:

| 6 | `2026-10-12-01-drop-dedup-add-embeddings.sql` | 下线 pHash 去重(DROP `phash`/`is_duplicate`/`dup_of` + HNSW 索引 + 死表 `image_duplicates`);`image_embeddings` 的 `kind` 改 `varchar(8)`、`embedding` 改 `vector(768)`,补唯一索引并重建 HNSW。**不可逆**。 |

- [ ] **步骤 5:Commit**

```bash
git add db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql db/migrations/README.md
git commit -m "feat(db): 下线 pHash 去重并改造 image_embeddings 存SigLIP 向量"

任务 2:删除 phash 包与模型字段

文件:

  • 删除:internal/pkg/phash/phash.go、internal/pkg/phash/phash_test.go

  • 修改:internal/model/runway_image.go、internal/model/street_snap.go

  • 步骤 1:删除前确认无其他引用

运行:grep -rn "pkg/phash" --include=*.go . 预期:仅 internal/service/ingest_service.go、internal/repository/{ingest,street_snap,article}_repository.go 引用(任务 3、5 会清掉)。model 包不应出现——若出现说明模型层直接依赖了 phash 包,停下来报告。

  • 步骤 2:删除包
rm -rf internal/pkg/phash
  • 步骤 3:删模型字段

在 internal/model/runway_image.go 的 BrandRunwayImage 中删除三个字段及其注释:

	Phash       sql.NullString // dHash 指纹(pgvector 二进制向量串),NULL 表示无法解码
	IsDuplicate uint8
	DupOf       string // 指向近重复的源图 id(空串表示非重复)

在 internal/model/street_snap.go 的街拍图片结构体中删除同名的 Phash / IsDuplicate / DupOf 三个字段。

  • 步骤 4:确认模型层已无 phash 引用

运行:grep -rn "Phash\|IsDuplicate\|DupOf" internal/model/ 预期:无输出。

  • 步骤 5:Commit
git add -A internal/pkg internal/model
git commit -m "refactor: 删除 phash 包与图片模型的去重字段"

任务 3:删除入库去重逻辑

文件:

  • 修改:internal/service/ingest_service.go

  • 步骤 1:删 dedupImage 函数

删除整个函数(原 721-747 行)及其上方注释:

// dedupImage 判断单张图是否近重复(dHash 汉明距离 ≤ 阈值):命中则仍入库,但标记 is_duplicate + dup_of。
func (s *IngestService) dedupImage(ctx context.Context, kind, phashBits string, timing *fetchTiming) (dupID uint32, isDup uint8) {

该函数整体删除,含内部对 s.repo.FindNearDuplicateImage 的调用与 dto.IngestKind 的 tables 分支。

  • 步骤 2:删并发段的 phash 计算

在每张图的抓取闭包中删除(原841 行附近)这两行:

	phashStart := time.Now()
	res.phashBits = phash.ToVectorBits(phash.Of(data))
	res.phash = time.Since(phashStart)

删除后该闭包只剩下载与上传。不要留res.phashBits = "" 之类的占位赋值——字段本身会在下一步被删掉,留赋值会编译不过。任务 10 会在同一位置插入 embed 调用。

  • 步骤 3:删 downloadResult 的 phash 字段

删除结构体中的:

	phashBits string        // dHash 向量串,空串表示无法解码
	phash     time.Duration // 本张指纹计算耗时

并删除其上方关于 phash 的注释行。

  • 步骤 4:删 fetchTiming 的 phash / dedup 字段与格式化

删除 fetchTiming 结构体中的 phash、dedup 两个字段,并把 formatTiming 简化为只输出 download / upload(embed 字段由任务 10 添加,本步不要引用它,否则编译不过):

func (t *fetchTiming) String() string {
	// 注意:下载 / 上传是「各张图耗时之和」,并发执行下其总和会大于墙钟总耗时,
	// 这是预期行为,不代表超时。
	return fmt.Sprintf("图片=%d 并发=%d 下载=%v 上传=%v(下载/上传为各张累计值)",
		t.total, t.concurrency,
		t.download.Round(time.Millisecond),
		t.upload.Round(time.Millisecond))
}
  • 步骤 5:删串行段的去重调用与落库赋值

删除两处 dedupImage 调用(走秀约 467 行、街拍约 626 行):

		dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, r.phashBits, timing)

改为:

		dupID, isDup := uint32(0), uint8(0)

删除所有落库处的 Phash: 与 IsDuplicate: 赋值(约 343/344、483/484、570/571 行)。

  • 步骤 6:编译验证

运行:go build ./... 预期:此时会报错,因为仓储层 FindNearDuplicateImage 还在但已无调用方(这属于预期,任务 5 清理);ingest_service.go 自身不应有「未使用」错误。若报 phash 未使用等错误,就地清理残留 import。

  • 步骤 7:Commit
git add internal/service/ingest_service.go
git commit -m "refactor(ingest): 移除入库去重判定与 dHash 计算"

任务 4:删除删图/删文章时的 dup_of 重算逻辑

文件:

  • 修改:internal/service/article_service.go(约 297-354 行)

  • 修改:internal/service/street_snap_service.go(约 398-449 行)

  • 步骤 1:删走秀侧重算

删除 article_service.go 中三处带「删图后重算本画廊去重」「跨文章清理」「下架前先取存活图片 id」注释的代码块。这些块的目的仅是维护 dup_of 标记,去重下线后不再需要。

删除后,删图流程应只剩下:取存活图片 id → 软删 / 物理删 → 返回。

  • 步骤 2:删街拍侧重算

同样删除 street_snap_service.go 中对应的三处重算代码块。

  • 步骤 3:确认残留清理函数已无调用

运行:grep -rn "dup_of\|DupOf\|IsDuplicate" internal/service/ 预期:无输出。

  • 步骤 4:编译验证

运行:go build ./... 预期:通过(仓储层残留调用在任务 5 清理;若 article_service.go/street_snap_service.go 内有查询返回 dup_of 的语句被删漏,编译会报错,据此补齐)。

  • 步骤 5:Commit
git add internal/service/article_service.go internal/service/street_snap_service.go
git commit -m "refactor: 移除删图/删文章时的 dup_of 重算逻辑"

任务 5:删除仓储层去重查询与三列写入

文件:

  • 修改:internal/repository/ingest_repository.go

  • 修改:internal/repository/article_repository.go(约 430 行)

  • 修改:internal/repository/street_snap_repository.go(约 372 行)

  • 修改:internal/repository/admin_repository.go

  • 步骤 1:删 FindNearDuplicateImage

删除 ingest_repository.go 中整个 FindNearDuplicateImage 方法(约 575-600 行)及其在 IngestRepository 接口中的声明(约 26 行起的接口体内),连同上方解释 phashBits 为 vector(64) 二进制向量串 的注释。

  • 步骤 2:删写入语句中的三列

在 CreateRunwayWithImages、ReuseRejectedRunway、CreateStreetSnapWithImages 等所有 INSERT 语句中删除 phash、is_duplicate、dup_of 三列及其占位符与参数绑定。

  • 步骤 3:删 phash <-> 近邻查询

删除 article_repository.go(约 430 行)与 street_snap_repository.go(约 372 行)中的:

		Select("id, (phash <-> ?::vector) AS dist", phashBits).
		Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).

连同其所属方法(若该方法只服务于去重,整体删除)及相关 l2Limit 计算。

  • 步骤 4:删重复率统计

删除 admin_repository.go 中基于 is_duplicate 的重复张数统计方法及其在接口中的声明。

  • 步骤 5:编译验证

运行:go build ./... 预期:通过。若 phash 包 import 变成未使用,删除该 import。

  • 步骤 6:Commit
git add internal/repository
git commit -m "refactor(repo): 移除 phash 近邻查询、去重三列写入与重复率统计"

任务 6:删除后台重复徽章与重复率展示

文件:

  • 修改:internal/handler/backstage_handler.go

  • 修改:internal/handler/templates/partials.html(约 62 行)

  • 修改:internal/handler/assets/admin.js

  • 步骤 1:删图集装配中的重复字段

在 backstage_handler.go 约 677-735 行的图集 cells 装配处,删除所有 isDuplicate 与 dupOf 键,并把 var dupCount int 及其 if im.IsDuplicate == 1 { dupCount++ } 一并删除。

  • 步骤 2:删重复率统计与 buildDupSources

删除约 333 行、388 行的批量重复率统计调用,以及约 961 行起的整个 buildDupSources 函数(含其上方注释)。

  • 步骤 3:删模板徽章

在 partials.html 删除:

  {{if eq .isDuplicate 1}}<span class="badge dup">重复</span>

以及与之配套的折叠/隐藏 CSS 类(.badge.dup)与包裹它的条件渲染结构。

  • 步骤 4:删前端逻辑

在 admin.js 中删除读取 isDuplicate / dupOf 并渲染重复徽章、隐藏重复图的代码段;同时删除模板中对应的折叠样式引用。

  • 步骤 5:跑测试

运行:go test ./... 2>&1 | Select-String -Pattern "FAIL" 预期:无 FAIL(既有与去重相关的集成测试会在任务 7 统一处理;若此处出现因本任务改动导致的失败,就地修复)。

  • 步骤 6:Commit
git add internal/handler
git commit -m "refactor(backstage): 移除重复徽章、折叠与重复率展示"

任务 7:清理测试、命令与文档

文件:

  • 删除:internal/repository/dedup_integration_test.go

  • 删除:cmd/diagdup/(整目录)

  • 修改:internal/repository/editor_image_delete_integration_test.go

  • 修改:internal/repository/ingest_single_table_integration_test.go

  • 修改:internal/service/ingest_image_count_test.go

  • 修改:cmd/dbtool/main.go

  • 修改:README.md

  • 步骤 1:删去重专用测试与诊断命令

rm internal/repository/dedup_integration_test.go
rm -rf cmd/diagdup
  • 步骤 2:清理仍引用 phash.ToVectorBits 的测试

在 editor_image_delete_integration_test.go 与 ingest_single_table_integration_test.go 中,删除所有 phash.ToVectorBits(...) 相关的测试数据构造与去重断言(含 allOne / bVec / cVec 之类的指纹变量)。

  • 步骤 3:清理 fake 仓储方法

在 ingest_image_count_test.go 的 fake 仓储中删除 FindNearDuplicateImage 方法定义(约 38 行)。

  • 步骤 4:清理 dbtool 引用

在 cmd/dbtool/main.go 中删除对 phash 包或去重相关逻辑的引用;若无直接引用则无需改动本文件。

  • 步骤 5:全仓扫描残留

运行:

grep -rn "phash\|Phash\|is_duplicate\|IsDuplicate\|dup_of\|DupOf" --include=*.go --include=*.html --include=*.js .

预期:零输出。有残留就逐个清理干净。

  • 步骤 6:更新 README

在 README.md 中删除 pHash / 自动去重的描述,改为说明入库时产出768 维 FashionSigLIP 向量写入 image_embeddings,供后台「疑似同款」提示使用。

  • 步骤 7:跑全量测试

运行:go test ./... 预期:全部通过。

  • 步骤 8:Commit
git add -A
git commit -m "test: 清理去重相关测试与诊断命令,更新 README"

阶段一完成。此时系统行为变化:前台不再自动折叠任何重复图,全部平铺,靠人工清理。这是预期结果。


阶段二:SigLIP 集成

任务 8:新增 VisionConfig

文件:

  • 修改:internal/config/config.go

  • 修改:configs/config.yml

  • 步骤 1:加配置结构体

在 config.go 的 S4Config 定义之后追加:

// VisionConfig 视觉服务(FashionSigLIP)配置。
//
// 入库时把每张图编码成 768 维归一化向量写入 image_embeddings,
// 后台图集页据此提示「疑似同款」。服务不可用时自动降级为「不产出向量」,
// 绝不阻断入库。
type VisionConfig struct {
	// Enabled 总开关;关闭则完全跳过 embed 调用。
	Enabled bool `yaml:"enabled"`
	// Endpoint 服务地址,仅监听内网。env: VISION_ENDPOINT
	Endpoint string `yaml:"endpoint"`
	// TimeoutSeconds 单批 embed 超时(秒),默认 5。
	TimeoutSeconds int `yaml:"timeout_seconds"`
	// Dim 向量维度,必须与模型一致;M0 实测为 768。
	Dim int `yaml:"dim"`
	// SimilarThreshold 「疑似同款」余弦相似度阈值,须经样本校准。
	SimilarThreshold float64 `yaml:"similar_threshold"`
	// FailThreshold 连续失败多少次后熔断冷却。
	FailThreshold int `yaml:"fail_threshold"`
}
  • 步骤 2:挂到 Config

在 Config 结构体中 S4 S4Config \yaml:"s4"`` 之后追加:

	// Vision 视觉服务(FashionSigLIP)配置,用于「疑似同款」提示。
	Vision VisionConfig `yaml:"vision"`
  • 步骤 3:加默认值归一

在该文件的 normalize(或同名默认值处理函数)中追加:

	if c.Vision.Endpoint == "" {
		c.Vision.Endpoint = "http://127.0.0.1:8900"
	}
	if c.Vision.TimeoutSeconds <= 0 {
		c.Vision.TimeoutSeconds = 5
	}
	if c.Vision.Dim <= 0 {
		c.Vision.Dim = 768
	}
	if c.Vision.FailThreshold <= 0 {
		c.Vision.FailThreshold = 3
	}
	if c.Vision.SimilarThreshold <= 0 {
		c.Vision.SimilarThreshold = 0.68
	}

SimilarThreshold 默认值取 0.68:实测同套连拍(c1/c2)相似度仅 0.6965,初稿的 0.85 会漏掉最该命中的样本。任务 11 后需用真实样本校准。

  • 步骤 4:写 yml

在 configs/config.yml 追加:

vision:
  enabled: false
  endpoint: "http://127.0.0.1:8900"
  timeout_seconds: 5
  dim: 768
  similar_threshold: 0.68
  fail_threshold: 3
  • 步骤 5:编译验证

运行:go build ./... 预期:通过。

  • 步骤 6:Commit
git add internal/config configs
git commit -m "feat(config): 新增 VisionConfig(视觉服务 / 疑似同款阈值)"

任务 9:视觉服务客户端

文件:

  • 创建:internal/vision/client.go

  • 创建:internal/vision/client_test.go

  • 步骤 1:编写失败的测试

创建 internal/vision/client_test.go:

package vision

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"
)

// 1x1 纯色 PNG,作为最小合法图片字节。
const tinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="

func TestEmbedReturnsVectorsAndSkipsBroken(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/embed" {
			t.Errorf("路径不对: %s", r.URL.Path)
		}
		json.NewEncoder(w).Encode(map[string]any{
			"dim":      768,
			"vectors":  [][]float32{make([]float32, 768), make([]float32, 768)},
			"indexes":  []int{0, 2},
			"skipped":  []int{1},
			"device":   "cpu",
		})
	}))
	defer srv.Close()

	c := New(srv.URL, 768, 2*time.Second, 3)
	vecs, skipped, err := c.Embed(context.Background(), [][]byte{[]byte("a"), []byte("b"), []byte("c")})
	if err != nil {
		t.Fatalf("Embed 返回错误: %v", err)
	}
	if len(vecs) != 2 {
		t.Fatalf("应有 2 个向量,得到 %d", len(vecs))
	}
	if len(skipped) != 1 || skipped[0] != 1 {
		t.Fatalf("skipped 应为 [1],得到 %v", skipped)
	}
	if len(vecs[0]) != 768 {
		t.Fatalf("向量维度应为 768,得到 %d", len(vecs[0]))
	}
}

func TestEmbedDisabledSkipsCall(t *testing.T) {
	called := false
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
	}))
	defer srv.Close()

	c := New(srv.URL, 768, time.Second, 3)
	c.Disable()
	vecs, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")})
	if err != nil || vecs != nil {
		t.Fatalf("禁用时应返回 nil,nil,实际 %v %v", vecs, err)
	}
	if called {
		t.Fatal("禁用时不应发起 HTTP 请求")
	}
}

func TestEmbedDimensionMismatchIsError(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(map[string]any{
			"dim": 512, "vectors": [][]float32{make([]float32, 512)}, "indexes": []int{0}, "skipped": []int{},
		})
	}))
	defer srv.Close()

	c := New(srv.URL, 768, time.Second, 3)
	if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
		t.Fatal("维度不符应返回错误")
	}
}

func TestEmbedTripBreakerThenCooldownSkips(t *testing.T) {
	// 服务总是 500,验证连续失败达到阈值后熔断、不再发起请求。
	hits := 0
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		hits++
		w.WriteHeader(http.StatusInternalServerError)
	}))
	defer srv.Close()

	c := New(srv.URL, 768, time.Second, 3)
	for i := 0; i < 3; i++ {
		if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
			t.Fatalf("第 %d 次调用应报错", i+1)
		}
	}
	if c.Open() {
		t.Fatal("熔断后 Open 应为 false")
	}
	before := hits
	if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err != nil {
		t.Fatalf("熔断期应直接返回错误而非请求: %v", err)
	}
	if hits != before {
		t.Fatalf("熔断期间不应再发起请求,hits 从 %d 变为 %d", before, hits)
	}
}

func TestEmbedSendsBase64Payload(t *testing.T) {
	var got struct {
		Images []string `json:"images"`
	}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewDecoder(r.Body).Decode(&got)
		json.NewEncoder(w).Encode(map[string]any{
			"dim": 768, "vectors": [][]float32{make([]float32, 768)}, "indexes": []int{0}, "skipped": []int{},
		})
	}))
	defer srv.Close()

	c := New(srv.URL, 768, time.Second, 3)
	if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("hello")}); err != nil {
		t.Fatalf("Embed 返回错误: %v", err)
	}
	want := base64.StdEncoding.EncodeToString([]byte("hello"))
	if len(got.Images) != 1 || got.Images[0] != want {
		t.Fatalf("请求体 base64 不正确: %v", got.Images)
	}
}
  • 步骤 2:运行测试验证失败

运行:go test ./internal/vision/ -v 预期:FAIL,报错 undefined: New、undefined: Client 之类(包内尚无实现)。

  • 步骤 3:实现客户端

创建 internal/vision/client.go:

// Package vision 调用独立部署的 FashionSigLIP 视觉服务,把图片编码成归一化向量。
//
// 设计要点:
//   - 服务不可用、超时、维度不符一律返回错误,调用方据此「不写向量」而非中断入库;
//   - 连续失败达到阈值后熔断冷却,避免每张图都卡在超时上;
//   - 熔断期间不发起任何 HTTP 请求。
package vision

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"sync"
	"time"
)

// MaxBatch 单次请求的最大图片数,与服务端限制保持一致。
const MaxBatch = 32

// ErrUnavailable 表示服务不可用(未启用、熔断中、网络错误、超时、维度不符)。
var ErrUnavailable = errors.New("vision service unavailable")

// ErrBatchTooLarge 表示单批图片数超过 MaxBatch。
var ErrBatchTooLarge = errors.New("vision batch too large")

const cooldown = 5 * time.Minute

// Client 是视觉服务客户端。并发安全。
type Client struct {
	endpoint     string
	dim          int
	failLimit    int
	http         *http.Client
	mu           sync.Mutex
	fails        int
	cooldownEnd  time.Time
	disabled     bool
}

// embedResponse 是服务端 POST /embed 的响应结构。
type embedResponse struct {
	Dim     int       `json:"dim"`
	Vectors [][]float32 `json:"vectors"`
	Indexes []int     `json:"indexes"`
	Skipped []int     `json:"skipped"`
	Device  string    `json:"device"`
}

type embedRequest struct {
	Images []string `json:"images"`
}

// New 构造客户端。
func New(endpoint string, dim int, timeout time.Duration, failLimit int) *Client {
	if timeout <= 0 {
		timeout = 5 * time.Second
	}
	if failLimit <= 0 {
		failLimit = 3
	}
	return &Client{
		endpoint:  endpoint,
		dim:       dim,
		failLimit: failLimit,
		http:      &http.Client{Timeout: timeout},
	}
}

// Disable 关闭客户端,此后 Embed 直接返回 ErrUnavailable 且不发起请求。
func (c *Client) Disable() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.disabled = true
}

// Enabled 报告客户端是否可用(未禁用且不在熔断期)。
func (c *Client) Enabled() bool { return c.Open() }

// Open 报告是否可以发起请求(未禁用且不在熔断冷却期)。
func (c *Client) Open() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.canRequestLocked()
}

func (c *Client) canRequestLocked() bool {
	if c.disabled {
		return false
	}
	return time.Now().After(c.cooldownEnd)
}

// fail 记录一次失败;达到阈值则进入熔断冷却。
func (c *Client) fail() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.fails++
	if c.fails >= c.failLimit {
		c.cooldownEnd = time.Now().Add(cooldown)
		c.fails = 0
	}
}

// ok 记录一次成功,清零连续失败计数。
func (c *Client) ok() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.fails = 0
}

// Embed 把图片字节批量编码为归一化向量。返回的 skipped 是无法解码的图片下标。
//
// 任何故障(未启用 / 熔断 / 网络 / 超时 / 维度不符)都返回 ErrUnavailable,
// 调用方应据此跳过写向量,而不是让入库失败。
func (c *Client) Embed(ctx context.Context, imgs [][]byte) ([][]float32, []int, error) {
	if len(imgs) == 0 {
		return nil, nil, nil
	}
	if len(imgs) > MaxBatch {
		return nil, nil, ErrBatchTooLarge
	}
	if !c.Open() {
		return nil, nil, ErrUnavailable
	}

	payload := embedRequest{Images: make([]string, len(imgs))}
	for i, b := range imgs {
		payload.Images[i] = base64.StdEncoding.EncodeToString(b)
	}
	body, err := json.Marshal(payload)
	if err != nil {
		return nil, nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/embed", bytes.NewReader(body))
	if err != nil {
		return nil, nil, err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		c.fail()
		return nil, nil, ErrUnavailable
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		c.fail()
		return nil, nil, ErrUnavailable
	}
	var out embedResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		c.fail()
		return nil, nil, ErrUnavailable
	}
	if out.Dim != c.dim {
		// 维度不符属于配置与模型不一致,必须告警并拒写,防止污染向量列。
		c.fail()
		return nil, nil, fmt.Errorf("%w: dim=%d want=%d", ErrUnavailable, out.Dim, c.dim)
	}

	c.ok()
	return out.Vectors, out.Skipped, nil
}
  • 步骤 4:运行测试验证通过

运行:go test ./internal/vision/ -v 预期:全部 PASS。

  • 步骤 5:Commit
git add internal/vision
git commit -m "feat(vision): 视觉服务客户端(批量编码 + 熔断降级)"

任务 10:embedding 仓储与入库集成

文件:

  • 创建:internal/repository/embedding_repository.go

  • 修改:internal/service/ingest_service.go

  • 修改:cmd/server/main.go

  • 创建:internal/repository/embedding_repository_test.go

  • 步骤 1:写仓储层失败测试

创建 internal/repository/embedding_repository_test.go:

package repository

import (
	"strings"
	"testing"
)

// 向量串格式化是写入正确性的关键:维度必须精确、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", trunc(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", trunc(s))
	}
}

// 空向量必须能安全格式化,不能 panic。
func TestFormatEmbeddingVectorEmpty(t *testing.T) {
	if got := FormatEmbeddingVector(nil); got != "[]" {
		t.Fatalf("nil 应格式化为 [],得到 %q", got)
	}
}

func trunc(s string) string {
	if len(s) > 80 {
		return s[:80] + "..."
	}
	return s
}

(该测试故意写得直白,只验证「维度正确、带方括号、float32 精度不丢」这三件事。若 splitComma/countDims 与仓库内既有 helper 重名,改为方法内联实现。)

  • 步骤 2:运行测试验证失败

运行:go test ./internal/repository/ -run TestFormatEmbeddingVector -v 预期:FAIL,报错 undefined: FormatEmbeddingVector。

  • 步骤 3:实现仓储层

创建 internal/repository/embedding_repository.go:

package repository

import (
	"context"
	"strconv"
	"strings"

	"github.com/jackc/pgx/v5"
)

// SimilarImage 是一张「疑似同款」的近邻结果。
type SimilarImage struct {
	ImageID uint32  // 近邻图片 id
	OwnerID uint32  // 近邻所属走秀 / 街拍专辑 id(供前端跳转)
	Similar float64 // 余弦相似度 0~1
}

// EmbeddingRepository 读写 image_embeddings 并做近邻检索。
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
	// FindSimilarImages 返回该图的近邻(已排除自身、已过滤软删图片)。
	FindSimilarImages(ctx context.Context, kind string, imageID uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error)
}

// FormatEmbeddingVector 把向量格式化为 pgvector 的文本字面量([x,y,...])。
//
// 用 'g' + -1 位精度:float32 的每个值都能精确往返,避免向量被截断后相似度漂移。
func FormatEmbeddingVector(vec []float32) string {
	var b strings.Builder
	b.Grow(len(vec) * 12)
	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()
}

type embeddingRepository struct {
	pool *pgxpool.Pool
}

// NewEmbeddingRepository 构造 EmbeddingRepository。
func NewEmbeddingRepository(pool *pgxpool.Pool) EmbeddingRepository {
	return &embeddingRepository{pool: pool}
}

func (r *embeddingRepository) UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error {
	return r.BulkUpsertEmbedding(ctx, kind, []uint32{imageID}, [][]float32{vec})
}

func (r *embeddingRepository) BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error {
	if len(ids) == 0 {
		return nil
	}
	// 逐行写入而非 unnest 批量:日入库仅几百张,量级极小,
	// 换来的是可读性与低出错概率。ON CONFLICT 保证同一图重复入库不会写出多行。
	now := nowUnix()
	for i, id := range ids {
		if i >= len(vecs) || vecs[i] == nil {
			continue
		}
		if _, err := r.pool.Exec(ctx, `
			INSERT INTO image_embeddings (kind, image_id, embedding, created_at)
			VALUES ($1, $2, $3::vector, $4)
			ON CONFLICT (kind, image_id) DO UPDATE
			SET embedding = EXCLUDED.embedding, created_at = EXCLUDED.created_at`,
			kind, id, FormatEmbeddingVector(vecs[i]), now); err != nil {
			return err
		}
	}
	return nil
}

`FindSimilarImages` 的实现分两步(先走 HNSW 取候选,再 join 过滤软删,保证能用上索引):

```go
func (r *embeddingRepository) FindSimilarImages(ctx context.Context, kind string, imageID uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error) {
	if len(vec) == 0 || limit <= 0 {
		return nil, nil
	}
	// 放大候选倍数:部分候选会因图片已软删被过滤掉。
	candidate := limit * 5
	if candidate < 20 {
		candidate = 20
	}
	rows, err := r.pool.Query(ctx, `
		SELECT e.image_id,
		       1 - (e.embedding <=> $1::vector) AS sim
		FROM image_embeddings e
		WHERE e.kind = $2
		  AND e.image_id <> $3
		ORDER BY e.embedding <=> $1::vector
		LIMIT $4`, FormatEmbeddingVector(vec), kind, imageID, candidate)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	type cand struct {
		imageID uint32
		sim     float64
	}
	var cands []cand
	for rows.Next() {
		var c cand
		if err := rows.Scan(&c.imageID, &c.sim); err != nil {
			return nil, err
		}
		cands = append(cands, c)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	if len(cands) == 0 {
		return nil, nil
	}

	// 第二步:按 kind join 图片表,过滤软删并取所属文章 id。
	ids := make([]int32, len(cands))
	for i, c := range cands {
		ids[i] = int32(c.imageID)
	}
	var ownerTable string
	switch kind {
	case "runway":
		ownerTable = "brand_runway_images"
	case "street":
		ownerTable = "street_snap_images"
	default:
		return nil, nil
	}
	ownerCol := "runway_id"
	if kind == "street" {
		ownerCol = "snap_id"
	}

	q := `SELECT i.id, i.` + ownerCol + ` FROM ` + ownerTable + ` i
	      WHERE i.id = ANY($1) AND i.is_deleted = 0`
	out, err := r.pool.Query(ctx, q, ids)
	if err != nil {
		return nil, err
	}
	defer out.Close()
	owners := map[int32]uint32{}
	for out.Next() {
		var id int32
		var owner uint32
		if err := out.Scan(&id, &owner); err != nil {
			return nil, err
		}
		owners[id] = owner
	}
	if err := out.Err(); err != nil {
		return nil, err
	}

	res := make([]SimilarImage, 0, limit)
	for _, c := range cands {
		owner, ok := owners[c.imageID]
		if !ok {
			continue // 图片已软删
		}
		if c.sim < minSim {
			continue
		}
		res = append(res, SimilarImage{ImageID: c.imageID, OwnerID: owner, Similar: c.sim})
		if len(res) >= limit {
			break
		}
	}
	return res, nil
}

实现提示:street_snap_images 的所属列名与 brand_runway_images 不同(很可能一个是 snap_id、一个是 runway_id,或街拍用 owner_id)。动手前先读 internal/model/street_snap.go 与 internal/repository/street_snap_repository.go 确认真实列名与表结构,按实际情况替换上面的 ownerTable / ownerCol。不要凭猜测写死。

  • 步骤 4:运行测试验证通过

运行:go test ./internal/repository/ -run TestFormatEmbeddingVector -v 预期:PASS。

  • 步骤 5:接入入库流程

修改 internal/service/ingest_service.go:

  1. 在 IngestService 结构体中新增字段:
	vision   *vision.Client          // 视觉服务客户端(可为 nil:未启用时跳过 embed)
	embedRep repository.EmbeddingRepository // 向量写入与检索(可为 nil:跳过)
  1. 在 NewIngestService 签名末尾追加两个参数 vis *vision.Client, embedRep repository.EmbeddingRepository,并赋值到字段上。两个参数都允许传 nil。

  2. 在 downloadResult 结构体中新增:

	vec      []float32     // FashionSigLIP 向量;nil 表示未产出(服务不可用/失败)
	embed    time.Duration // 本张向量编码耗时
  1. 在每张图的抓取闭包中(原 phash 计算位置),替换为:
		if s.vision != nil && s.vision.Enabled() {
			embedStart := time.Now()
			vecs, _, err := s.vision.Embed(ctx, [][]byte{data})
			res.embed = time.Since(embedStart)
			if err == nil && len(vecs) > 0 {
				res.vec = vecs[0]
			} else if err != nil {
				log.Printf("[ingest] embed 失败 url=%s err=%v(不写向量,继续入库)", url, err)
			}
		}
  1. 在 fetchTiming 结构体中新增 embed time.Duration,并在并发段累加 timing.embed += r.embed(与既有 timing.phash += ... 同一位置),formatTiming 按任务 3 的新串输出。

  2. 在串行段组装落库时,收集有向量的图片,落库成功后调用:

	if s.embedRep != nil {
		var ids []uint32
		var vecs [][]float32
		for _, r := range results {
			if r.vec != nil {
				ids = append(ids, r.imageID)
				veccs = append(vecs, r.vec)
			}
		}
		if len(ids) > 0 {
			if err := s.embedRep.BulkUpsertEmbedding(ctx, kind, ids, vecs); err != nil {
				log.Printf("[ingest] 写向量失败 kind=%s count=%d err=%v(不影响入库)", kind, len(ids), err)
			}
		}
	}

图片 id 需要从 CreateRunwayWithImages / CreateStreetSnapWithImages 的返回或回填中取得。若这两个方法当前不返回新建图片的 id,需先给它们补返回值(或在插入后按 sort_order 回查 id),这是本步的前置改动。

  • 步骤 6:在 main.go 装配

在 cmd/server/main.go 中,NewIngestService 调用处(约 174 行)追加两个参数:

	var embedRep repository.EmbeddingRepository
	var vis *vision.Client
	if cfg.Vision.Enabled {
		vis = vision.New(cfg.Vision.Endpoint, cfg.Vision.Dim, time.Duration(cfg.Vision.TimeoutSeconds)*time.Second, cfg.Vision.FailThreshold)
		embedRep = repository.NewEmbeddingRepository(pool)
	}
	ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy, translateSvc, vis, embedRep)

并补上 internal/vision 的 import。

  • 步骤 7:编译并跑测试

运行:go build ./... && go test ./internal/service/ ./internal/repository/ ./internal/vision/ 预期:全部通过。

  • 步骤 8:Commit
git add internal/repository internal/service cmd/server
git commit -m "feat(ingest): 入库时产出并存储 FashionSigLIP 向量"

任务 11:后台「疑似同款」提示

文件:

  • 创建:internal/service/similar_service.go

  • 创建:internal/service/similar_service_test.go

  • 修改:internal/handler/backstage_handler.go

  • 修改:internal/handler/templates/partials.html

  • 修改:internal/handler/assets/admin.js

  • 修改:cmd/server/main.go

  • 步骤 1:编写失败的测试

创建 internal/service/similar_service_test.go:

package service

import (
	"context"
	"testing"

	"fashionapi/internal/repository"
)

// fakeEmbeddingRepo 模拟仓储:固定返回预置近邻。
type fakeEmbeddingRepo struct {
	res []repository.SimilarImage
}

func (f *fakeEmbeddingRepo) UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error {
	return nil
}

func (f *fakeEmbeddingRepo) BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error {
	return nil
}

func (f *fakeEmbeddingRepo) FindSimilarImages(ctx context.Context, kind string, imageID uint32, vec []float32, minSim float64, limit int) ([]repository.SimilarImage, error) {
	return f.res, nil
}

// SimilarService 需要能从仓储取到该图的向量才能查近邻,故接口需提供取向量能力。
func TestSimilarBelowThresholdIsDropped(t *testing.T) {
	// 阈值过滤应在仓储查询侧完成(已传 minSim),此处验证空结果安全返回。
	svc := NewSimilarService(&fakeEmbeddingRepo{}, 0.9)
	got, err := svc.ForArticle(context.Background(), "runway", nil)
	if err != nil {
		t.Fatalf("ForArticle 返回错误: %v", err)
	}
	if len(got) != 0 {
		t.Fatalf("无向量时应返回空结果,得到 %d", len(got))
	}
}
  • 步骤 2:运行测试验证失败

运行:go test ./internal/service/ -run TestSimilar -v 预期:FAIL,报错 undefined: NewSimilarService。

  • 步骤 3:实现服务

创建 internal/service/similar_service.go:

package service

import (
	"context"

	"fashionapi/internal/repository"
)

// SimilarHint 是一张图的「疑似同款」提示集合。
type SimilarHint struct {
	ImageID  uint32
	OwnerID  uint32
	Similar  float64
}

// SimilarService 组装「疑似同款」提示。
type SimilarService struct {
	embedRep  repository.EmbeddingRepository
	threshold float64
}

// NewSimilarService 构造 SimilarService。threshold 为余弦相似度阈值。
func NewSimilarService(embedRep repository.EmbeddingRepository, threshold float64) *SimilarService {
	return &SimilarService{embedRep: embedRep, threshold: threshold}
}

// ListVectors 取一批图片的已存向量,供 ForArticle 查询近邻使用。
// embedRep 为 nil(视觉服务未启用)时返回 nil map,调用方据此跳过。
func (s *SimilarService) ListVectors(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error) {
	if s == nil || s.embedRep == nil || len(imageIDs) == 0 {
		return nil, nil
	}
	return s.embedRep.ListEmbeddings(ctx, kind, imageIDs)
}

// ForArticle 为一个图集的所有图片查询「疑似同款」提示。
//
// vecByImage 提供每张图的已存向量;为 nil 的图片不参与查询。
// 结果按图片 id 归并,便于前端按图挂标签。
func (s *SimilarService) ForArticle(ctx context.Context, kind string, vecByImage map[uint32][]float32) (map[uint32][]SimilarHint, error) {
	out := map[uint32][]SimilarHint{}
	if s == nil || s.embedRep == nil || len(vecByImage) == 0 {
		return out, nil
	}
	for imageID, vec := range vecByImage {
		if len(vec) == 0 {
			continue
		}
		similar, err := s.embedRep.FindSimilarImages(ctx, kind, imageID, vec, s.threshold, 5)
		if err != nil {
			// 单张查询失败不影响其它图片,也不影响页面渲染。
			continue
		}
		if len(similar) == 0 {
			continue
		}
		hints := make([]SimilarHint, 0, len(similar))
		for _, it := range similar {
			hints = append(hints, SimilarHint{ImageID: it.ImageID, OwnerID: it.OwnerID, Similar: it.Similar})
		}
		out[imageID] = hints
	}
	return out, nil
}

实现提示:FindSimilarImages 只查近邻,但没有取回查询图自身向量的能力。后台要跑这个查询,必须先把该图集所有图片的向量取出来(新增 EmbeddingRepository.ListEmbeddings(ctx, kind, imageIDs) (map[uint32][]float32, error),一条 SQL 批量取)。动手前先补这个仓储方法,否则本任务无法完成。过滤 is_deleted = 0 在仓储层做。

  • 步骤 4:运行测试验证通过

运行:go test ./internal/service/ -run TestSimilar -v 预期:PASS。

  • 步骤 5:补仓储取向量方法

在 internal/repository/embedding_repository.go 的接口与实现中追加:

	// ListEmbeddings 批量取一个图集图片的已存向量;无向量的图片不出现在结果中。
	ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error)

实现用 WHERE kind = $1 AND image_id = ANY($2) SELECT image_id, embedding,pgx 能把 vector 直接扫成字符串再解析为 []float32。

  • 步骤 6:在图集 handler 挂提示

修改 backstage_handler.go 的图集装配处(任务 6 清理重复字段后的同一位置):

  1. 收集该图集所有图片 id,取回向量:
	ids := make([]uint32, 0, len(imgs))
	for _, im := range imgs {
		ids = append(ids, im.ID)
	}
	vecByImage, err := h.similar.ListVectors(ctx, kind, ids)
  1. 查提示并按图片 id 装配进 cells:
	hints, _ := h.similar.ForArticle(ctx, kind, vecByImage)

在每个 cell 的 gin.H{...} 里追加:

			"similarHints": hints[m.ID],
  1. SimilarService 上增加薄封装 ListVectors,内部转调 embedRep.ListEmbeddings,embedRep 为 nil 时返回 nil。
  • 步骤 7:模板与前端展示

在 partials.html 的图集 cell 渲染处,在原「重复」徽章的位置改为渲染同款提示:

  {{range .similarHints}}
    <a class="badge similar" href="/backstage/image/{{.ImageID}}" title="疑似同款(相似度 {{printf "%.2f" .Similar}})">疑似同款 {{printf "%.2f" .Similar}}</a>
  {{end}}

并在 CSS 中新增 .badge.similar 样式(建议琥珀色系,与红色「重复」区分开,避免误认为已折叠)。

前端 admin.js 无需折叠逻辑——同款提示不做隐藏,仅展示角标。

  • 步骤 8:装配并验证

在 cmd/server/main.go 中构造 similarSvc 并注入 BackstageHandler(该 handler 构造函数需新增一个参数)。

运行:go build ./... && go test ./... 预期:全部通过。

  • 步骤 9:Commit
git add internal/service internal/repository internal/handler cmd/server
git commit -m "feat(backstage): 图集页展示「疑似同款」提示"

任务 12:端到端验证

文件: 无代码改动,仅验证。

  • 步骤 1:本地起视觉服务(GPU)
cd D:\project\fashion-siglip
.\.venv\Scripts\python.exe -m uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1

预期日志:模型就绪:device=cuda dim=768。

  • 步骤 2:打开视觉开关

把 configs/config.yml 中 vision.enabled 改为 true。

  • 步骤 3:触发一次入库

用 spider 提交一条走秀或街拍数据入库。

预期日志出现 [ingest] embed 失败 或 不出现该行(取决于服务可达);入库本身必须成功。

  • 步骤 4:验证向量入库
SELECT kind, count(*), min(vector_dims(embedding)) FROM image_embeddings GROUP BY kind;

预期:有行数,vector_dims 为 768。

  • 步骤 5:验证后台提示

打开后台某图集页,确认出现「疑似同款」角标,且该图没有被隐藏(验证「只提示不折叠」这一关键决策)。

  • 步骤 6:验证降级

停掉视觉服务,再提交一次入库。 预期:入库成功,日志出现 [ingest] embed 失败 ...(不写向量,继续入库),image_embeddings 无新增行,后台无同款提示但页面正常。

  • 步骤 7:回归确认去重已下线

打开任一含重复图的前台页面。 预期:所有图片平铺展示,无任何折叠、无「重复」徽章。

  • 步骤 8:Commit(仅文档)

把实测结论补进 README.md(向量维度、开关配置、视觉服务启动方式),提交。


验收标准

  • go build ./... 与 go test ./... 全绿
  • 全仓无 phash / is_duplicate / dup_of 残留(grep -rn 零输出)
  • 入库时 image_embeddings 写入 768 维向量
  • 视觉服务不可用时入库不受影响,日志有明确告警
  • 后台图集页展示「疑似同款」角标,不隐藏任何图片
  • 前台不再有任何自动折叠