From 6d9f7b6ad7534fce08b9130c48632b076a23fe24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 21 Oct 2026 21:20:21 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20ADR-017/README/=E9=98=B6=E6=AE=B54?= =?UTF-8?q?=E7=A7=BB=E4=BA=A4=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-017 导入管道决策五项:幂等双轨身份键(六列快照行+五列内容身份)与 canonical.ts 共享 checksum 输入域/收编 UPDATE 语义;自建 term((domain,nicename) 兜底 + auto: 前缀 + name 回退 + 双序终态等价含 null-id 收编);counts 键全局 命名空间求和聚合;stale 回收维持 created_at 近似 + 24h 不加心跳列(REST 化重审) 与报告 issues 封顶;编排串行分派契约(锚点依赖推导) - README:快速开始加 sf import 全链示例(register→import);当前状态更新 (阶段 3 完成:192 条目真实落库/二导幂等 0 新修订/333 测试);ADR-001~017 - Plan 3 计划文档:Task 8 勾选 + 验收 6 项全过核验 + 文末新增"移交 Plan 4 清单" 8 项(publish 命令/Render Snapshot 六文件/pilot-minimal 六类页面+Astro 五禁/ Preview Server/最小门禁/导入产物消费接口与副本路径/续传项/度量基线 51.8s·20.3s·304MiB) Co-Authored-By: Claude Code --- README.md | 22 +++++++---- docs/adr/ADR-017-导入管道决策.md | 33 +++++++++++++++++ ...2026-10-21-slice-import-pipeline-phase3.md | 37 ++++++++++++++----- 3 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 docs/adr/ADR-017-导入管道决策.md diff --git a/README.md b/README.md index f5bb93f..603a545 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,16 @@ pnpm db:migrate # 开发库全量迁移(apps/api/drizzle pnpm db:test # 一键验收:起库 + 跑全部测试(测试用临时库,开发库零写入) ``` -WXR 适配器(阶段 2)速览——`pnpm sf` CLI(tsx 直吃 TS,无需构建): +`pnpm sf` CLI 速览(tsx 直吃 TS,无需构建)——阶段 2 探测/登记 + 阶段 3 导入: ```bash pnpm sf wxr:probe --wxr path/to/export.xml # 只读诊断:流式解析→映射→清洗→计数报告(不入库) -pnpm sf source:register --site --wxr [--sitemap ] [--media ] # 快照登记(content-addressed、幂等) +pnpm sf source:register --site --wxr [--sitemap ] [--media ] [--media-map ] # 快照登记(content-addressed、幂等) +pnpm sf import --site [--snapshot ] [--products-path /products/] [--news-path /news/] # 导入落库(幂等 job + JSON/HTML 报告) + +# 全链示例(register → import;同快照重复 import 复用既有 job 不重跑) +pnpm sf source:register --site demo --wxr storage/incoming/export.xml --media storage/incoming/media --media-map storage/incoming/media-map.json +pnpm sf import --site demo # 报告落 artifacts/import//reports/import.{json,html} ``` 日常质量门(等价于 CI): @@ -32,12 +37,12 @@ pnpm lint && pnpm typecheck && pnpm test ## 目录结构 ``` -apps/api/ NestJS standalone 骨架 + Drizzle schema/迁移(21 表,数据库约束即规格)+ source 快照登记 +apps/api/ NestJS standalone 骨架 + Drizzle schema/迁移(21 表,数据库约束即规格)+ source 快照登记 + 导入管道(四写入器编排) packages/contracts/ UniversalRow / Route / Render / Theme 跨源契约(冻结,变更须升版) packages/adapter-wordpress/ WXR 适配器(saxes 流式解析 + PHP unserialize + 映射 + HTML 白名单清洗,纯函数无 DB) tests/ 单元 + 集成测试(fixtures:WXR/Sitemap/媒体样例) infra/ docker-compose:PostgreSQL 16(端口 5434,避开 cms 项目 5433) -scripts/ migrate-test.sh 等运维脚本 · sf.ts(source:register / wxr:probe CLI) +scripts/ migrate-test.sh 等运维脚本 · sf.ts(wxr:probe / source:register / import CLI) docs/ adr/ 决策记录 · evidence/ 基线证据 · superpowers/ 规格与计划 ``` @@ -45,11 +50,12 @@ docs/ adr/ 决策记录 · evidence/ 基线证据 · superpowers/ - 设计基线(表结构/技术决策/阶段划分):`docs/superpowers/specs/2026-10-20-siteforge-vertical-slice-design-v2.md` - 阶段 0+1 实施计划:`docs/superpowers/plans/2026-10-20-slice-foundation-phase0-1.md` -- 架构决策记录(ADR-001~016):`docs/adr/` -- 基线证据(环境/真实 WXR):`docs/evidence/` +- 架构决策记录(ADR-001~017):`docs/adr/` +- 基线证据(环境/真实 WXR/阶段 3 导入验收):`docs/evidence/` ## 当前状态 - **阶段 1 已完成**:契约冻结、21 表数据模型全量落地(不可变触发器/唯一约束/CHECK 均落数据库并被 75 项测试锁定)、空环境一键启动+迁移验收通过。 -- **阶段 2 已完成**:WXR 流式适配器(saxes 流式解析 → PHP unserialize → UniversalRow 映射 → HTML 白名单清洗 → 导入前报告)、`source:register` 快照登记(content-addressed、幂等)与 `pnpm sf` CLI;263 测试全绿,真实 hesupipe WXR(5.5MB)冒烟 18 项计数对照全过(峰值堆 62MiB < 100MiB,证据见 `docs/evidence/phase2-smoke.md`)。 -- **下一步:阶段 3**——内容/分类/媒体入库(source_records 落库、稳定身份 upsert、checksum skip、route_registry 生成);输入清单见 Plan 2 文末"移交阶段 3 清单"。 +- **阶段 2 已完成**:WXR 流式适配器(saxes 流式解析 → PHP unserialize → UniversalRow 映射 → HTML 白名单清洗 → 导入前报告)、`source:register` 快照登记(content-addressed、幂等)与 `pnpm sf` CLI;真实 hesupipe WXR(5.5MB)冒烟 18 项计数对照全过(峰值堆 62MiB < 100MiB,证据见 `docs/evidence/phase2-smoke.md`)。 +- **阶段 3 已完成**:内容导入管道(`sf import`——content/term/media/route 四家写入器串行编排、稳定身份双轨幂等、checksum skip、route_registry §6.1 全表处置、JSON+HTML 导入报告与错误封顶);真实 hesupipe 全量导入验收通过——192 内容条目/1122 source_records/927 terms/2772 routes 落库,二导幂等(0 新修订)、published 指针全程未动(ADR-010),333 测试全绿(证据见 `docs/evidence/phase3-import.md`)。 +- **下一步:阶段 4**——content:publish 命令 + Render Snapshot + pilot-minimal 主题 + Astro 构建 + 本地 Preview;输入清单见 Plan 3 文末"移交 Plan 4 清单"。 diff --git a/docs/adr/ADR-017-导入管道决策.md b/docs/adr/ADR-017-导入管道决策.md new file mode 100644 index 0000000..967b618 --- /dev/null +++ b/docs/adr/ADR-017-导入管道决策.md @@ -0,0 +1,33 @@ +# ADR-017:导入管道决策(幂等语义/自建 term/counts 聚合/stale 回收/编排分派) + +- **状态**:已采纳(Plan 3 Task 8 收尾,阶段 3;实现锚点 `apps/api/src/modules/import/`——import.service.ts / content-writer.ts / term-writer.ts / media-writer.ts / route-writer.ts / orchestrator.ts) + +## 决策一:幂等语义(spec §7.5,承接 ADR-006) + +1. **稳定身份键双轨**: + - **快照行(六列)**:source_records 以 (site, connection, source_type, source_id, locale, **snapshot_id**) 唯一(`source_records_identity_uq`)——每快照各一行,是 normalized_checksum 的对比对象; + - **内容身份(五列)**:六列去掉 snapshot_id——跨快照复用同一 content_entry(permalink 变化、快照重导都**不建新 entry**,只在新快照行上前移 entry 指针)。两键由 content-writer 单写入方持有,五列解析口径与 term-writer.resolveContentEntryId 同源(latest first)。 +2. **checksum 输入域**:`canonicalJson`(`apps/api/src/common/canonical.ts`——与 register 的 media_bundle 清单哈希**共享同一实现**,单一序列化基形防两处漂移)作用于 `{ universal_row_version, adapter_version, mapping_version, sanitizer_version, locale, mapped_content: {title, slug, status, bodyHtml, seo, knownFields, extras}, taxonomy_relations, media_reference_identities, route_source_values: {sourceUrl} }` 再 sha256。**rawLocation 显式排除**(位置性字段,入哈希破坏跨快照 skip);三语义版本必入(bump 即新修订);status 必入(draft↔publish 翻转必须可见);sourceUrl 入(permalink 变化产新修订——spec §7.5 一致,但不建新 entry)。 +3. **收编 UPDATE 语义**:六列身份命中的既有行不重插——checksum 相同 → skip(0 新修订,二导幂等的根基);不同 → 新修订 + entry_locales.current 前移;**published 永不碰**(ADR-010:导入不发布)。外源 NULL-entry 占位行(六列命中、content_entry_id 为 NULL)走 UPDATE 挂 entry——不删不插,避免撞唯一约束。UPDATE 只落在可变表(source_records / entry_locales.current);content_revisions 纯 INSERT(不可变触发器兼容)。 + +## 决策二:自建 term 策略(Plan 2 移交项①兑现,真实数据实证) + +4. **(domain, nicename) 兜底自建**:真实 WXR 的 wp:term 表可整域缺席(hesupipe:category 148 引用 / post_tag 659 引用零条目——WordPress 7.1.3 导出行为)。row 的 taxonomyRefs miss 即自建 term:source_id = **`auto:` 前缀**(与 wp:term 的 term_id 字符串源身份空间隔离)、slug = nicename、**name 回退 = nicename**(UniversalRow.taxonomyRefs 契约不携带显示名 value——契约冻结,显示名回填须升版评审)。同一 nicename 多次引用只建一条(唯一 (site, taxonomy, source_id) 兜底)。 +5. **双序终态等价(含 null-id 收编)**:WXR 文档序 items 先于 wp:term——row 先到则按 nicename 自建,对应 wp:term 事件后到时**收编**(就地 UPDATE source_id/name 扶正为权威值);反向序(wp:term 先到)直建权威行。**null-id wp:term** 照建行(source_id=`auto:`)并记 issue;同 slug 行已见时只做 **name-only 收编**(UPDATE name,source_id 不动——无权威值可扶正)。同 slug 双 term_id → 文档序 first-wins + issue(不静默)。两种事件顺序终态一致(混合序独立探针测试钉死)。 + +## 决策三:counts 键全局命名空间 + 求和聚合(Task 6 钉死) + +6. **counts 是跨写入器的扁平全局命名空间**:汇合(ctx.report 累加器 ⊕ 管道返回值、四家 finish 产出)按**同名键求和**聚合(`orchestrator.sumCounts`/`mergeOutcomes`)——Task 1 的同名覆盖(CLOBBER)语义废弃。四家键前缀互斥(term:termsProcessed/autoCreatedTerms/contentTermsCreated;content:contentEntries/revisionsCreated/sourceRecords/skipped/…;media 六键 media 前缀;route 五键 routes 前缀;orchestrator 仅失败路径占 eventsDispatched),求和是防未来键名撞车的安全网;各写入器在自身 JSDoc 声明键归属,新写入器勿复用他家键名。 + +## 决策四:stale 回收与心跳列 + +7. **维持 created_at 近似 + 24h 回收,不加心跳列**:每次建 job 前把同 site 超过 `STALE_AFTER_MS = 24h` 仍 queued/running 的 job 置 stale(崩溃/中断残留终态化,finished_at 记宣判时刻)。评估一句话:单进程 CLI 模式下 job 就地推进、24h 远超任何真实执行时长,心跳列收益低不值当扩列;**REST 化(多进程/远程执行)时重审**。 +8. **报告 issues 封顶**(Task 8 兑现 Task 6 推迟项,对齐 Plan 2 ROW_ERRORS_CAP 纪律):import.json 的 flat issues 与 issueGroups 各组按 `REPORT_ISSUES_CAP = 10_000` 截断、`issuesTruncated` 记 flat 溢出(分组是同一集合的分划视图,各组溢出 ≤ flat 溢出不单独入账);counts/sanitize 统计始终完整;终态判定(succeeded_with_issues)在**全量**长度上做——截断不吞终态事实。HTML 版(import.html,renderImportReportHtml)与 JSON 同 try 落盘,动态文本全转义(Plan 2 Task 6 模板纪律)。 + +## 决策五:编排串行分派契约(锚点依赖推导,Task 5 审查移交钉死) + +9. **事件期严格串行 await**:content.handleEvent → term → media → route 逐家 await——term/media/route 三家的 entry 锚点解析依赖 content 对**同事件**的落库产物(source_records.content_entry_id / entry_locales),**禁 Promise.all**(并行下锚点查询可能抢在 content 写入前发生)。wp:attachment 行媒体域归 media-writer 单一 owner,content-writer 只留 record-only 足迹(前置事实 11)。 +10. **finish 唯一硬序**:`await media.finish()` 先于 `await route.finish()`(attachment 301 反查与 asset 全表都读 media_assets——资产须已齐);term/content finish 是纯返回(无库依赖)任意位置。**失败报告部分冲刷**:分派/finish 中途抛错时 term/content 纯 finish 就地补算 + eventsDispatched 进度计数冲入累加器后重抛;media/route finish 依赖库与先序产物,失败路径不调用(部分数据不可安全取得)。 + +- **理由**:幂等三态(miss / checksum 同 skip / checksum 异新修订)全部落在数据库唯一约束与不可变触发器之上(Plan 1 的"约束即规格"),应用层零防御性锁;自建 term 与收编语义保证 WXR 文档序不敏感(真实导出序 items 先于 wp:term 是常态而非例外);串行分派以最小复杂度满足锚点依赖,杜绝并行竞态面。 +- **权衡/陷阱**:① 五列身份解析的 latest-first 口径依赖 source_records 无并发写入方(CLI 单进程成立,REST 化时需重审——与决策四同一重审点);② auto: 前缀行被后续 wp:term 收编后 source_id 变更——跨快照的 term 幂等以 (site, taxonomy, slug) 域锚定而非 source_id,行为正确但排查时须知两种 source_id 形态并存;③ counts 求和的前提"键前缀互斥"是**约定不是约束**——新写入器复用他家键名会静默相加,JSDoc 键归属声明是唯一防线;④ 24h 回收窗口内崩溃残留的 job 会阻塞同 key 重提交的语义澄清(复用命中非终态 → CLI 警告 + 显式换 --key 重试),这是有意的"不静默重跑"纪律。 diff --git a/docs/superpowers/plans/2026-10-21-slice-import-pipeline-phase3.md b/docs/superpowers/plans/2026-10-21-slice-import-pipeline-phase3.md index 5b510ce..1c6bfde 100644 --- a/docs/superpowers/plans/2026-10-21-slice-import-pipeline-phase3.md +++ b/docs/superpowers/plans/2026-10-21-slice-import-pipeline-phase3.md @@ -128,16 +128,33 @@ tests/integration/import-*.test.ts - [ ] 内存/耗时记录;证据文档;commit `test(evidence): 阶段3真实全量导入验收` - [ ] 任何计数不符 → 如实报告停下分析(同 Task 8 纪律) -### Task 8: 收尾 +### Task 8: 收尾 ✅ 完成(ADR-017 五决策/HTML 报告与 issues 封顶/orchestrator.ts 抽取(import.service 849→754 行,零行为变更)/README 全链示例与阶段 3 状态/移交 Plan 4 清单 8 项;333 测试全绿;两个 commit:feat(import) + docs,不 push——推送由用户执行) -- [ ] ADR-017(导入幂等语义:稳定身份键选择/checksum 输入域/自建 term 策略);README 状态更新;typecheck 四段链/lint/test 三绿;阶段 4 移交清单(publish 命令消费的接口:current 指针集合/审计动作形状;site_config_revisions 首版生成时机建议) -- [ ] commit + push 由用户执行 +- [x] ADR-017(导入幂等语义:稳定身份键选择/checksum 输入域/自建 term 策略)——`docs/adr/ADR-017-导入管道决策.md` 五决策:①幂等双轨身份键(六列快照行 + 五列内容身份)/canonical.ts 共享 checksum 输入域/收编 UPDATE 语义 ②自建 term((domain,nicename) 兜底 + auto: 前缀 + name 回退 + 双序终态等价含 null-id 收编)③counts 键全局命名空间 + 求和聚合 ④stale 回收 24h 常量 + **不加心跳列**(单进程 CLI 下收益低,REST 化重审)⑤编排串行分派契约(锚点依赖推导);附带报告 issues 封顶(REPORT_ISSUES_CAP=10k)决策入册 +- [x] HTML 报告 + 错误封顶(Task 6 推迟项兑现):`report-html.ts` renderImportReportHtml(Plan 2 Task 6 模板纪律——全动态文本转义/静态骨架无 script/分组固定序)+ import.html 落盘(与 JSON 同 try 同失败口径);import.json issues/issueGroups 封顶 10k + issuesTruncated 计数(终态判定用全量长度——截断不吞 succeeded_with_issues 事实);测试 7 单元(恶意 payload 转义/结构/封顶注记)+ 1 集成(10_003 条注入:截断计数/原文保留/HTML 转义) +- [x] orchestrator.ts 抽取(审查触发器:import.service 849 行):orchestratedPipeline + mergeOutcomes + sumCounts + trySyncFinish 迁至 `apps/api/src/modules/import/orchestrator.ts`(纯函数 over ctx;契约类型单源 import.service.ts 不动——写入器同款 type-only 反向引用);既有测试全绿即证零行为变更 +- [x] README 状态更新:快速开始加 `pnpm sf import` 全链示例(register→import);当前状态更新(阶段 3 完成:192 条目真实落库/二导幂等 0 新修订/333 测试);ADR-001~017;下一步阶段 4 指向文末移交清单 +- [x] 阶段 4 移交清单(见文末新节定稿——含 publish 消费接口、Render Snapshot 六文件、pilot-minimal 六类页面、Preview、最小门禁、度量基线 8 项) +- [x] typecheck 四段链/lint/test 三绿(333 测试);commit 两个(feat(import) + docs);push 由用户执行 -## 验收(对照 spec §15 阶段 3) +## 验收(对照 spec §15 阶段 3)✅ 全过(Task 7/8 收官核验) -- [ ] 同 Snapshot 二次导入 0 新 Revision -- [ ] Permalink 变化不创建重复 Content Entry -- [ ] 媒体歧义不自动匹配(多候选全列报告) -- [ ] URL 冲突和不支持对象全部进入报告 -- [ ] 真实全量导入成功 + 幂等重跑 + 报告对照 -- [ ] 三绿;published 指针全程未动 +- [x] 同 Snapshot 二次导入 0 新 Revision(Task 7 独立复跑 21 断言全 PASS:revisionsCreated=0;集成测试 import-full/import-content 钉死) +- [x] Permalink 变化不创建重复 Content Entry(五列内容身份与 URL 解耦——import-content 用例 + 真实 836 301 反查) +- [x] 媒体歧义不自动匹配(多候选全列报告)(mediaAmbiguous 计数 + 逐条 issue) +- [x] URL 冲突和不支持对象全部进入报告(routesCollisions/issueGroups 六组 + unsupported → source_records parse_status='unsupported' 可审计) +- [x] 真实全量导入成功 + 幂等重跑 + 报告对照(192/1122/927(自建 476)/836/2772 逐桶对拍,见 `docs/evidence/phase3-import.md`) +- [x] 三绿;published 指针全程未动(333 测试;published_revision_id 全库 0 非 NULL——Task 6/7 双重断言) + +--- + +## 移交 Plan 4 清单(Plan 3 收官定稿——阶段 4:content:publish + Render Snapshot + pilot-minimal 主题 + Astro 构建 + 本地 Preview,计划的必读输入) + +1. **content:publish 命令**(V2 spec §8):dry-run/confirm(preview-token)/批量/审计动作形状——`published_revision_id = current_revision_id` 单语句语义、不得 UPDATE content_revisions、拒绝未解决 P0 issue、拒绝跨 site、重复发布幂等。**published 指针此刻才开始动**(Plan 3 全程未碰——ADR-010;查询起点:entry_locales where current_revision_id is not null and published_revision_id is null)。site_config_revisions 首版生成时机建议:随首次 publish 物化(site 行快照进 Render Snapshot 的 site.json 来源),本清单第 2 项消费。 +2. **Render Snapshot 生成**(V2 §9.2 六冻结文件):从 **published 修订** + routes + media + site 配置物化 `site.json / content.json / taxonomies.json / routes.json / assets.json / render-manifest.json`(不可变派生产物,hash 锚定——§10.2 冻结输入)。 +3. **pilot-minimal 主题 + Astro 构建管线**:六类页面按迁移快线第 8.1 节顺序(首页 → 普通页 → 产品列表 → 产品详情 → 新闻列表 → 新闻详情);ThemeManifest 最小契约(V2 §9.1);构建**只读快照**——禁查 PG/禁 Control API/禁读 storage/ 原始输入/禁站点 seed/禁构建期网络(V2 §9.2 五禁)。 +4. **Preview Server**(V2 §12):route_manifest 驱动(§12.1 字段面);只绑 127.0.0.1、只读指定 build 的 Dist+Manifest;301/410/404 按 Manifest 声明;全响应 `X-Robots-Tag: noindex`;路径穿越/目录列表防御。 +5. **最小门禁**(V2 §13 子集):死链(内链命中 route manifest 全量非抽样)/sitemap 双向对照/title-canonical-H1 四恰一/大小写冲突(lowercase collision)。 +6. **导入产物消费接口**:published 修订集合(entry_locales.published_revision_id → content_revisions.data)、routes(route_registry 全表)、**media_assets ready 集合**(status='ready'——quarantined/unsupported 排除语义:其路由行是旧 URL 空间台账,构建/产物侧不复制文件);`storage/incoming/` 副本路径保留(WXR 副本 + 媒体副本 2625 文件/164.53 MiB + 侧车,含 storage/snapshots content-addressed 双份共约 345MB——见 `docs/evidence/phase3-import.md` §6)供构建期媒体复制。 +7. **续传携带**:Task 0.2 cms 数据备份(用户暂缓——**阶段 6 L3 验收前必须补做**,Plan 2 清单第 6 项责任链延续);836 条 attachment 301 的 nginx conf 输出(V2 §14 route compile)——Plan 4 或 Plan 5 定,数据已齐(route_registry redirect 行含 location)。 +8. **度量基线**(Plan 4 构建耗时对照):导入首跑 51.8s / 二导 20.3s(逐事件串行分派的交互式往返口径——提速方向批量锚点解析,见 phase3-import.md §5);内存峰值 304.3 MiB(进程集上界口径);登记 media_bundle 19.0s(2625 文件哈希+副本)。