docs(plan): Plan 4 修复评审三 Issue 与建议——编码路径贯穿规则(54 条真实数据实测)、发布 actor/P0 门禁决策、无网证明测试、route-manifest 认领、URL 重写匹配键、ADR-010 双修订测试、分页统一不做、410/CSP/目录列表、redirect 全量与桌面截图

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 22:46:30 +08:00
parent 171137bc8e
commit 63056d33cf
@@ -20,6 +20,8 @@
4. **Render Snapshot 六文件**(contracts 冻结):site/content/taxonomies/routes/assets/render-manifest.json——从 published 修订 + route_registry + media ready 集合物化,canonical JSON + 总哈希(build_input_snapshots 表记录)。
5. 既有纪律全套:TDD/临时库/四段 typecheck/身份断言/cms 只读/artifacts 与 storage 不入库。
6. 版本纪律:新依赖 Astro 锁 minor;许可证 MIT 记 ADR-018。
7. **路径形态事实(评审实测)**:真实 hesupipe 有 **54 条百分号编码中文 permalink**(如 `/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/`,多为 page/news)且 **0 条 .html permalink**(该规则仅夹具用)。**编码路径贯穿规则钉死**:routes.json→dist 文件路径→route-manifest→preview 匹配**全程编码原样(字节精确)**,V1 不做解码归一(Windows/NTFS '%' 合法;解码归一属 SP3 验证器)。`.html` 规则保留但标注"仅夹具防御"。
8. **发布补充钉死**:① `--actor` 参数(CLI 必传或缺省 `super_admin@cli`,audit_logs.actor_label NOT NULL——本计划是全仓第一个审计写入方);② **P0 门禁决策**:V1 publish **不读报告文件做 P0 阻断**(ADR-018 记偏离——import issue 无 DB 查询源,206 条真实现状下严格门禁会反堵发布;missing 媒体等已在 build/gate 层有真实防线),Plan 5 SEO diff 时重审。
## 仓库结构增量
@@ -43,14 +45,14 @@ scripts/sf.ts # + content:publish / build:create / preview:star
**Files:** `apps/api/src/modules/publish/{publish.service.ts,publish.module.ts}`;sf.ts + `content:publish`;Test `tests/integration/publish.test.ts`
- [ ] 失败测试(临时库,mini 全链 register→import 后):① dry-run:列出 current 非空且 published≠current 的 192 条(mini 3 条)清单(typeKey/slug/修订对)+ 汇总计数,零写入;② confirm(token 从 dry-run 输出取):published_revision_id=current、audit_logs 行(action='content.publish' 批量摘要含条数)、重复发布幂等(已等则 skip);③ site_config_revisions 首版:首次 publish 时若站点无 config 修订 → 从 site 行生成 v1(name/origin/locale 空品牌占位——data 结构按后续主题消费定义:{name, origin, locale, brand:{}});④ 未导入条目不受影响
- [ ] 实现:`publishSiteContent(db,{siteKey, dryRun, confirmToken})`——查询起点 `entry_locales.current_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id != current_revision_id)`(事务批量 UPDATE);dry-run token=随机;五列锚点共享 helper 顺手抽 `apps/api/src/modules/import/anchors.ts`(三写入器改 import——零行为变更由既有测试守护)
- [ ] 实现:`publishSiteContent(db,{siteKey, dryRun, confirmToken, actor})`——查询起点 `entry_locales.current_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id != current_revision_id)`(事务批量 UPDATE);dry-run token=随机;**actor 按 --actor 传入、缺省 super_admin@cli**(前置事实 8);P0 门禁按前置事实 8 决策不做(ADR-018 记偏离);五列锚点共享 helper 顺手抽 `apps/api/src/modules/import/anchors.ts`(三写入器改 import——零行为变更由既有测试守护)
- [ ] 绿 → commit `feat(publish): 显式发布命令(dry-run/confirm/审计/站点配置首版)`
### Task 2: Render Snapshot 物化 + build job
**Files:** `apps/api/src/modules/build/{build.service.ts,render-snapshot.ts,build.module.ts}`;sf.ts + `build:create`;Test `tests/integration/build-snapshot.test.ts`
- [ ] 失败测试(临时库,publish 后):① build_jobs 行(queued→snapshotting→building→…本任务到 snapshotting 完成即 succeeded 分支留 Task 6 接——**简化**:本任务状态机到 `snapshotting→validating(空)→succeeded`,dist 尚空,Task 6 换真实构建)+ build_input_snapshots 行(三哈希/site_config_revision_id/theme_key='pilot-minimal');② 六文件物化:site.json(config v1)/content.json(192 published 修订——**bodyHtml URL 重写**:旧绝对 URL→根相对 public_path,映射源=media ready 集合;未命中的绝对 URL 保留原样并计 issue)/taxonomies.json(927 term+taxonomy)/routes.json(html+asset 行,redirect 并入)/assets.json(836 ready:public_path→storage 实体路径+sha256)/render-manifest.json(版本块+总数+源哈希);③ canonical JSON 确定性(键排序);④ 未 publish 站点 → 明确报错 NOT_PUBLISHED
- [ ] 失败测试(临时库,publish 后):① build_jobs 行(queued→snapshotting→**validating(空)→succeeded**——dist 尚空;Task 6 接真实构建时**新建 build job**而非改行——build_input_snapshots 有不可变触发器)+ build_input_snapshots 行(三哈希/site_config_revision_id/theme_key='pilot-minimal');② 六文件物化:site.json(config v1)/content.json(published 修订——**bodyHtml URL 重写钉死匹配键**:URL 剥 host 后按 path 与 media ready 集合的 public_path/source_url 路径段比对(覆盖绝对/根相对/协议相对变体),命中→根相对 public_path;未命中保留原样+issue)/taxonomies.json(term+taxonomy)/routes.json(html+asset 行,redirect 并入)/assets.json(ready:public_path→storage 实体路径+sha256)/render-manifest.json(版本块+总数+源哈希);③ canonical JSON 确定性(键排序);④ 未 publish 站点 → NOT_PUBLISHED;⑤ **ADR-010 双修订测试**:发布 A→导入改内容 current=B→快照 content.json 仍为 A(published 冻结语义,spec §15 阶段 4 验收明文)
- [ ] 实现:一致性读取(单事务 repeatable read 或快照时点标记——简化:顺序读取+记录行数基线,差异检查);媒体实体路径解析用 register 快照 metadata 的 storageRoot+storage_path(`storage/snapshots/media_bundle/…` 已存在的 content-addressed 副本)+ incoming 副本兜底
- [ ] 绿 → commit `feat(build): 冻结 Render Snapshot 物化与 build job`
@@ -66,15 +68,15 @@ scripts/sf.ts # + content:publish / build:create / preview:star
**Files:** `themes/pilot-minimal/src/pages/[...slug].astro` + 模板分派;Test `themes/pilot-minimal/test/render-cases.test.ts`(Astro 构建级测试:六类夹具数据 → 构建产物断言)
- [ ] 失败测试(六夹具页 = 真实 hesupipe 抽样数据的脱敏化形状——home(/)、generic page(/products/ 内容页)、product-category 列表、product 详情、news 列表 /news/、news 详情 .html):构建产物存在且含关键内容(页面 title/slug 渲染进 HTML、产品参数/新闻正文存在、图片标签 src=根相对);404.html 存在
- [ ] 实现:getStaticPaths 读 SF_BUILD_INPUT 的 routes.json(html 行)+ 按内容类型/路径特征分派模板:`/`→home;`/product-category/*` 或 `/category/*`→列表(taxonomy.json 查 term+content.json 过滤关联内容);内容页按 typeKey(product→详情含参数表+相册、news_article→正文+日期、basic_page→富文本);/news/ 特判 news-list。分页:taxonomy 列表超 20 条时 …/page/N/(快线允许简化——本切片 471 post_tag 无页面、product_cat 最大分类条数实测后定:**若单分类>50 再做分页,否则 V1 不做**,JSDoc 记录)
- [ ] 失败测试(六夹具页 = 真实 hesupipe 抽样数据的脱敏化形状——home(/)、generic page(**/products/ 内容页 + 一条编码中文路径页**如 `/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/`)、product-category 列表、product 详情、news 列表 /news/、news 详情 .html(仅夹具防御——真实 0 条)):构建产物存在且含关键内容(title/slug 渲染进 HTML、产品参数/新闻正文、图片 src=根相对;**编码路径产物按编码原样落盘**);404.html 存在
- [ ] 实现:getStaticPaths 读 SF_BUILD_INPUT 的 routes.json(html 行)+ 按内容类型/路径特征分派模板:`/`→home;`/product-category/*` 或 `/category/*`→列表(taxonomy.json 查 term+content.json 过滤关联内容);内容页按 typeKey(product→详情含参数表+相册、news_article→正文+日期、basic_page→富文本);/news/ 特判 news-list。分页:**V1 一律不做**(/news/ 129 条单页全列 + taxonomy 列表同——分页路由不在 routes.json 会自破门禁①死链;触发条件与路由登记义务一并记 ADR-018 移交 Plan 5)
- [ ] 绿 → commit `feat(theme): 六类页面渲染与路由分派`
### Task 5: 构建管线(astro 构建 + 媒体落盘 + sitemap/robots)
**Files:** `build.service.ts` 扩展(spawn astro build→媒体复制→sitemap→产物登记);Test `tests/integration/build-pipeline.test.ts`
- [ ] 失败测试(小夹具全链):① spawn `pnpm --filter pilot-minimal build`(SF_BUILD_INPUT 指输入目录)→ dist/ 页面按 routes.json 的 html 行路径落盘(route_path→文件映射:尾斜杠→index.html;.html→原样);② 媒体复制:assets.json 逐条复制到 dist 的 public_path(跳过 quarantined——build 输入本就只有 ready);复制校验 sha256 抽检;③ sitemap.xml(origin+indexable html 行)+ robots.txt(Allow / + Sitemap 行 + 不含 noindex 页);④ build_artifacts 行(dist/manifest 各 kind+哈希);⑤ 构建环境五禁断言:SF_BUILD_INPUT 之外零数据源(loader 单一入口 grep 守护 + 构建期数据库连接断开测试——DATABASE_URL 置空跑构建仍成功)
- [ ] 失败测试(小夹具全链):① spawn `pnpm --filter pilot-minimal build`(SF_BUILD_INPUT 指输入目录)→ dist/ 页面按 routes.json 的 html 行路径落盘——**映射规则钉死**:尾斜杠→`<path>index.html`;`.html` 后缀→字面文件(仅夹具);**百分号编码路径→编码原样目录结构**(Astro 产物若不符则构建后按 manifest 重命名/移动对齐——manifest 的 target_file 是唯一权威);② 媒体复制:assets.json 逐条复制到 dist 的 public_path(跳过 quarantined);sha256 抽检;③ sitemap.xml(origin+indexable html 行,**编码 URL 原样进 sitemap**)+ robots.txt(Allow / + Sitemap 行);④ **route-manifest.json 生成与字段面断言**(spec §12.1:manifest_version/site_id/build_job_id/origin/generated_from_route_snapshot_hash/routes[] 含 exact_key/query_policy/kind/target_file/status/location/content_type/indexable——含 redirect 行与 ?attachment_id= 查询形态)+ build_artifacts 行(dist/manifest 各 kind+哈希);⑤ 构建环境五禁断言:SF_BUILD_INPUT 之外零数据源(loader 单一入口 grep + **DATABASE_URL 置空跑构建仍成功** + **无网证明**:构建进程内 stub global fetch 抛错仍成功——spec §10.4 明文要求)
- [ ] 实现:build.service 编排(快照→astro→媒体→sitemap→登记);产物落 `artifacts/jobs/<buildJobId>/{input,dist,manifests}`
- [ ] 绿 → commit `feat(build): astro 构建管线与媒体落盘`
@@ -83,14 +85,14 @@ scripts/sf.ts # + content:publish / build:create / preview:star
**Files:** `apps/api/src/modules/build/gates.ts`;sf.ts + `build:validate`;Test `tests/integration/build-gates.test.ts`
- [ ] 失败测试(夹具产物注入错误逐项触发):① 内部死链:dist 内 a[href]/img[src] 内部目标全命中(页面或 assets)——注入坏链 → fail;② sitemap 一致:sitemap 条目=实际 indexable html 集;③ SEO 基线:每 html 有 title/meta description/canonical(指向 origin+自身路径)/恰一 H1;④ 大小写冲突:dist 路径 lowercase 无重复;⑤ 产物完整性:route-manifest 的 target_file 全存在。全过 → validation_runs 行 PASS
- [ ] 实现:门禁引擎(纯函数读 dist+manifests → issues 按 P0/P2 分级——P0 阻断 P2 报告);build 状态机补 validating→succeeded/failed
- [ ] 实现:门禁引擎(纯函数读 dist+manifests → issues 按 P0/P2 分级——P0 阻断 P2 报告;**HTML 解析用 parser 复用清洗管线,禁纯正则** spec §13.4);build 状态机补 validating→succeeded/failed;**报告落盘 HTML 先写**(Plan 3 窄窗口顺手修)
- [ ] 绿 → commit `feat(build): 最小门禁与状态机闭环`
### Task 7: Preview Server
**Files:** `apps/preview-server/{server.mjs,package.json}`;sf.ts + `preview:start`;Test `tests/integration/preview-server.test.ts`
- [ ] 失败测试(真实 http 请求小夹具构建产物):① html 行 200+Content-Type;② redirect 行 301+Location(?attachment_id= 查询形态匹配);③ asset 行 200+正确 MIME(png/jpg/webp/pdf);④ 未命中 404+自定义页;⑤ 全响应 `X-Robots-Tag: noindex`;⑥ 绑定 127.0.0.1;⑦ 路径穿越拒绝(../ 编码形式)
- [ ] 失败测试(真实 http 请求小夹具构建产物):① html 行 200+Content-Type;② redirect 行 301+Location(?attachment_id= 查询形态匹配);③ asset 行 200+正确 MIME(png/jpg/webp/pdf);④ 未命中 404+自定义页;⑤ 全响应 `X-Robots-Tag: noindex`;⑥ 绑定 127.0.0.1;⑦ 路径穿越拒绝(../ 与编码形式);⑧ **gone 行 410**(夹具手构——真实数据暂无)+ 目录列表禁用 + HTML 响应基础 CSP 头(self-only)
- [ ] 实现:Node http 服务器读 dist+route-manifest.json(exact_key 匹配+query_key 提取);MIME 表复用 magic-bytes 思路(按扩展名+魔数双确认可选——扩展名够用)
- [ ] 绿 → commit `feat(preview): 路由驱动的本地预览服务器`
@@ -99,7 +101,7 @@ scripts/sf.ts # + content:publish / build:create / preview:star
**Files:** `docs/evidence/phase4-preview.md`;可能 `scripts/e2e-real.mjs`(编排复验)
- [ ] 临时库重建全链:register(副本复用)→ import → **publish(192 条)** → build → validate → preview:start
- [ ] 验收断言(快线 §8.7 第 10 天标准逐条):① 六类页面 HTTP 200 且内容来自真实数据(首页/一个内容页/产品分类列表/产品详情含参数/新闻列表/新闻详情);② 图片本地化抽检 10 张(dist 内存在+页面引用根相对);③ 每页 title/description/canonical 抽检 5 页;④ 404 与 301 行为(?attachment_id= 抽检);⑤ sitemap.xml 条目数=indexable html 数;⑥ 移动端 375px 截图无明显破版(Playwright MCP 截图 3 页存证据);⑦ 全量 URL 冒烟:route-manifest html 行全 200(190 页)+redirect 抽检 50
- [ ] 验收断言(快线 §8.7 第 10 天标准逐条):① 六类页面 HTTP 200 且内容来自真实数据(首页/一个内容页(含一条编码路径页)/产品分类列表/产品详情含参数/新闻列表/新闻详情);② 图片本地化抽检 10 张(dist 内存在+页面引用根相对);③ 每页 title/description/canonical 抽检 5 页;④ 404 与 301 行为(?attachment_id= 抽检);⑤ sitemap.xml 条目数=indexable html 数;⑥ 移动端 375px + **桌面 1440px** 截图无明显破版(Playwright MCP 各 3 页存证据——快线 §8.7 原文双端);⑦ 全量 URL 冒烟:route-manifest html 行全 200(190 页)+ **redirect 全量 1746 逐条 301+Location 校验**(回环零成本)
- [ ] 耗时记录(快照物化/astro 构建/媒体复制/门禁分解——对照 Plan 3 基线 51.8s);差异如实分析。证据文档 + commit `test(evidence): 阶段4端到端预览验收`;临时库 DROP;storage 副本保留
### Task 9: 收尾
@@ -108,6 +110,8 @@ scripts/sf.ts # + content:publish / build:create / preview:star
- [ ] 移交 Plan 5 清单:SEO 三方 diff(source_rendered 抓取需求)/完整门禁(redirect 环/OG/hreflang)/可复现双构建/部署前置(836 nginx conf 输出);续传 Task 0.2 备份
- [ ] commit + push 由用户执行
> 组件清单省略注记:快线 §8.2 的 ProductGrid/NewsList 由模板内联网格实现、TaxonomyList V1 无消费场景(首页不做分类导航)——避免与 §8.2 对照误判遗漏。
## 验收(对照快线 §8.7 + V2 §15 阶段 4 子集)
- [ ] 显式发布可用且导入产物发布前不可见(published 指针语义)