# 下线 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 维向量 - [ ] 视觉服务不可用时入库不受影响,日志有明确告警 - [ ] 后台图集页展示「疑似同款」角标,**不隐藏任何图片** - [ ] 前台不再有任何自动折叠