# 重复检测与审核展示设计(含街拍实体键改为按文章) - 日期:2026-09-21 - 状态:设计已确认,待用户审阅规格 - 范围: 1. 「重复图对」表 `image_duplicates` + 审核页展示(列表给数量、详情给标记) 2. 街拍实体键由 `(city, year)` 改为 `(source, source_url)`——一篇文章一张专辑 - **不含**:街拍主副图(已确认设计与交互,另立规格) --- ## 1. 背景 ### 1.1 现有的 `is_duplicate` / `dup_of` 为什么不能直接用 两者在入库时写入,但有两个硬缺陷: **缺陷一:`dup_of` 是「不带表名的裸行 id」,无法反查归属。** ```go DupOf: strconv.FormatUint(uint64(dupID), 10) ``` `FindNearDuplicateImage` 按「草稿表 → 正式表」顺序遍历,**命中哪张表就返回哪张表的 id**。拿到 `dup_of=264`,无法判断它是 `street_snap_draft_images` 的 264 还是 `street_snap_images` 的 264,更推不出它属于哪篇草稿。 **缺陷二:篇内重复完全检不到(结构性缺陷)。** 草稿图片是**全部下载完之后一次性批量插入**的(`CreateStreetSnapDraftImages` 在 `fetchImages` 返回后才调用),而 `dedupImage` 是每张图下载后立刻查库——**那一刻本篇的行还不存在**,所以同一篇文章内部的近似重复根本没有比较对象。 **实测证据**(当前 5 篇街拍,1052 张图): | 指标 | 值 | | --- | --- | | `is_duplicate = 1` 的行数 | **3** | | 实际 hamming ≤ 10 的对数 | **10**(其中**篇内 6 对,一条都没标**) | 篇内漏标的典型:行 id `1150`/`1151`(同一篇内前后相邻的两张,连拍),hamming = 5——这类最该被剔除的,入库标记完全看不见。 ### 1.2 阈值实测(`phash.DefaultThreshold = 10` 明显过松) 对 1052 张图做两两汉明距离统计(`L2² == 汉明距离`): | 阈值 | 命中对数 | 效果 | | --- | --- | --- | | ≤4 | **0** | — | | ≤5 | 1 | 唯一的真候选(行 1150/1151,篇内相邻) | | ≤8 | 1 | 与 ≤5 相同 | | ≤9 | 4 | 开始混入噪声 | | **≤10(现值)** | **10** | 混入 9 对噪声 | 距离分布(低尾): ``` hamming | pairs 5 | 1 9 | 3 10 | 6 11 | 7 12 | 13 13 | 40 14 | 85 ... 平均 31.45 ≈ 32(64 位随机期望) ``` **关键判定**:9、10、11 三档的计数(3、6、7)平滑接进 12、13、40、85……**没有分离的簇**,说明 9~11 就是噪声分布的肩部。用户实际查看后确认:hamming = 10 的跨篇对(行 264 ↔ 1021)**目视完全不同**,是假阳性。 **根因**:dHash 只有 9×8 采样格、64 位,而这批图全是「竖构图的街头全身人像」,整体构图高度雷同,描述子被构图主导、内容差异被压缩。(描述子升级为更大网格 dHash / pHash-DCT 的方案已评估,另立规格处理。) --- ## 2. 决策记录 | 决策点 | 结论 | 理由 | | --- | --- | --- | | 重复检测机制 | **读时预计算并落库到 `image_duplicates` 表**,不依赖 `is_duplicate`/`dup_of` | 现有字段无法反查归属且漏篇内重复(见 1.1) | | 计算时机 | **草稿图片写完之后的 worker 里** | 入库每张图时本篇行还不存在,篇内比不到(见 1.1 缺陷二) | | 计算算法 | 对新草稿每张图,用现成的 `_phash_hnsw` 索引做 LATERAL 近邻查询 | 复杂度 O(新增图数 × log N),优于全表 O(n²) 自连接 | | 比对范围 | **同 kind 的「草稿表 + 正式表」都比** | 用户选定:要能知道「这张图已经上线过了」 | | 晋升镜像 | **跳过 `image` 相同的对** | 草稿晋升后同一张图在草稿表与正式表各存一行、phash 相同(hamming 0),那是镜像不是重复 | | 阈值 | **4** | 用户指定。写入时应用(见 3.3) | | 是否存汉明距离 | **不存** | 用户指定。代价:改阈值需重跑配对计算——但**只读库里的 phash,不需重新下图**,比换描述子便宜一个量级 | | 列表页展示 | **不展示**(用户取消了「列表显示重复数量」这一需求) | 用户指定 | | 详情页展示 | 给重复图**打标记** + 显示对手属于哪篇文章 | 用户指定 | | 街拍实体键 | **`(source, source_url)`**——一篇文章一张专辑 | 用户指定;月/年不再参与区分 | | 合并成一张专辑 | **取消** | 用户取消 | | `month` 进实体键 | **取消** | 用户取消按月区分 | --- ## 3. 详细设计 ### 3.1 新表 `image_duplicates` ```sql CREATE TABLE IF NOT EXISTS image_duplicates ( id BIGSERIAL PRIMARY KEY, kind varchar(16) NOT NULL, -- runway | street -- 本方(「这张图」) image_id integer NOT NULL, -- 图片行 id(在 *_draft_images 或 *_images 中,由 owner_kind 决定) owner_kind varchar(8) NOT NULL, -- draft | official owner_id integer NOT NULL, -- 草稿 id 或正式表主键(brand_runways.id / street_snaps.id) -- 对手(「和它重复的那张」) peer_image_id integer NOT NULL, peer_owner_kind varchar(8) NOT NULL, peer_owner_id integer NOT NULL, created_at integer NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX IF NOT EXISTS uq_image_dups_pair ON image_duplicates (kind, owner_kind, image_id, peer_owner_kind, peer_image_id); CREATE INDEX IF NOT EXISTS idx_image_dups_owner ON image_duplicates (kind, owner_kind, owner_id); ``` 唯一索引保证重复计算幂等;辅助索引服务读侧(按草稿批量取数)。 **不冗余存对手的标题**:读时按 `peer_owner_kind` join 对应表取(草稿 → `*_drafts.title_en` / `.title`;正式 → `brand_runways.title_en` / `street_snaps.title`)。避免标题被改后出现陈旧副本。 建表放在 `internal/database/postgres.go` 的 `EnsureDedupSchema`(该函数本就是「`AutoMigrate` 之外的索引与表补充」的既定位置,且全部幂等)。 ### 3.2 写入时机与算法 **时机**:`processRunway` / `processStreet` 内,`Create*DraftImages` **成功之后**。失败仅记录日志、**不影响任务状态**(重复信息是审核辅助,不该让入库任务失败);漏算可由回填命令补齐(3.6)。 **算法**:对本草稿的每一张图,用 `_phash_hnsw` 索引(`vector_l2_ops`)在对手池中取近邻。对手池 = 同 kind 的「草稿表 UNION 正式表」。 ``` L2 阈值 = sqrt(4) = 2.0 -- 与 phash 口径一致:phash 为 {0,1}^64,L2² == 汉明距离 跳过条件:对手行的 image 与本方相同(晋升镜像) ``` 结果以 `INSERT ... ON CONFLICT (kind, owner_kind, image_id, peer_owner_kind, peer_image_id) DO NOTHING` 入库——表内不存汉明距离,故命中已存在的对时**无需更新**,DO NOTHING 即幂等。 ### 3.3 阈值 `4`,作为**配置项**(不入库汉明距离,故阈值在写入时生效): ``` dup.hamming_threshold: 4 ``` **改阈值的代价**:需重跑配对计算。因为只读库里的 `phash`、不需要重新下载图片,这是一条纯数据库批处理(3.6 的回填命令可直接复用)。 ### 3.4 读侧:审核列表 —— **不做** 用户已取消「审核列表显示重复数量」这一需求:**列表页不加任何重复相关列**,`DraftCard` 也不增加字段。 因此 `image_duplicates` 的读侧只有**一处**消费方——审核详情页(见 3.5)。列表页保持原样。 > 说明:曾实现过一版读时自连接 + 列表列的过渡方案,因该需求取消而**整体移除**。详情页标记改由本规格的 `image_duplicates` 表实现。 ### 3.5 读侧:审核详情 `DraftImageRef` 增加一个字段: ```go // DupPeers 与该图重复的对手;为空表示不重复。 // 对手可能多于一个(同一张图在多篇文章里都出现过),故用切片而非 bool。 type DupPeer struct { OwnerKind string // draft | official Title string // 对手所属文章 / 专辑标题 } type DraftImageRef struct { // ...既有字段... DupPeers []DupPeer } ``` 「是否重复」直接由 `len(DupPeers) > 0` 判定,**不单设 bool**(避免两者不一致)。 详情模板对有对手的图加角标(复用现有 `.badge` 视觉语言,新增 `.badge.dup`),角标含对手文章标题;对手多于一个时全部列出。 对手文章标题的取法:按 `peer_owner_kind` 分别 join,读时组装。 ### 3.6 存量回填 现有需回填规模: | 表 | 总行 | 存活行 | | --- | --- | --- | | `brand_runway_images` | 672 | 556 | | `brand_runway_draft_images` | 556 | 556 | | `street_snap_images` | 0 | 0 | | `street_snap_draft_images` | 1052 | 1052 | 去重后 **1608 张不同图**(走秀 556 张在两表重复出现)。 提供一个**可中断、可重入**的回填命令:按 kind 与 owner 分批处理,重复执行不产生重复行(靠唯一索引),支持从任意位置续跑。 ### 3.7 街拍实体键改为按文章 街拍实体键由 `(city, year)` 改为 **`(source, source_url)`**,即一篇文章一张专辑。落点与 `2026-09-20-ingest-idempotency-design.md` §3.6 所列的 4 处一致,但键的构成改变: | # | 落点 | 改动 | | --- | --- | --- | | 1 | `ingest_repository.StreetSnapIDByEntity` | 改为按 `(source, source_url)` 查正式表 | | 2 | `ingest_service.processStreet` | 调用点同步 | | 3 | `review_repository.SaveStreetSnapFromDraft` | 正式行查找改为按 `(source, source_url)` | | 4 | `review_repository.streetApprovedSiblingImages` | **对街拍变为空操作**(per-article 后不存在「兄弟草稿」) | ⚠️ `street_snaps` 正式表需新增 `source` / `source_url` 两列(与草稿表同构),否则晋升时无法按该键 upsert。同时补一个与草稿表同形的**部分唯一索引**,从数据库层面保证同一篇文章不会产生两张正式专辑: ```sql CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_source ON street_snaps (source, source_url) WHERE source_url <> ''; ``` 必须是部分索引,理由与 `2026-09-20-ingest-idempotency-design.md` §3.2 相同:存量行 `source_url` 为空串,全量唯一索引会因大量 `('','')` 冲突导致**建索引失败 → 启动崩溃**。 **关于 `month` 与 `year`**(用户只说「不按月区分」,未明确字段去留,此处为裁决): - **不做 `month`**:它此前的唯一用途是月度分组,该需求已取消(YAGNI)。 - **保留「year 取抓取年份」**(spider 侧 2 行改动):`year=0` 是错误数据,且公开 API 支持按 year 过滤,`year=0` 的条目无法被正常筛选。注意实体键改为按文章后,`year=0` 已**不再造成静默丢数据**(原 `(city, 0)` 吸附桶问题随键变更消失)。 ### 3.8 明确不动的部分 - `is_duplicate` / `dup_of` 两列**保留写入但不读**(纯历史留痕)。不删列,避免破坏性迁移。 - 入库时的 `dedupImage` 逻辑不变。 - runway 的 `(brand_id, season_code, collection_type)` 实体键与 sibling union **不变**(多来源聚合对走秀仍有意)。 --- ## 4. 影响面 ``` internal/model/image_duplicate.go 新增(模型) internal/database/postgres.go EnsureDedupSchema 建表与索引 internal/repository/dup_repository.go 新增:写入 + 读取 + 回填 internal/repository/ingest_repository.go StreetSnapIDByEntity 改键 internal/repository/review_repository.go SaveStreetSnapFromDraft / streetApprovedSiblingImages 改键 internal/service/ingest_service.go 草稿写入后触发配对计算;processStreet 调用点 internal/service/review_service.go DraftImageRef 加重复对手字段(仅详情用) internal/handler/backstage_handler.go 详情页加重复标记 internal/model/street_snap.go +source / +source_url cmd/dupbackfill(新) 存量回填命令 configs/config.yml +dup.hamming_threshold ../spider/internal/spider/theimpression.go year 改取抓取年份 ``` --- ## 5. 测试 ### 5.1 单元测试(不依赖数据库) - 配对写入会**跳过 `image` 相同的对**(晋升镜像)。 - 详情渲染:重复图被标记,并显示对手文章标题;无对手的图**不显示标记**。 ### 5.2 集成测试(需 PostgreSQL) - 唯一索引生效:同一对重复写入只留一行。 - 阈值语义:hamming 恰为 4 的对入表、为 5 的不入表。 - 回填命令可重入:连跑两次行数不变。 - 街拍实体键:不同 `source_url` 的两篇文章晋升后是**两张**正式专辑;相同 `source_url` 是**一张**。 --- ## 6. 对既有规格与计划的影响 | 对象 | 影响 | | --- | --- | | `2026-09-20-ingest-idempotency-design.md` §3.6 | **作废**(月/年进键的部分)。§3.1–3.5(来源幂等)**仍然有效**,且其 `source` / `source_url` 字段正好成为街拍新实体键 | | `2026-09-20-ingest-idempotency.md`(实现计划)**任务 6** | **删除**(实体键改动移入本规格) | | 同上,任务 5(spider 填 source/source_url) | **保留**,但 `Month` 字段的填充与 `parseYear` 删除需按本规格 §3.7 调整 | --- ## 7. 待确认 / 开放问题 1. **阈值 4 会让当前数据命中 0 对**,审核页该列为空。用户已指定 4;因它是配置项,改成 5 只改一行配置 + 重跑回填。 2. 街拍主副图(含公开 API 分组化)**另立规格**,不在本文档范围。 3. `image_duplicates` 的生命周期:草稿被软删 / 单图被审核删除后,表中会残留指向失效行 id 的记录。当前设计**读时靠 join `is_deleted = 0` 过滤**,不做主动清理;若日后数据量增大再考虑级联清理。