update backend
This commit is contained in:
1210
docs/superpowers/plans/2026-09-20-ingest-idempotency.md
Normal file
1210
docs/superpowers/plans/2026-09-20-ingest-idempotency.md
Normal file
File diff suppressed because it is too large
Load Diff
297
docs/superpowers/specs/2026-09-20-ingest-idempotency-design.md
Normal file
297
docs/superpowers/specs/2026-09-20-ingest-idempotency-design.md
Normal file
@ -0,0 +1,297 @@
|
||||
# 入库幂等设计(来源级去重 + 街拍实体键引入月份)
|
||||
|
||||
- 日期: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`。列由 `AutoMigrate` 托管,**模型上不加 `uniqueIndex` tag**(GORM tag 无法表达部分索引条件)。
|
||||
|
||||
索引写在 `internal/database/postgres.go` 的 `EnsureDedupSchema` 中——该函数本就是「`AutoMigrate` 之外的索引补充」的既定位置,且全部 `IF NOT EXISTS` 幂等:
|
||||
|
||||
```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 街拍实体键引入月份
|
||||
|
||||
街拍实体键由 `(city, year)` 改为 `(city, year, month)`。**该键共有 4 处落点,必须同时修改**,否则晋升与去重口径不一致会导致数据撕裂。
|
||||
|
||||
| # | 落点 | 改动 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `ingest_repository.StreetSnapIDByEntity` | 增加 `month` 参数,查询加 `AND month = ?`;接口注释同步 |
|
||||
| 2 | `ingest_service.processStreet` | 调用点传 `p.Month`;日志补上 month |
|
||||
| 3 | `review_repository.SaveStreetSnapFromDraft` | 正式行查找 `WHERE city = ? AND year = ?` 增加 month |
|
||||
| 4 | `review_repository.streetApprovedSiblingImages` | 兄弟草稿聚合查询增加 month 条件 |
|
||||
|
||||
**第 4 处是最容易漏、后果最脏的一处**:它是「同 city+year 已 approved 的其他街拍草稿图」的聚合查询。若只改第 3 处而不改第 4 处,会出现**「正式行按月分开了、图片却跨月混在一起」**——2 月 Copenhagen 专辑里混进 8 月的图。晋升与去重口径必须完全一致。
|
||||
|
||||
其余改动:
|
||||
|
||||
- **spider**:`Year` 与 `Month` 均取抓取时刻 `time.Now()`;删除 `(*TheImpressionSpider).parseYear`(失去调用方)。`yearRe` 被 `vogue.go` 共用,**保留**。
|
||||
- **写草稿**:`processStreet` 填 `Month: p.Month`。
|
||||
- **晋升写正式表**:`SaveStreetSnapFromDraft` 的 `common` map 增加 `"month": draft.Month`。
|
||||
- **后台可编辑**:`streetDraftEditable` 白名单放行 `month`;street 模块的 `Fields` 增加 `{month, 月份, number}`(与 `year` / `city` 一致——三者本就是键的一部分,后两者现已可编辑);列表卡片副标题由 `city` 改为 `city · YYYY-MM`。
|
||||
- **公开 API 不动**:`PublicStreetSnap` / `PublicStreetSnapDetail` 目前连 `year`、`city` 都未暴露,加 `month` 无意义。前端确有需要时再单独加字段。
|
||||
|
||||
**残留代价(明确接受)**:同城、同年、同月内的不同文章,仍会在「首条晋升之后」被判重跳过。但同一批抓取会一起进草稿、并在晋升时聚合,实际影响很小。这是键粒度换来的必然取舍,「爬过即终态」的既定语义不变。
|
||||
|
||||
### 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`,走兼容路径,同样不影响。
|
||||
- 两侧因此**不存在必须同时发布的顺序约束**。
|
||||
Reference in New Issue
Block a user