# 下线 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}}重复
```
以及与之配套的折叠/隐藏 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}}
疑似同款 {{printf "%.2f" .Similar}}
{{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 维向量
- [ ] 视觉服务不可用时入库不受影响,日志有明确告警
- [ ] 后台图集页展示「疑似同款」角标,**不隐藏任何图片**
- [ ] 前台不再有任何自动折叠