Files
backend_v2/docs/superpowers/specs/2026-09-21-duplicate-review-design.md
toom1996 d908036621 update
2026-09-22 11:12:23 +08:00

277 lines
14 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.

# 重复检测与审核展示设计(含街拍实体键改为按文章)
- 日期: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`)。避免标题被改后出现陈旧副本。
> **落点已变更(2026-09-21)**:`AutoMigrate` / `EnsureDedupSchema` 已移除,结构改由 `cmd/dbtool` 的 dump 维护。
> 本节的表与索引已落在 `db/migrations/2026-09-21-02-duplicate-review.sql`(已应用)。
> 今后加表 / 加索引一律走 `db/migrations/` 的一次性脚本 → 对开发库执行 → `dbtool dump -clean` 重新导出。
### 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 新增(模型)
db/migrations/2026-09-21-02-duplicate-review.sql 建表与索引(已应用)
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` 过滤**,不做主动清理;若日后数据量增大再考虑级联清理。