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

14 KiB
Raw Permalink Blame History

重复检测与审核展示设计(含街拍实体键改为按文章)

  • 日期:2026-09-21
  • 状态:设计已确认,待用户审阅规格
  • 范围:
    1. 「重复图对」表 image_duplicates + 审核页展示(列表给数量、详情给标记)
    2. 街拍实体键由 (city, year) 改为 (source, source_url)——一篇文章一张专辑
  • 不含:街拍主副图(已确认设计与交互,另立规格)

1. 背景

1.1 现有的 is_duplicate / dup_of 为什么不能直接用

两者在入库时写入,但有两个硬缺陷:

缺陷一:dup_of 是「不带表名的裸行 id」,无法反查归属。

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

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 增加一个字段:

// 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。同时补一个与草稿表同形的部分唯一索引,从数据库层面保证同一篇文章不会产生两张正式专辑:

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 过滤,不做主动清理;若日后数据量增大再考虑级联清理。