Files
backend_v2/docs/superpowers/specs/2026-09-20-ingest-idempotency-design.md
toom1996 d908036621 update
2026-09-22 11:12:23 +08:00

293 lines
17 KiB
Markdown
Raw Permalink 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-20
- 状态:设计已确认,待编写实现计划
- 范围:
1. 爬虫入库管线(`ingest`)的**来源级幂等**(第 3.1–3.5 节)
2. 街拍实体键 `(city, year)` → `(city, year, month)`(第 3.6 节)
---
## 1. 背景
### 1.1 现状:链路上现有四道去重,但没有一道能挡「同一篇文章重爬」
| 层 | 现有机制 | 能挡住 | 挡不住 |
| --- | --- | --- | --- |
| 传输 | `ingest_nonces` + HMAC 时间窗 | 同一请求被重放 | **爬虫重跑**(每次生成新 nonce) |
| 内容 | `downloadAndUpload` 的 sha1 内容寻址 key | 重复图产生孤儿文件 | 重复草稿行 |
| 实体 | `RunwayIDByEntity` / `StreetSnapIDByEntity` | — | 见 1.2 |
| 晋升 | `SaveRunwayFromDraft` / `SaveStreetSnapFromDraft` 聚合 approved 兄弟草稿图 | 重复正式行 | 只聚合 approved,pending 重复照留 |
### 1.2 「只判正式表」是刻意设计,不是缺陷
`processRunway` / `processStreet` 中的实体键去重**只查正式表、不查 pending 草稿**,注释写明了原因:
> 否则多来源(Vogue + theImpression)爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
因此**不能**简单地把实体去重扩展到草稿表——那会连带干掉多来源聚合。
### 1.3 根因:设计里没有「来源文章」这个维度
来源级幂等键历史上存在过,且爬虫侧还有 `ExistsSourceURLs` 预检,但已被移除。爬虫代码中留有原文:
```go
// 历史上这里会调用后端 ExistsSourceURLs 预检、在抓取前跳过已爬图集以省流量;
// 现 source_url 已从 ingestion 管线移除,后端按「实体键(品牌+季节码+系列)」去重,
```
当前 `dto.RunwayIngest` 不含任何来源字段,后端无从判断「这篇是否已爬过」。
### 1.4 实测印证(来源维度缺失)
同一篇 theimpression 哥本哈根街拍,间隔 5 分钟上报两次(job 96 / job 98),产生 **2 条 pending 草稿**。
### 1.5 第二个独立缺陷:街拍 `year=0` 是一个「吸附桶」
街拍实体键为 `(city, year)`,而 theImpression 的 `parseYear` 取不到年份时**明确返回 0**:
```go
// 提取不到时返回 0(未知年份),不再回退到「当前年份」——那会把旧街拍伪造成今年。
```
而 theImpression 标题实测**基本不含年份**(实测日志:`[Warning] 标题未含年份,按未知(0)处理: The Best Street Style From Copenhagen Fashion Week`)。
后果链:
1. 首次抓取产生 `(Copenhagen, 0)` 草稿。
2. 该草稿被审核通过、晋升到 `street_snaps` 后,实体键 `(Copenhagen, 0)` **永久占位**。
3. 此后**任何标题不含年份的 Copenhagen 街拍都会被判重、静默 `MarkDone` 丢弃**,不是延迟而是永久进不来。
4. 推广:**每个城市永远只能存在一张街拍专辑**。
此外,即使修掉 `year=0`,`(city, year)` 仍然无法区分同城一年内的两季时装周(如 2 月 FW / 8 月 SS)——因为命中实体键的行为是**跳过**而非**合并**,第二季会被整批丢弃。
---
## 2. 决策记录
| 决策点 | 结论 | 理由 |
| --- | --- | --- |
| 重爬语义 | **纯跳过**:命中即整个任务 `MarkDone`,不下载、不写草稿 | 最省流量;有人工审核兜底,需补齐时后台手动重试 |
| 来源幂等键 | `(source, source_url)` | 来源站 + 文章详情页地址 |
| 「已存在」边界 | **任何状态都算**(含 `rejected`、含软删) | 用户明确:**爬过的文章不会更新**,故「爬过」即终态 |
| 检查位置 | **方案 1:worker 处理前查重** | 与现有「去重 → 命中即 MarkDone」模式同构;`202` 接口保持只做 `Enqueue` |
| 来源索引形态 | 部分唯一索引,`WHERE source_url <> ''` | 存量行 `source_url` 为空串,全量唯一索引会因 `('','')` 冲突导致**建索引失败 → 启动崩溃** |
| 街拍 `year` 来源 | **抓取时间**(不再回退 0) | 标题实测不含年份;0 会形成「每城永远一个专辑」的吸附桶(见 1.5) |
| 街拍 `month` | **进实体键** → `(city, year, month)` | `(city, year)` 仍会撞同城两季时装周;月粒度可避免「整季被丢弃」 |
| street 侧 `parseYear` | **删除**(失去调用方) | `year` 与 `month` 必须同源才自洽;「年份取标题、月份取抓取」会产出 `(Copenhagen, 2024, 9)` 这类无意义键 |
| URL 解析年份优先 | **不做** | URL 中虽含季节年份(如 `...-spring-2027/`),但形状依赖强、收益仅为精度提升,按 YAGNI 排除 |
| 历史测试数据 | **清理** | 用户确认:数据量小、全部为测试数据 |
### 2.1 已评估并否决的方案
- **方案 2(入队时查重)**:在 `Submit()` 中先查草稿表,命中即返回「已跳过」。好处是省队列槽位,但把 DB 查询塞进刻意做薄的 ingest 接口,且**引入 TOCTOU**——并发提交时双方都查不到、都入队,最终仍须靠唯一索引兜底。多一层代码,未换取正确性。
- **方案 3(只靠唯一索引)**:不做前置查询,靠 `INSERT` 冲突兜底。代价是**每次重爬都要把全部图片下载并上传一遍**才发现冲突,恰好浪费最想省下的那部分时间,直接违背目标。
- **爬虫侧预检(恢复 `ExistsSourceURLs`)**:可连文章页都不抓,但抓一个 HTML 页的成本与后台下载数十张图不在一个量级,收益太小且引入额外跨服务往返,按 YAGNI 排除。
- **街拍月份只做展示字段、不进键**:可减少专辑碎片化,但同一城市同一年的第二季时装周会被整批丢弃(见 1.5 第 4 点)。用户明确选择进键。
- **爬虫解析 URL 中的季节年份**:精度更高(`spring-2027` → 2027),但强依赖 URL 形状,按 YAGNI 排除。
---
## 3. 详细设计
### 3.1 契约:爬虫 → 后端
`internal/dto/ingest.go` 的 `RunwayIngest` 与 spider 侧 `spider/internal/ingest/payload.go` 的 `RunwayIngest` **两侧同时**新增三个字段,JSON 名必须逐字一致:
| 字段 | JSON 名 | 类型 | 含义 | 取值 |
| --- | --- | --- | --- | --- |
| `Source` | `source` | string | 来源站标识 | `vogue` / `theimpression` |
| `SourceURL` | `source_url` | string | 文章详情页地址 | 该篇 URL |
| `Month` | `month` | uint8 | 抓取月份(street 用) | 1–12;runway 不填 = 0 |
填充点:
- `spider/internal/spider/vogue.go`:`Source: "vogue"`,`SourceURL: requestURL`(即现有 `sourceURL(info)` 的返回值);`Month` 不填
- `spider/internal/spider/theimpression.go`:`Source: "theimpression"`,`SourceURL: tk.URL`(即现有 `streetTask.URL`);`Year` / `Month` 均取抓取时刻的 `time.Now()`
**向后兼容**:三个字段均可空。老爬虫不上送时 `source_url == ""`,来源去重自动跳过,行为与现状完全一致。
### 3.2 Schema
**(a)来源幂等:两张草稿主表各新增两列**
**使用 `not null default ''` 而非可空**——`NULL` 在唯一索引中不参与比较,可空会导致「同键可重复插入」,使索引形同虚设。
```sql
source varchar(32) not null default ''
source_url varchar(512) not null default ''
```
对应模型:`internal/model/runway_draft.go` 的 `BrandRunwayDraft`、`internal/model/street_snap_draft.go` 的 `StreetSnapDraft`。**模型上不加 `uniqueIndex` tag**(GORM tag 无法表达部分索引条件)。
> **落点已变更(2026-09-21)**:服务启动时的自动迁移(`AutoMigrate` / `EnsureDedupSchema`)已移除,
> 全库结构改由 `cmd/dbtool` 的 dump 维护。加列 / 加索引现在走 `db/migrations/` 下的一次性脚本:
> 对开发库执行 → 再 `dbtool dump -clean` 重新导出。本规格的列与索引已落在
> `db/migrations/2026-09-21-03-ingest-source-idempotency.sql`(已应用)。
索引定义(部分唯一索引——全量唯一索引会因存量大量 `('','')` 冲突而建索引失败):
```sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_draft_source
ON brand_runway_drafts (source, source_url) WHERE source_url <> '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_draft_source
ON street_snap_drafts (source, source_url) WHERE source_url <> '';
```
两点取舍:
- **必须是部分索引**(`WHERE source_url <> ''`)。存量行 `source_url` 全为空串,全量唯一索引会因大量 `('','')` 冲突而建索引失败,进而导致服务**启动崩溃**。该条件同时让老数据豁免。
- **不加 `is_deleted` 条件**。按决策「爬过即终态」,若把软删排除在外,则删除草稿即变相绕过幂等。
**(b)街拍月份:两张街拍表各新增一列**
```sql
month smallint not null default 0
```
对应模型:`model.StreetSnapDraft`、`model.StreetSnap`。由 `AutoMigrate` 托管,**不建索引**——现有 `(city, year)` 本就无索引,街拍表体量很小。
### 3.3 判定逻辑:worker 处理前查重
新增仓储方法(接口仍定义在消费方 `IngestRepository`,与项目现有做法一致):
```go
// SourceDraftExists 按来源键 (source, source_url) 判断该文章是否已入库过。
// sourceURL 为空时直接返回 false(老爬虫兼容)。不限 status、不限 is_deleted,
// 与 uq_*_draft_source 部分唯一索引口径严格一致(决策:爬过即终态)。
SourceDraftExists(ctx context.Context, kind, source, sourceURL string) (bool, error)
```
实现要点:
- `sourceURL == ""` → 立即返回 `false`(老爬虫兼容路径)。
- `kind` 非 `runway` / `street` → 返回 `false`(防御性;`process()` 实际只会传入这两者)。
- 否则按 `kind` 选择草稿表,执行 `WHERE source = ? AND source_url = ? LIMIT 1` 的存在性查询。
- `source` 为空但 `source_url` 非空时,仍按 `('', <source_url>)` 参与去重,不做特殊处理。
实际接入的两个爬虫都会同时上送两字段,此规则仅为消除歧义。
插入点在 `internal/service/ingest_service.go` 的 `process()` 中:解析 payload、归一化 `p.Kind` **之后**,`switch p.Kind` 分派 **之前**。此处是单一插入点,同时覆盖 runway / street 两条管线,且早于品牌校验与实体键去重。
命中后:按现有日志风格记录 + `MarkDone` + `return`,格式与现有实体去重日志对齐:
```
[ingest] job=%d %s 来源去重命中(source=%s url=%s),跳过 耗时=%v
```
### 3.4 写入草稿时带上来源
`processRunway` / `processStreet` 构造 draft 时填充 `Source` / `SourceURL`。这是唯一索引真正生效的落点:多实例并发时两个任务的前置查询都查不到,第二个 `INSERT` 将撞上唯一索引。
### 3.5 错误处理:唯一冲突必须当「跳过」而非「失败」
`CreateRunwayDraft` / `CreateStreetSnapDraft` 返回唯一冲突时,**绝不能走 `failOrRetry`**——否则会按指数退避白重试 3 次,最终在后台堆出一批假故障任务。
正确做法:识别冲突(复用 `internal/repository/ingest_repository.go` 中现成的 `isDuplicateKey(err)`)→ 记录日志 → `MarkDone`,与 3.3 的前置查重共用同一条出口语义。
**冲突时无需回滚已上传的图**:两张草稿的图片 URL 完全相同,sha1 内容寻址会推出同一个对象 key,属覆盖写,不产生孤儿文件。此点须在代码注释中写明,避免后来人误加 `cleanupUploads`。
### 3.6 ~~街拍实体键引入月份~~(已作废)
> **本节已作废(2026-09-21)**,由 `2026-09-21-duplicate-review-design.md` §3.7 取代。
>
> 原设计把街拍实体键改为 `(city, year, month)`(同城同月自动收敛成一张专辑)。用户随后取消了「按月区分」与「合并成一张专辑」两项需求,改为**一篇文章一张专辑**,实体键变为 `(source, source_url)`。
>
> 影响:
>
> - 本节描述的全部改动**不再执行**(原实现计划的任务 6 已删除)。
> - 但 §3.1(契约新增 `source` / `source_url`)**仍然有效、且更关键**——这两个字段现在直接充当街拍实体键。
> - `Month` 字段不再需要(其唯一用途是月度分组):`month` 列、spider 的 `Month` 填充、`streetDraftEditable` 放行 `month`、列表副标题加月份等一并取消。
> - 街拍 `street_snaps` 正式表需新增 `source` / `source_url` 两列,见新规格 §3.7。
### 3.7 明确不动的部分
- **runway 侧**实体键去重 `RunwayIDByEntity` —— 保留原样(runway 有 `season_code`,不存在街拍这种月份问题)。它与来源去重**互补而非替代**。
- 晋升聚合的通用机制(sibling union)—— 除 3.6 第 4 处的 month 条件外不变。
- `Submit` / `Enqueue` —— 方案 1 刻意不碰,ingest 接口继续只做「入队 + 立即 202」。
- 传输层 nonce 防重放 —— 不变。
---
## 4. 影响面
```
internal/dto/ingest.go 新增 Source / SourceURL / Month 三字段
internal/model/runway_draft.go BrandRunwayDraft 新增 source / source_url
internal/model/street_snap_draft.go StreetSnapDraft 新增 source / source_url / month
internal/model/street_snap.go StreetSnap 新增 month
internal/repository/ingest_repository.go 新增 SourceDraftExists;StreetSnapIDByEntity 加 month
internal/repository/review_repository.go SaveStreetSnapFromDraft / streetApprovedSiblingImages 加 month
internal/repository/review_repository.go streetDraftEditable 放行 month
internal/service/ingest_service.go process() 来源去重;两处填来源;processStreet 填 month
internal/service/review_service.go street 模块 Fields 加 month;卡片副标题加月份
internal/database/postgres.go EnsureDedupSchema 加两个部分唯一索引
../spider/internal/ingest/payload.go 新增三字段
../spider/internal/spider/vogue.go 填充 source / source_url
../spider/internal/spider/theimpression.go 填充 source / source_url / year / month;删除 parseYear
```
---
## 5. 测试
### 5.1 单元测试(不依赖数据库)
- `source_url == ""` 时不执行来源去重(老爬虫兼容路径)。
- 命中已存在时:任务被 `MarkDone`,且**上传器一次都未被调用**(用计数 spy 上传器断言,构造方式参照现有 `TestFetchImagesCleansUpOnFailure`)。
- 唯一冲突路径:`CreateXxxDraft` 返回重复键错误时任务 `MarkDone`,且不触发重试(`attempts` 不变)。
- `processStreet` 写入草稿时 `month` 被正确透传。
### 5.2 集成测试(需 PostgreSQL)
- 并发两次插入同一 `(source, source_url)` 只保留一行。
- 存量空串行不影响索引创建,也不与他行冲突(验证部分索引生效)。
- 实体键含月份:`(city, year, month)` 不同月份不互相判重。
项目内已有 `dedup_integration_test.go` 可作参照。
---
## 6. 存量数据清理
### 6.1 盘点(2026-09-20 实测)
| 表 | 行数 | 内容 |
| --- | --- | --- |
| `street_snap_drafts` | 3 | id 1/2/3 ← job 95/96/98,均 `Copenhagen / year=0`、同标题、各 59 图、pending |
| `street_snap_draft_images` | 177 | |
| `street_snaps` / `street_snap_images` | 0 | 街拍从未晋升 |
| `brand_runway_drafts` | 5 | id 88–92,全 approved,brand_id=7 |
| `brand_runways` | 5 | 与上表一一对应 |
| `brand_runway_draft_images` / `brand_runway_images` | 556 / 672 | |
| `ingest_jobs` | 96 | 队列历史 |
### 6.2 结论
用户确认全部为测试数据,**执行清理**。
清理方式:
1. 删除上表全部业务行(草稿主表 + 明细表 + 正式表 + 图片表)。按外键顺序或先删明细。
2. 清理后,原被引用的 S4 对象成为孤儿。复用既有机制收尾:由 `media_cleanup` 任务按引用计数判定,归零者才真删(`purgeOrphanImages`),不手写批量删除。
3. `ingest_jobs` 一并清空,取得干净的监控起点。
### 6.3 与 3.2 部分索引的相互作用
清理后存量行归零,`source_url` 全空串的情形不再存在——但**部分索引 `WHERE source_url <> ''` 仍然必须保留**。理由:它是「老爬虫不上送 `source_url`」这一兼容路径的正确性保证,而非仅为绕过历史数据;若将来再出现空串行,全量唯一索引会再次导致建索引失败。此点已在 3.2 说明,清理不改变该结论。
---
## 7. 待确认 / 开放问题
- 是否需要在后台任务列表、审核列表中展示 `source`(便于人工判断来源)?当前设计**未包含** UI 改动,按 YAGNI 暂缓。
## 8. 回滚
设计对既有行为基本是纯增量的,回滚成本低:
- 删掉两个部分唯一索引(`DROP INDEX IF EXISTS uq_br_draft_source` / `uq_ss_draft_source`)即恢复「无来源去重」的旧行为;相关列可保留不动(空闲列无害)。
- 街拍月份回滚需同时回退 3.6 表中 4 处落点(任一处漏改都会造成晋升/去重口径不一致),因此**该部分不建议单独部分回滚**,应整体回退到改动前版本。
- 爬虫侧若先于后端回滚,多送的字段会被后端 JSON 反序列化静默忽略,不会报错。
- 后端若先于爬虫回滚/部署,老爬虫不送 `source_url`,走兼容路径,同样不影响。
- 两侧因此**不存在必须同时发布的顺序约束**。