Co-Authored-By: Claude Code <noreply@anthropic.com>
18 KiB
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 通道、差量删除检测。
前置事实(执行者必读)
- Plan 2 移交清单 9 项是本计划必读输入(Plan 2 文末);尤其第 1 项(term 表缺 category/post_tag,按 (domain,nicename) 自建)、第 2 项(uploads 路径对拍)、第 5 项(消费接口契约)。
- 表结构已冻结(Plan 1):source_records 六列稳定身份 UNIQUE、content_revisions 不可变触发器、entry_locales 双指针、route_registry exact/collision 双键 + NULLS NOT DISTINCT。
- 既有纪律全套沿用:TDD、临时库测试、身份断言、无包级 test 脚本、typecheck 四段链、cms 只读。
- 导入语义核心(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,不影响条目身份。
- wppop/extras 等未知字段已在 UniversalRow.extras——落 revision.data.extras 原样保留。
- 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。 - 媒体映射侧车(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 插不进),只进报告。 - locale 接缝坑(评审提醒):适配器 mapConfig.locale 缺省空串——import 必须传
site.default_locale,否则 1047 行全被拒为未知 locale。Task 6 编排钉死。 - 阶段 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 + 去尾斜杠。 - attachment 页三态降级(评审 Issue 3):阶段 3 无 sitemap 解析器——attachment 页**一律按"仅 WXR→301 媒体文件"**处理(sitemap→html 分支留待 sitemap 解析能力落地,报告注明降级)。
- 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。
- 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)。 - 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 不带出)。
仓库结构增量
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)
- 失败测试:
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) - 实现:job 状态机 + 报告落 artifacts/import//;空流(无 snapshot)→ 明确报错
- 绿 → 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,315 测试;§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 同属无输入源合理空缺)
Files: route-writer.ts;Test tests/integration/import-routes.test.ts
- 失败测试(所有阶段 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 表可变,非不可变表)
- 内容 permalink → html(路径保真含 .html;target_content_id 关联);首页
- 实现 + 绿 → commit
feat(import): 路由注册表生成
Task 6: 编排集成 + 导入报告
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 定分派规则)
- 报告落 artifacts/import//reports/import.json+html(沿用 Plan 2 Task 6 报告纪律:动态文本转义/错误封顶/清洗统计入报告)
- 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 指针全程未动