Files
cms-website/docs/superpowers/plans/2026-10-21-slice-import-pipeline-phase3.md
T

144 lines
20 KiB
Markdown
Raw Normal View History

# SiteForge 纵向切片 · Plan 3:内容导入管道(阶段 3)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把阶段 2 的 UniversalRow 流落库:稳定身份幂等导入(内容修订 + current 指针)、term 物化(含自建缺失 taxonomy)、媒体登记(魔数/SVG 隔离/uploads 对拍)、route_registry 生成(§6.1 全表处置);终点 = 真实 hesupipe 全量导入成功且二导幂等(0 新修订)。
**Architecture:** 依据 spec §15 阶段 3、§5 数据模型(Plan 1 已建 21 表)、§6.1 路径处置、§7.5 checksum;消费 Plan 2 产出(runWxrAdapter 四事件流 / registerSource 快照 / 版本块)。新增 `apps/api/src/modules/import/`(编排服务)+ `sf import` 命令。**导入不发布**(published 指针不动——ADR-010)。
**Tech Stack:** 既有栈零新增依赖(file-type 类魔数检测手写常用签名表,避免新依赖——见 Task 4)。
**边界(不做)**:显式 publish 命令(阶段 4)、构建/主题(阶段 4)、SEO diff(后续)、REST 通道、差量删除检测。
---
## 前置事实(执行者必读)
1. **Plan 2 移交清单 9 项**是本计划必读输入(Plan 2 文末);尤其第 1 项(term 表缺 category/post_tag,按 (domain,nicename) 自建)、第 2 项(uploads 路径对拍)、第 5 项(消费接口契约)。
2. 表结构已冻结(Plan 1):source_records 六列稳定身份 UNIQUE、content_revisions 不可变触发器、entry_locales 双指针、route_registry exact/collision 双键 + NULLS NOT DISTINCT。
3. 既有纪律全套沿用:TDD、临时库测试、身份断言、无包级 test 脚本、typecheck 四段链、cms 只读。
4. **导入语义核心**(spec §7.5/§8.1):同一 (site, connection, source_type, source_id, locale) 的 row → 已有 source_record 且 normalized_checksum 相同 → **skip(0 新修订)**;不同 → 新修订 + entry_locales.current_revision_id 前移(**published 不动**)。permalink 变化只影响 route,不影响条目身份。
5. wppop/extras 等未知字段已在 UniversalRow.extras——落 revision.data.extras 原样保留。
6. **normalized checksum 输入域(P0 钉死,评审 Issue 2)**:`canonicalJson({ universal_row_version, adapter_version, mapping_version, sanitizer_version, locale, mapped_content: {title, slug, **status**, bodyHtml, seo, knownFields, extras}, taxonomy_relations: taxonomyRefs, media_reference_identities: attachmentRefs, route_source_values: {sourceUrl} })`——**rawLocation 显式排除**(位置性字段,进哈希破坏跨快照 skip);版本三常量必入(bump 语义);status 必入(draft↔publish 翻转必须可见)。revision.data 顶层结构钉死:`{ title?, slug?, status, bodyHtml, seo, knownFields, extras }`。canonicalJson 帮助函数从 register.service.ts 提取到 `apps/api/src/common/canonical.ts` 共享导出(register 侧改 import,零行为变更)。**注**:sourceUrl 在哈希内(route_source_values)→ permalink 变化**会产新修订**(spec §7.5 一致),只是不建新 entry。
7. **媒体映射侧车(P0 钉死,评审 Issue 1——真实数据实证)**:真实媒体目录为扁平 GUID 命名(2625 文件无 yyyy/mm),与 WXR attachment_url 零精确命中;唯一映射在旧 cms 库 media_assets 表(source_url→file_path)。阶段 3 增加**映射侧车输入**:Task 0 从 cms PG(5433,只读 SELECT)导出该站 (source_url, file_path) 清单 JSON → 登记为 `manual_url_list` 快照(register 复用既有 kind)。对拍三级:①清单 uploads 相对路径精确命中 → ②侧车 source_url 命中(file_path 再对清单实体)→ ③missing 报告。**清单 miss 的 attachment 不插 media_assets 行**(sha256 NOT NULL 插不进),只进报告。
8. **locale 接缝坑(评审提醒)**:适配器 mapConfig.locale 缺省空串——import 必须传 `site.default_locale`,否则 1047 行全被拒为未知 locale。Task 6 编排钉死。
9. **阶段 3 站点路径来源(评审 Issue 4)**:主列表路径用 CLI 参数 `--products-path /products/ --news-path /news/`(默认值即此,记入 job metadata);taxonomy public_base_path 常量钉死(WP 默认):category→`/category/`、post_tag→`/tag/`、product_cat→`/product-category/`、product_tag→`/product-tag/`(本切片该路径不生效仅记录;spec §19.5 权威路径确认后可调——记入报告备注)。collision_key 折叠函数钉死:NFC + lowercase + 去尾斜杠。
10. **attachment 页三态降级(评审 Issue 3)**:阶段 3 无 sitemap 解析器——attachment 页**一律按"仅 WXR→301 媒体文件"**处理(sitemap→html 分支留待 sitemap 解析能力落地,报告注明降级)。
11. **attachment 行不建 content_entries**:wp:attachment 行只进媒体管道(media_assets)+ 路由(301);content_entries 仅 129 post + 48 product + 15 page = **192**(Task 7 计数以此为准)。sourceType→typeKey 映射钉死:wp:post→news_article、wp:page→basic_page、wp:product→product。
12. **unsupported item 也写 source_records**(评审 Issue ②选项①):nav_menu_item 等 75 条 → source_records 行(parse_status='unsupported'、locale=站点默认、source_id=post_id、不关联 content_entry)——"不支持对象有明确结果"的可审计性;**source_records 总数 = 1122**。**前置工作项(Task 1 内)**:@sf/adapter-wp 的 unsupported/error 事件**加法式补 `postId?: string`**(纯加法不触发 UniversalRow 契约升版;error 分支 failed>0 时同理按报告计数、不硬造 source_id)。
13. **import_jobs 与三快照的绑定**:job.source_snapshot_id 指向 **wxr 快照**(主输入);media_bundle 与 manual_url_list 由 CLI 按 (connection, kind) 解析最新并记入 job 报告(job 行不扩列)。Task 0 导出 SQL 加 `AND source_url IS NOT NULL`(867 中 8 行 NULL 不带出)。
## 仓库结构增量
```text
apps/api/src/modules/import/
import.service.ts # 编排:事件流→各写入器→报告
term-writer.ts # wp:term 事件 + 自建缺失 term
content-writer.ts # row→entry/locale/revision/source_record(checksum skip)
media-writer.ts # attachment row + media_bundle 对拍→media_assets/usages
route-writer.ts # §6.1 处置表→route_registry
magic-bytes.ts # 常用文件签名检测(png/jpg/gif/webp/pdf/svg 判别 + svg 文本特征)
scripts/sf.ts # + import 命令
tests/integration/import-*.test.ts
```
---
2026-10-21 15:13:09 +08:00
### Task 0: 媒体映射侧车导出(P0 前置,评审 Issue 1)✅ 完成(a227cfa + 加固 a185254,规格审查通过含 859/859 独立比对;导出 859 行、WXR 命中 836/855=97.8%、未命中 19 全列证据文档;register --media-map → manual_url_list 幂等)
**Files:** `scripts/export-media-map.mjs`;`docs/evidence/media-map-export.md`
- [ ] 只读查询 cms PG(5433,infrastructure-postgres-1,只读 SELECT——绝不写):`SELECT source_url, file_path FROM media_assets WHERE site_id = '<54f91c49 站点>' AND source_url IS NOT NULL ORDER BY source_url` → JSON 数组落 `storage/incoming/media-map-hesupipe.json`
- [ ] 登记为 manual_url_list 快照(`pnpm sf source:register --site … --sitemap?` 需扩:register 支持 `--media-map <json>` 输入→kind=manual_url_list——小改 register.service 输入枚举)
- [ ] 证据文档:行数、与冒烟 855 attachment 的覆盖率预估(URL 前缀归一后 join 对照)
- [ ] commit `feat(source): 媒体映射侧车导出与登记`
2026-10-21 16:00:14 +08:00
### Task 1: import 命令骨架 + import_jobs 生命周期 ✅ 完成(0e8b2b5 + 修复 eb188da,双审通过;282 测试;审查战果:复用口径忠实化、**管道 Outcome 契约定缝**(counts/issues + ctx.report 累加器 + succeeded_with_issues 派生)、报告 tmp+rename 先写后落行、stale 警告、真 PG 23505 探针。遗留两行入 Task 2:ImportErrorCode 补 REPORT_WRITE_FAILED、ctx.db 事务边界 JSDoc) ✅ 完成
**Files:** `apps/api/src/modules/import/{import.service.ts,import.module.ts}`;sf.ts + `import`;Test `tests/integration/import-job.test.ts`
(本任务含前置事实 12 的 adapter 事件加法扩展:unsupported/error 事件补 `postId?: string`)
2026-10-21 15:30:42 +08:00
- [x] 失败测试:`sf import --site <key> --snapshot <id>`(或按 connection+kind 选最新快照)→ import_jobs 行 created→running→succeeded(或 succeeded_with_issues/failed);idempotency_key 唯一(重复提交同 key 复用既有 job 不重跑);job 记录 mapping/adapter/sanitizer 版本块(来自 versions.ts)
- [x] 实现:job 状态机 + 报告落 artifacts/import/<jobId>/;空流(无 snapshot)→ 明确报错
- [x] 绿 → commit `feat(import): job 骨架与生命周期`
2026-10-21 16:50:12 +08:00
### Task 2: term 物化(含自建缺失)✅ 完成(191c275 + 修复 8485c51,双审通过;289 测试;审查战果:**双序终态等价**(收编 UPDATE + name-only 收编,混合序独立探针证实)、同 slug 双 id 报告、handleEvent 分派形状(Task 6 编排就绪)。遗留入 Task 3/6:content_terms 跨快照 is_primary 翻转决策;autoCreatedTerms null-id 注脚;REPORT_WRITE_FAILED 测试)
**Files:** `term-writer.ts`;Test `tests/integration/import-terms.test.ts`
- [ ] 失败测试(用 mini.xml 全链 + 手构缺表场景):
- wp:term 事件 → taxonomies(按 term_taxonomy 映射 object_type:category→post/product_cat→product/post_tag→post…)+ terms(source_id=term_id)+ 幂等重跑不重复
- **自建缺失 term**(移交项①):row 的 taxonomyRefs 中 (domain, nicename) 无对应 term → 自动建(source_id=`auto:<nicename>`、slug=nicename、name=引用 value)+ 报告计数 autoCreatedTerms;**同一 nicename 多次引用只建一条**(唯一 (site, taxonomy, source_id) 兜底)
- content_terms 写入(is_primary:首个为 true 余 false——WP 单主分类语义近似,JSDoc 注明)
- [ ] 实现 + 绿 → commit `feat(import): term 物化与缺失自建`
### Task 3: 内容写入器(稳定身份 + checksum skip)✅ 完成(2b61431 + rider fe72df3,双审通过;296 测试;幂等三态含跨快照五列身份、checksum 三方独立对拍、published 全程未碰;审查沉淀:counts 键全局命名空间契约、unsupported/failed 分项键、NULL-entry 缝归属 Task 6)
**Files:** `content-writer.ts`;Test `tests/integration/import-content.test.ts`
- [ ] 失败测试:
- 首导:每 row → source_records + content_entries + entry_locales(current_revision_id 设、**published NULL**)+ content_revisions(source=import、checksum=normalized checksum、data 含 title/knownFields/extras/清洗后 bodyHtml/seo)
- **二导同快照**:0 新修订(checksum skip)、0 新 entry
- **内容变化**(改 checksum 手构):新修订 + current 前移 + published 仍指旧值(ADR-010 断言)
- **permalink 变化**(改 sourceUrl 手构):**不建新 entry**(身份键不含 URL)+ route 更新走 Task 5;source_records.source_url 更新
- locale 语义:row.locale 必须匹配站点 default_locale 或显式支持列表——未知 locale 记错误跳过(Plan 1 纪律)
- [ ] 实现 + 绿 → commit `feat(import): 内容稳定身份写入`
### Task 4: 媒体登记(魔数/隔离/对拍)✅ 完成(ed081ec + 加固 7e6ce91,双审通过;310 测试;三级对拍含侧车扁平名候选/两阶段 usage 双序一致/SVG quarantine 不建 usage/幂等定向冲突;加固:manifest 形态守卫/侧车门控/约束名精确认领(constraint_name 实证)/窗口边界。Task 7 度量口径已固化计划)
**Files:** `media-writer.ts` + `magic-bytes.ts`;Test `tests/integration/import-media.test.ts`
- [ ] 失败测试:
- attachment row 对拍**三级**(前置事实 7):①uploads 相对路径清单精确命中 ②侧车 source_url→file_path→清单实体 ③missing 报告(**不插 media_assets 行**);media_assets(public_path=旧 uploads 路径、sha256/storage_path 从清单取)+ media_usages(正文/featured/gallery 引用从 row.attachmentRefs+清洗后 HTML 提取)
- **悬空引用**(mini 203/204):引用无对应 attachment/清单记录 → 报告 issue 计数、**不静默丢弃**(§11.2 一级/§7.6 禁静默)
- **魔数校验**:png/jpg/gif/webp/pdf 签名表手写(~40 行);声明 MIME 与检测不符 → 报告 issue;**SVG 检出(文本 `<svg` 特征)→ status=quarantined 不进产物**(spec §11.1)
- 多候选(同名不同目录)→ **歧义不自动选**,全部列报告(spec §5.5)
- 855 attachment 真实规模冒烟(Task 7 一并)
- [ ] 实现 + 绿 → commit `feat(import): 媒体登记与安全校验`
2026-10-21 19:46:05 +08:00
### Task 5: route_registry 生成(§6.1 处置全表)✅ 完成(9d2cc0a + 修复 bb2d381 + docs 15d7c91,双审通过;317 测试;§6.1 阶段 3 可产生的行全覆盖(mini 全链 12 行逐行对拍),?p= 排除钉死"不插行+计数+issue"(route_kind 六值无 excluded——查询路由 SP3),attachment 301 暂存到 finish 反查 media_assets(时序契约),host 单值 = production_origin(旧 host 变体 SP3),upsert 三分(owner = route_kind+target_content_id+target_file),日期/作者归档与人工映射/410 同属无输入源合理空缺;**审查修复:finish ②④ 域 isSafePath 统一(反斜杠穿越探针双重验证)、attachment ?p= 排除对齐、WriteOutcome 四态降级计数精化、permalink 形态覆盖**)
**Files:** `route-writer.ts`;Test `tests/integration/import-routes.test.ts`
2026-10-21 19:15:38 +08:00
- [x] 失败测试(**所有阶段 3 可产生的 §6.1 行**均有对应用例——含评审补的 asset 与首页两行,mini 数据手构补充):
- 内容 permalink → html(路径保真含 .html;target_content_id 关联);**首页 `/` → html**;**媒体上传路径 `/wp-content/uploads/...` → asset**(media public_path 生成);主列表页 `/products/`、`/news/`(**CLI 参数来源**,默认即此)→ html;产品/文章分类 → html(public_base_path 常量按前置事实 9,product_tag 记 `/product-tag/`——WooCommerce 默认,本切片该路径不生效仅记录);tag/product_tag/日期/作者 → redirect 301 主列表;attachment 页 → **一律 301 媒体文件**(前置事实 10 降级,报告注明);`?p=` → excluded_with_reason 登记。**"所有阶段 3 可产生的 §6.1 行"均有用例**(人工映射/确认删除两行无输入源,属合理空缺)
- **exact/collision 双键**:`/About` 与 `/about` 两条并存(collision_key 折叠函数按前置事实 9 钉死);同 exact_key 冲突(两内容同路径)→ 报告 conflict 不静默覆盖
- **重导幂等**:同路径同目标 → 0 新行;目标变 → 更新(route 表可变,非不可变表)
2026-10-21 19:15:38 +08:00
- [x] 实现 + 绿 → commit `feat(import): 路由注册表生成`
2026-10-21 20:40:51 +08:00
### Task 6: 编排集成 + 导入报告 ✅ 完成(8003de3 + 修复 77687e2 + 终验补完 fa6e8a3,双审通过;325 测试;九项编排义务全落地(真流/串行分派/finish 硬序/counts 求和/NULL-entry 收编/共享基类型/stale 回收/REPORT_WRITE_FAILED 测试/清洗统计);审查战果:**失败报告部分冲刷**(eventsDispatched+纯 finish 补算)、列表路径建行前校验(INVALID_LIST_PATH)、reason 归档分层。HTML 报告推迟 Task 8;orchestrator.ts 抽取触发器已记)
**Files:** `import.service.ts` 补全;Test `tests/integration/import-full.test.ts`
- [ ] 失败测试:mini.xml register→import 全链:site/connection/snapshot→job→terms(2+自建0)→content_entries(**3**:post/product/page——attachment 不建,前置事实 11)→media(2+对拍)→routes(首页+2列表+3内容+2媒体 asset+2 attachment 301+tags…)→报告(各计数+**清洗统计**+版本块+冲突/失配清单);**published_revision_id 全 NULL**(导入不发布断言);**mapConfig.locale=site.default_locale 接线断言**(前置事实 8)
- [ ] **Task 3 审查移交的编排义务**:① NULL-entry 占位行收编(content-writer 六列分支 UPDATE 挂 entry——精确位置已标注在代码缝注释)② 多写入器 counts 按**求和**聚合(import.service 现为⊕覆盖——键名全局唯一已 JSDoc 契约化;编排层改或约定各写入器键前缀,二选一定死)③ 写入器 identity 参数共享基类型(Pick<ImportJobContext,…>)防漂移 ④ stale 回收(已在清单)⑤ REPORT_WRITE_FAILED 测试补 ⑥ wp:attachment 双写入方单一 owner 决策(content-writer record-only 足迹 vs media-writer——媒体域归 Task 4,编排广播时 media-writer 消费 attachment 行、content-writer 只留 record 足迹,Task 6 定分派规则)
- [ ] **Task 5 审查移交的汇流契约(Task 6 实现底本)**:事件期**严格串行 await** 分派 `content.handleEvent → term → media → route`(三家锚点都查 content 同事件产物,禁 Promise.all);finish 期唯一硬序 `await media.finish()` **先于** `route.finish()`(attachment 反查与 asset 全表读 media_assets);term/content finish 同步无库依赖任意位置。**测试 fullPipeline(import-routes.test.ts)即抄写底本**。Task 7 度量备注:routesCollisions 重跑如实再现非缺陷;WP 首页=page 场景的首页冲突 issue 有既定解释;真实规模 ~2815 route 行 ~9300 RT——**DB 须同机/近端跑并记录耗时**,超预期先加 `(site_id,host,collision_key)` 与 `media_assets(source_id)` 两索引
- [x] 报告落 artifacts/import/<jobId>/reports/import.json(**HTML 版与错误封顶推迟至 Task 8 收尾**——Task 6 审查确认;JSON 已含 18 键 counts/issueGroups 六组/清洗统计/版本块)
- [ ] stale 回收:超时 running → stale 置位与心跳列决策(ADR-017)
- [ ] 绿 → commit `feat(import): 全链编排与导入报告`
### Task 7: 真实 hesupipe 全量导入(阶段 3 验收)
**Files:** `scripts/import-real.mjs`(或直接 sf import 命令 + 采样);`docs/evidence/phase3-import.md`
- [ ] register 真实 WXR + 媒体目录(`D:/project/cms/storage/media/54f91c49-*` 只读副本到 storage/incoming——**实测约 171MB/2625 文件**(评审勘误:非 500MB,勿拷错站点目录),耗时记录)+ **Task 0 侧车** → sf import
- [ ] 验收断言:content_entries=**192**(129+48+15);media_assets=侧车+清单命中数(报告对照 855 attachment 的三级分布);source_records=1122;terms=451+自建数(对照冒烟 807 引用覆盖);routes 计数与处置分布报告;**二导 0 新修订**;数据库行数与导入报告逐项一致
- [ ] **Task 4 审查移交的度量口径**:mediaRegistered 只计首跑插入——838 命中数必须来自干净库首跑;missing/quarantined/dangling 重跑如实再现;**quarantined/unsupported 资产的引用不落任何 count 桶**(非 dangling 非 usage)——"侧车 23 悬空"对拍时勿期望它们在 mediaDanglingRefs 里(只能经 mediaQuarantined/issues 可见)
- [ ] 内存/耗时记录;证据文档;commit `test(evidence): 阶段3真实全量导入验收`
- [ ] 任何计数不符 → 如实报告停下分析(同 Task 8 纪律)
### Task 8: 收尾
- [ ] ADR-017(导入幂等语义:稳定身份键选择/checksum 输入域/自建 term 策略);README 状态更新;typecheck 四段链/lint/test 三绿;阶段 4 移交清单(publish 命令消费的接口:current 指针集合/审计动作形状;site_config_revisions 首版生成时机建议)
- [ ] commit + push 由用户执行
## 验收(对照 spec §15 阶段 3)
- [ ] 同 Snapshot 二次导入 0 新 Revision
- [ ] Permalink 变化不创建重复 Content Entry
- [ ] 媒体歧义不自动匹配(多候选全列报告)
- [ ] URL 冲突和不支持对象全部进入报告
- [ ] 真实全量导入成功 + 幂等重跑 + 报告对照
- [ ] 三绿;published 指针全程未动