Files
backend_v2/docs/superpowers/specs/2026-09-20-ingest-idempotency-design.md
2026-09-20 01:06:13 +08:00

18 KiB
Raw Blame History

入库幂等设计(来源级去重 + 街拍实体键引入月份)

  • 日期: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 预检,但已被移除。爬虫代码中留有原文:

// 历史上这里会调用后端 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:

// 提取不到时返回 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 在唯一索引中不参与比较,可空会导致「同键可重复插入」,使索引形同虚设。

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 幂等:

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)街拍月份:两张街拍表各新增一列

month  smallint  not null default 0

对应模型:model.StreetSnapDraft、model.StreetSnap。由 AutoMigrate 托管,不建索引——现有 (city, year) 本就无索引,街拍表体量很小。

3.3 判定逻辑:worker 处理前查重

新增仓储方法(接口仍定义在消费方 IngestRepository,与项目现有做法一致):

// 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,走兼容路径,同样不影响。
  • 两侧因此不存在必须同时发布的顺序约束。