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

1456 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 下线 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`:
```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:在库上执行并验证**
运行:
```bash
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:删除包**
```bash
rm -rf internal/pkg/phash
```
- [ ] **步骤 3:删模型字段**
在 `internal/model/runway_image.go` 的 `BrandRunwayImage` 中删除三个字段及其注释:
```go
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**
```bash
git add -A internal/pkg internal/model
git commit -m "refactor: 删除 phash 包与图片模型的去重字段"
```
---
### 任务 3:删除入库去重逻辑
**文件:**
- 修改:`internal/service/ingest_service.go`
- [ ] **步骤 1:删 `dedupImage` 函数**
删除整个函数(原 721-747 行)及其上方注释:
```go
// 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 行附近)这两行:
```go
phashStart := time.Now()
res.phashBits = phash.ToVectorBits(phash.Of(data))
res.phash = time.Since(phashStart)
```
删除后该闭包只剩下载与上传。**不要留`res.phashBits = ""` 之类的占位赋值**——字段本身会在下一步被删掉,留赋值会编译不过。任务 10 会在同一位置插入 embed 调用。
- [ ] **步骤 3:删 `downloadResult` 的 phash 字段**
删除结构体中的:
```go
phashBits string // dHash 向量串,空串表示无法解码
phash time.Duration // 本张指纹计算耗时
```
并删除其上方关于 phash 的注释行。
- [ ] **步骤 4:删 `fetchTiming` 的 phash / dedup 字段与格式化**
删除 `fetchTiming` 结构体中的 `phash`、`dedup` 两个字段,并把 `formatTiming` 简化为只输出 download / upload(`embed` 字段由任务 10 添加,本步**不要**引用它,否则编译不过):
```go
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 行):
```go
dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, r.phashBits, timing)
```
改为:
```go
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**
```bash
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**
```bash
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 行)中的:
```go
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**
```bash
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` 删除:
```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**
```bash
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:删去重专用测试与诊断命令**
```bash
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:全仓扫描残留**
运行:
```bash
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**
```bash
git add -A
git commit -m "test: 清理去重相关测试与诊断命令,更新 README"
```
**阶段一完成。此时系统行为变化:前台不再自动折叠任何重复图,全部平铺,靠人工清理。这是预期结果。**
---
## 阶段二:SigLIP 集成
### 任务 8:新增 VisionConfig
**文件:**
- 修改:`internal/config/config.go`
- 修改:`configs/config.yml`
- [ ] **步骤 1:加配置结构体**
在 `config.go` 的 `S4Config` 定义之后追加:
```go
// 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"\`` 之后追加:
```go
// Vision 视觉服务(FashionSigLIP)配置,用于「疑似同款」提示。
Vision VisionConfig `yaml:"vision"`
```
- [ ] **步骤 3:加默认值归一**
在该文件的 `normalize`(或同名默认值处理函数)中追加:
```go
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` 追加:
```yaml
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**
```bash
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`:
```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`:
```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**
```bash
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`:
```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`:
```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` 结构体中新增字段:
```go
vision *vision.Client // 视觉服务客户端(可为 nil:未启用时跳过 embed)
embedRep repository.EmbeddingRepository // 向量写入与检索(可为 nil:跳过)
```
2. 在 `NewIngestService` 签名末尾追加两个参数 `vis *vision.Client, embedRep repository.EmbeddingRepository`,并赋值到字段上。两个参数都允许传 nil。
3. 在 `downloadResult` 结构体中新增:
```go
vec []float32 // FashionSigLIP 向量;nil 表示未产出(服务不可用/失败)
embed time.Duration // 本张向量编码耗时
```
4. 在每张图的抓取闭包中(原 phash 计算位置),替换为:
```go
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)
}
}
```
5. 在 `fetchTiming` 结构体中新增 `embed time.Duration`,并在并发段累加 `timing.embed += r.embed`(与既有 `timing.phash += ...` 同一位置),`formatTiming` 按任务 3 的新串输出。
6. 在串行段组装落库时,收集有向量的图片,落库成功后调用:
```go
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 行)追加两个参数:
```go
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**
```bash
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`:
```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`:
```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` 的接口与实现中追加:
```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,取回向量:
```go
ids := make([]uint32, 0, len(imgs))
for _, im := range imgs {
ids = append(ids, im.ID)
}
vecByImage, err := h.similar.ListVectors(ctx, kind, ids)
```
2. 查提示并按图片 id 装配进 cells:
```go
hints, _ := h.similar.ForArticle(ctx, kind, vecByImage)
```
在每个 cell 的 `gin.H{...}` 里追加:
```go
"similarHints": hints[m.ID],
```
3. `SimilarService` 上增加薄封装 `ListVectors`,内部转调 `embedRep.ListEmbeddings`,embedRep 为 nil 时返回 nil。
- [ ] **步骤 7:模板与前端展示**
在 `partials.html` 的图集 cell 渲染处,在原「重复」徽章的位置改为渲染同款提示:
```html
{{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**
```bash
git add internal/service internal/repository internal/handler cmd/server
git commit -m "feat(backstage): 图集页展示「疑似同款」提示"
```
---
## 任务 12:端到端验证
**文件:** 无代码改动,仅验证。
- [ ] **步骤 1:本地起视觉服务(GPU)**
```powershell
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:验证向量入库**
```sql
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 维向量
- [ ] 视觉服务不可用时入库不受影响,日志有明确告警
- [ ] 后台图集页展示「疑似同款」角标,**不隐藏任何图片**
- [ ] 前台不再有任何自动折叠