Files
cms-website/docs/superpowers/plans/2026-10-21-slice-import-pipeline-phase3.md
T
浅念 cf6193acc5 docs(plan): P3 Task 6 勾选
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 20:40:51 +08:00

144 lines
20 KiB
Markdown
Raw 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.
# 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
```
---
### 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): 媒体映射侧车导出与登记`
### 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`)
- [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 骨架与生命周期`
### 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): 媒体登记与安全校验`
### 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`
- [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 表可变,非不可变表)
- [x] 实现 + 绿 → commit `feat(import): 路由注册表生成`
### 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 指针全程未动