# 入库幂等设计(来源级去重 + 街拍实体键引入月份) - 日期: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` 非空时,仍按 `('', )` 参与去重,不做特殊处理。 实际接入的两个爬虫都会同时上送两字段,此规则仅为消除歧义。 插入点在 `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`,走兼容路径,同样不影响。 - 两侧因此**不存在必须同时发布的顺序约束**。