diff --git a/docs/superpowers/specs/2026-10-20-siteforge-vertical-slice-design-v2.md b/docs/superpowers/specs/2026-10-20-siteforge-vertical-slice-design-v2.md new file mode 100644 index 0000000..f45d98e --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-siteforge-vertical-slice-design-v2.md @@ -0,0 +1,1539 @@ +# SiteForge 最小纵向切片设计与实施计划 V2.0 + +> 日期:2026-10-20 +> 状态:已确认(2026-10-20 用户采纳为设计基线,附四项修订:①资产移植白名单列明 ②Tag/归档页默认 301 ③ZIP 输入移出切片 ④?p= 查询路由降级为登记不实现) +> 上位文档:`SiteForge_完整项目计划方案_V3.0.md` +> 现状依据:`cms现状与SiteForge差距分析_V2.0.md` +> 复用依据:`SiteForge_现有CMS项目复用评估_V1.0.md` +> 被替代文档:`2026-10-20-siteforge-vertical-slice-design.md` +> 验收目标:L3——使用真实试点数据完成本地全链路验证;不把本切片认定为生产上线完成。 + +--- + +## 1. 文档定位 + +SiteForge 是多站点静态迁移、内容维护、静态构建和发布平台。本计划定义第一个最小纵向切片,用 hesupipe.com 的 WordPress WXR 和媒体副本打通一条可重复、可审计、可继续演进的正式主链路: + +```text +授权与输入登记 +→ 原始快照 +→ WXR 流式解析 +→ 内容/分类/媒体标准化 +→ 草稿修订 +→ URL 注册表 +→ 显式发布修订 +→ 冻结构建输入 +→ 极简主题静态构建 +→ 全量门禁 +→ 基于同一 Manifest 的本地预览 +``` + +本切片的目标不是尽快拼出一个演示站,而是建立后续后台、主题、差量迁移、部署和回滚都能继续使用的领域模型与契约。 + +### 1.1 权威关系 + +1. `SiteForge_完整项目计划方案_V3.0.md` 是产品范围和总体架构的唯一基线。 +2. 本文是第一个纵向切片的设计与实施基线。 +3. `cms现状与SiteForge差距分析_V2.0.md` 说明旧代码的真实成熟度和风险。 +4. 旧 cms 文档和旧切片 V1 只作为历史参考,不覆盖本文。 + +### 1.2 前提约束 + +- `D:\project\cms` 保持只读,不在其中实施新架构。 +- **资产移植白名单(路线 C,列明如下)**——可移植:WXR 解析规则与映射经验(重写到流式解析器之上)、测试夹具(脱敏后)、门禁规则逻辑、docker-compose/CI 配置;旧 cms 的 Astro 页面仅作视觉参考,不移植代码。不继承、按本设计重写:认证、BuildService、release/rollback、MigrationService 编排(迁移即发布)、settings JSONB 路由、Lead 服务、后台业务页面、整页硬编码的前台架构。 +- 真实试点数据必须获得使用授权,只复制完成切片所需的 WXR、媒体和 Sitemap。 +- 真实数据、媒体、数据库、凭据、完整报告不进入 Git。 +- 测试夹具必须脱敏、最小化并可公开给项目开发者。 +- 本切片只启动本地 CLI 和绑定回环地址的 Preview Server,不对公网开放控制 API。 +- 所有新 Schema、契约和目录均按可长期演进的方式设计,不使用明确会被下一阶段推翻的临时结构。 + +--- + +## 2. 切片目标、范围与成功标准 + +### 2.1 目标 + +| 编号 | 目标 | 验收口径 | +|---|---|---| +| G1 | WXR 范围内全量导入 | 纳入范围的 post、page、product、taxonomy、term、attachment 全部被创建、更新、跳过或明确报错;不支持对象有统计和原因 | +| G2 | 幂等与不可变修订 | 第二次导入同一 Source Snapshot 产生 0 条新修订;内容变化产生新修订,不 UPDATE 历史修订 | +| G3 | 导入不发布 | 首次导入只更新 current revision;只有显式 publish 才更新 published revision | +| G4 | URL 注册表 | WXR + 本地 Sitemap 构成的“当前已知 URL 基线”全部有明确处置;未处理 🔴=0 | +| G5 | 路径和媒体保真 | 可保真的内容、分类、分页和媒体按旧路径输出;不能保真的路径使用明确 Redirect/Gone/Unresolved 状态 | +| G6 | 可复现静态构建 | 构建只读取冻结快照;相同输入连续构建的业务文件 Hash 一致;禁止 Seed 和在线数据库读取 | +| G7 | 最小主题契约 | pilot-minimal 使用正式 Theme Manifest、Render Snapshot 和公共基础组件,不复制旧 cms 的整页硬编码架构 | +| G8 | 自动门禁 | 全量检查路由、SEO、死链、媒体、Sitemap、重定向图、路径冲突和远程依赖 | +| G9 | 本地 HTTP 预览 | Preview 只读取该 Build 的 Route Manifest 和 Dist,正确响应 HTML、Asset、301、410 和 404 | +| G10 | 可审计证据 | Import、Publish、Build、Validation 全部持久化状态;生成 JSON/HTML 报告和脱敏验收摘要 | + +### 2.2 本切片支持的 WordPress 范围 + +必须支持: + +- `post` → `news_article` +- `page` → `basic_page` +- `product` CPT → `product` +- 分类、标签、产品分类、产品标签及内容关系 +- Attachment 元数据、特色图片、正文图片、产品图库 +- Yoast 常见 Title、Description 和主分类字段 +- `_thumbnail_id` +- `_product_image_gallery` +- 试点已知 `wppop_sections` 等主题字段 +- `.html`、`.php`、多级目录等原 permalink +- Sitemap 文件中的额外已知 URL + +必须识别但允许进入“不支持清单”: + +- 未配置映射的 CPT +- Elementor/WPBakery 等复杂布局数据 +- 未知短代码 +- 插件私有表中、WXR 不包含的数据 +- 复杂 WooCommerce 变体、库存、订单等非展示数据 +- 评论、用户和敏感个人数据 + +“全量导入”是指本切片范围内的所有对象均有明确结果,不代表已经支持 WordPress 的全部插件生态。 + +### 2.3 非目标 + +以下内容不在本切片交付范围: + +- Art Design Pro 管理后台。 +- ZIP 媒体包解包输入(本切片仅接受已解包目录);查询参数路由(`?p=` 等仅登记不实现)。 +- 公网认证、MFA 和会话体系。 +- WordPress REST、SQL 直连和在线爬虫。 +- 日志、Search Console、GA4 等完整 URL 发现来源。 +- 差量同步、删除检测和人工冲突队列;本切片只建立未来所需的数据位。 +- 第二主题和可视化 Block Editor。 +- Redis/BullMQ;但 Job 状态必须先持久化。 +- Nginx/SSH、OSS/CDN 正式部署。 +- 生产发布锁、CDN 刷新和生产回滚。 +- 公网表单服务。 +- 完整多语言工作流;模型支持 Locale,本切片只验收 `en`。 +- ClickHouse、Event Gateway、客户画像和增长分析。 + +### 2.4 能力边界声明 + +本切片的 URL 基线只来自: + +```text +WXR 可推导 URL +∪ WXR Attachment URL +∪ 本地提供的 Sitemap/Sitemap Index +∪ 人工补充 URL 清单(可选) +``` + +报告必须显示“当前已知基线”,不得使用“发现了全部历史 URL”的表述。日志、GSC、GA4、在线爬虫未接入的事实必须显示在报告首页。 + +--- + +## 3. 技术决策 + +| ADR | 决策 | 本切片选择 | 约束 | +|---|---|---|---| +| ADR-001 | ORM | Drizzle + PostgreSQL 16 | 所有 Schema 通过迁移文件管理;关键约束落数据库 | +| ADR-002 | 服务形态 | NestJS 模块化单体 + Standalone App Context + CLI | 本切片不监听公网端口;服务层未来由 HTTP Controller 复用 | +| ADR-003 | Job 执行 | 进程内 Runner + 数据库 Job 状态 | 不要求 BullMQ,但不能只存内存;进程中断后可识别 stale Job | +| ADR-004 | 原始输入 | Source Connection 与 Source Snapshot 分离 | 每个 WXR/Sitemap/媒体包形成不可变 Snapshot 和 Hash | +| ADR-005 | WXR 解析 | 流式 SAX 解析 | 禁止整文件正则解析;限制字节、节点、深度、字段长度和处理时间 | +| ADR-006 | 内容身份 | Source Record 稳定键 | WordPress ID 是来源身份;Permalink 只是 URL 证据,不作为稳定主键 | +| ADR-007 | URL 模型 | Exact Route Key 与 Collision Key 分离 | 实际路由不大小写折叠、不去尾斜杠;碰撞键只用于风险检查 | +| ADR-008 | 媒体 | 本地 Content-Addressed Storage | 本切片不用 MinIO;对象存储阶段可更换 Storage Adapter | +| ADR-009 | HTML 安全 | 白名单清洗,原文保留在 Source Snapshot | 清洗后内容进入 Revision;差异写报告 | +| ADR-010 | 发布模型 | current/published 双指针 | 导入不改变 published pointer;发布不修改 Revision | +| ADR-011 | 构建 | 冻结 Render Snapshot | Astro 禁止查数据库、禁止在线拉内容、禁止 Seed fallback | +| ADR-012 | 主题 | 最小 Theme Manifest + Render Contract | 完整 Block 协议后置,但第一主题不得依赖站点专属数据导入代码 | +| ADR-013 | 预览 | Artifact 自包含 | Preview 读取同一 Build 的 Route Manifest,不读取实时数据库 | + +运行环境: + +- Node.js 22 LTS +- pnpm Workspace +- TypeScript Strict +- PostgreSQL 16 +- Astro +- NestJS +- Drizzle ORM +- Vitest +- HTML Parser/Sanitizer +- SAX XML Parser + +依赖版本必须写入 Lockfile。使用新第三方库前检查许可证和维护状态。 + +--- + +## 4. 仓库结构 + +```text +cms-one/ + apps/ + api/ # NestJS 模块化单体;本切片由 CLI 创建 standalone context + packages/ + contracts/ # Source/Content/Route/Render/Report DTO + adapter-wordpress/ # WXR 流式解析与映射 + route-engine/ # URL 建模、冲突检测、Route Manifest + validation-engine/ # Gate 规则与报告 + theme-runtime/ # Render Snapshot 读取和公共渲染约定 + ui-primitives/ # 最小无业务耦合 Astro 组件 + themes/ + pilot-minimal/ + theme.manifest.ts + src/layouts/ + src/components/ + src/pages/ + src/styles/ + scripts/ + register-source.ts + import.ts + publish.ts + build.ts + gate.ts + preview.ts + verify.ts + infra/ + docker-compose.yml # 本切片仅 PostgreSQL + database/ + storage/ # 全部 Git Ignore + snapshots/ + media/ + quarantine/ + sitemap/ + artifacts/ # 全部 Git Ignore;本地完整证据 + jobs// + input/ + dist/ + manifests/ + reports/ + logs/ + tests/ + fixtures/ # 脱敏最小夹具,可提交 + contract/ + unit/ + integration/ + acceptance/ + docs/ + adr/ + specs/ + runbooks/ + evidence/ # 可提交的脱敏验收摘要 +``` + +暂不移动根目录现有规划文档,避免在实现启动时同时制造链接和历史迁移噪音。文档整理另开独立提交。 + +`.gitignore` 已经存在,实施开始时只需验证以下路径确实被忽略: + +- `storage/` +- `artifacts/` +- `.env*` +- `node_modules/` +- `dist/` +- `coverage/` +- 数据库本地卷 + +--- + +## 5. 核心领域模型 + +### 5.1 站点与配置 + +#### sites + +```text +id uuid PK +key text UNIQUE +name text +default_locale text +production_origin text +created_at timestamptz +updated_at timestamptz +``` + +`production_origin` 必须是明确的 `https://host[:port]`,用于 Canonical、OG 和 Sitemap。路径不写入该字段。 + +#### site_config_revisions + +```text +id uuid PK +site_id FK +version integer +data jsonb +checksum text +created_at timestamptz +UNIQUE(site_id, version) +``` + +第一版配置至少包含: + +- 公司名称和站点名称。 +- 首页路径和主要列表路径。 +- Logo、联系方式和 Footer 信息。 +- 默认 SEO 信息。 +- 主导航配置;如果 WXR 中存在可用菜单,可以导入后生成配置修订。 + +### 5.2 来源、快照与导入 + +#### source_connections + +```text +id uuid PK +site_id FK +kind enum(wxr) +name text +configuration jsonb +created_at timestamptz +``` + +Connection 只描述来源,不保存某次文件路径和 Hash。 + +#### source_snapshots + +```text +id uuid PK +site_id FK +source_connection_id FK +kind enum(wxr, sitemap, media_bundle, manual_url_list) +storage_path text +sha256 text +byte_size bigint +adapter_version text +captured_at timestamptz +metadata jsonb +created_at timestamptz +UNIQUE(site_id, kind, sha256) +``` + +Snapshot 创建后不可变。替换文件必须创建新 Snapshot。 + +#### import_jobs + +```text +id uuid PK +site_id FK +source_snapshot_id FK +status enum(queued, running, succeeded, succeeded_with_issues, failed, cancelled, stale) +mapping_version text +idempotency_key text UNIQUE +attempt integer +started_at / finished_at +error_code / error_summary +report_path text +created_at timestamptz +``` + +#### source_records + +```text +id uuid PK +site_id FK +source_connection_id FK +source_snapshot_id FK +import_job_id FK +source_type text +source_id text +locale text +source_url text +raw_location text +source_checksum text +normalized_checksum text +parse_status text +content_entry_id uuid nullable +UNIQUE(site_id, source_connection_id, source_type, source_id, locale, source_snapshot_id) +``` + +跨 Snapshot 的稳定映射键为: + +```text +site_id + source_connection_id + source_type + source_id + locale +``` + +### 5.3 内容与修订 + +#### content_entries + +```text +id uuid PK +site_id FK +type_key text +lifecycle_status enum(active, archived) +created_at / updated_at +INDEX(site_id, type_key) +``` + +不使用 Permalink 作为 Content Entry 的唯一身份。 + +#### entry_locales + +```text +id uuid PK +site_id FK +content_entry_id FK +locale text +slug text +current_revision_id uuid nullable +published_revision_id uuid nullable +created_at / updated_at +UNIQUE(site_id, content_entry_id, locale) +``` + +Slug 是否唯一由 Route Registry 决定,不单独假设所有内容类型共享同一 Slug 空间。 + +#### content_revisions + +```text +id uuid PK +site_id FK +content_entry_id FK +locale text +schema_version integer +data jsonb +checksum text +source_kind enum(import, manual, restore) +source_record_id uuid nullable +import_job_id uuid nullable +overridden_fields jsonb default [] +created_by text +created_at timestamptz +``` + +约束: + +- 只 INSERT,不提供 UPDATE/DELETE 业务接口。 +- 数据库权限或 Trigger 阻止对既有 Revision 的更新。 +- `current_revision_id` 指向最新工作修订。 +- `published_revision_id` 指向公开修订。 +- 两个指针不同表示“已发布但有未发布修改”。 +- 构建只读取 `published_revision_id`。 + +### 5.4 分类和关系 + +#### taxonomies + +```text +id uuid PK +site_id FK +key text +object_type text +public_base_path text +UNIQUE(site_id, key, object_type) +``` + +#### terms + +```text +id uuid PK +site_id FK +taxonomy_id FK +source_id text +slug text +name text +parent_term_id uuid nullable +data jsonb +UNIQUE(site_id, taxonomy_id, source_id) +``` + +#### content_terms + +```text +site_id FK +content_entry_id FK +term_id FK +is_primary boolean +PRIMARY KEY(site_id, content_entry_id, term_id) +``` + +这些表支撑分类页、标签页、产品分类、分页和 Yoast 主分类,不允许临时塞进内容 JSON 后再重构。 + +### 5.5 媒体与使用关系 + +#### media_assets + +```text +id uuid PK +site_id FK +source_connection_id FK +source_id text nullable +source_url text +source_path text +storage_path text +public_path text +sha256 text +detected_mime text +declared_mime text nullable +byte_size bigint +width / height integer nullable +alt_text text nullable +status enum(ready, missing, quarantined, unsupported) +created_at timestamptz +UNIQUE(site_id, source_url) +UNIQUE(site_id, public_path) +``` + +#### media_usages + +```text +site_id FK +media_asset_id FK +content_revision_id FK +field_path text +source_url text +PRIMARY KEY(media_asset_id, content_revision_id, field_path) +``` + +媒体匹配顺序: + +1. 完整旧 URL。 +2. `wp-content/uploads` 后的完整相对路径。 +3. 编码修复后的完整相对路径。 +4. 仅文件名模糊匹配只能生成候选,不得自动选中;多个候选进入冲突报告。 + +### 5.6 路由注册表 + +#### route_registry + +```text +id uuid PK +site_id FK +host text +source_path_raw text +route_path text +exact_route_key text +collision_key text +query_policy enum(ignore, exact, preserve, dynamic) +query_key text nullable +source_status text +target_status text +route_kind enum(html, asset, redirect, gone, dynamic, unresolved) +target_content_id uuid nullable +target_file text nullable +redirect_status integer nullable +redirect_location text nullable +indexable boolean +priority integer +source_snapshot_id uuid nullable +verification_status text +created_at / updated_at +UNIQUE(site_id, host, exact_route_key, query_key) +``` + +规则: + +- `source_path_raw` 永远保存来源原文。 +- `route_path` 是经过安全校验的实际公开路径。 +- `exact_route_key` 用于请求匹配,保留大小写和尾斜杠语义。 +- `collision_key` 仅用于检测 Windows/Linux、大小写、Unicode NFC 和尾斜杠风险,不用于实际路由。 +- Percent Encoding 只解码 RFC 非保留字符;`%2F`、`%5C` 等保留编码不改写。 +- 拒绝 NUL、反斜杠、路径穿越、控制字符、Windows 保留设备名、尾部空格/点和超长路径。 +- Fragment 不属于服务端路由,不写入 Route Key。 +- 查询路由单独建模,不与 Path 静默合并。 + +### 5.7 Job、构建和验收 + +#### build_jobs + +```text +id uuid PK +site_id FK +status enum(queued, snapshotting, building, validating, succeeded, failed, cancelled, stale) +idempotency_key text UNIQUE +attempt integer +worker_id text nullable +started_at / heartbeat_at / finished_at +error_code / error_summary +created_at timestamptz +``` + +#### build_input_snapshots + +```text +id uuid PK +build_job_id FK UNIQUE +site_id FK +content_snapshot_hash text +route_snapshot_hash text +asset_manifest_hash text +site_config_revision_id FK +theme_key text +theme_version text +theme_source_hash text +dependency_lock_hash text +source_commit text +node_version text +pnpm_version text +astro_version text +locale text +timezone text +build_timestamp timestamptz +manifest_path text +created_at timestamptz +``` + +#### build_artifacts + +```text +id uuid PK +build_job_id FK +kind enum(dist, manifest, report, log) +storage_path text +sha256 text +byte_size bigint +created_at timestamptz +UNIQUE(build_job_id, kind, storage_path) +``` + +#### validation_runs / validation_issues + +保存 Gate 的规则版本、开始/结束时间、结果和每条问题的严重级别、URL、证据、修复状态。 + +### 5.8 审计日志 + +`audit_logs` 为追加式,记录: + +- site_id +- actor_type:system、cli_user +- actor_id/actor_label +- command_id +- action +- object_type/object_id +- before/after 摘要 +- occurred_at + +CLI 必须接受或生成明确 Actor,不使用模糊的固定 `system/importer` 代替所有操作。 + +--- + +## 6. URL 与页面生成策略 + +### 6.1 路径处置 + +| 来源 URL | 切片处置 | Route Kind | +|---|---|---| +| 内容 Permalink | 尽量按原路径输出 HTML | html | +| 首页 | 使用站点配置指定路径 `/` | html | +| 产品/新闻主列表 | 使用站点配置的旧路径,生成分页 | html | +| 产品分类/文章分类 | 根据 Taxonomy/Term 生成列表和分页 | html | +| Tag/Product Tag | 默认 301 到对应主列表(本切片不生成 Tag 页;生成能力留待 SP3 后按需评估) | redirect | +| 日期/作者归档 | 默认 301 到新闻主列表,逐条登记 | redirect | +| Attachment 页面 | 按 Sitemap/WXR 状态决定 HTML、Redirect 或 Gone | html/redirect/gone | +| 上传媒体路径 | 按旧 `wp-content/uploads/...` 输出 | asset | +| 无法保真的内容路径 | 人工映射;不得默认跳首页 | redirect/unresolved | +| 已确认永久删除 | 410 | gone | +| `?p=ID`、`?page_id=ID` | 登记为 `excluded_with_reason`(报告单列记录在案);查询路由能力留待 SP3 实现 | —(登记不实现) | + +### 6.2 当前已知 URL 基线 + +每条来源 URL 必须归入: + +```text +verified_html +verified_asset +verified_redirect +verified_gone +unresolved +excluded_with_reason +unknown_source_gap +``` + +Gate 要求 `unresolved=0`。`unknown_source_gap` 不伪装成成功,必须在报告首页显示,但因本切片明确未接日志/GSC/爬虫,不单独阻止 L3 验收。 + +### 6.3 Redirect 约束 + +- Redirect 状态码只能是批准的 301/302;本切片默认 301。 +- Location 必须可解析。 +- 目标必须落到 HTML、Asset、Gone 或批准的外部 URL。 +- Redirect 环直接失败。 +- 任意多跳大于 1 均告警;超过配置上限直接失败。 +- 禁止把无法映射的路径批量跳转到首页。 + +### 6.4 静态文件映射 + +示例: + +```text +/about-us/ → dist/about-us/index.html +/news/a.html → dist/news/a.html +/legacy/page.php → dist/legacy/page.php 或由 Route Manifest 映射 +/wp-content/uploads/2024/a.jpg + → dist/wp-content/uploads/2024/a.jpg +``` + +所有文件映射必须经过安全 Join,最终绝对路径必须位于该 Build 的 Dist 根目录内。 + +--- + +## 7. WXR 导入管道 + +### 7.1 输入登记 + +```bash +pnpm sf source:register \ + --site hesupipe \ + --wxr storage/incoming/hesupipe.xml \ + --media storage/incoming/uploads \ + --sitemap storage/incoming/sitemap.xml +``` + +登记过程: + +1. 验证文件位于允许目录。 +2. 计算 SHA-256、字节数和媒体清单。 +3. 将输入复制到 Content-Addressed Snapshot 目录。 +4. 创建 `source_snapshots`。 +5. 原输入之后不再被任务直接读取。 + +本切片媒体输入**只接受已解包目录**(自 cms 只读复制 `storage/media/` 下的站点目录)。ZIP 包输入及其防护(Zip Slip、绝对路径、符号链接逃逸、压缩炸弹、数量/单文件/总解压限额、文件名碰撞)**移出本切片**,待后续支持 ZIP 上传的阶段实现。 + +### 7.2 流式解析 + +解析器限制通过配置明确: + +- 最大 WXR 字节数。 +- 最大 Item 数。 +- 最大 XML 深度。 +- 最大单字段和 CDATA 长度。 +- 最大 Term、Meta、Attachment 数。 +- 总处理时限。 +- PHP Serialized 数据最大深度和元素数。 + +解析器不得解析外部实体或访问网络。 + +### 7.3 UniversalRow + +最低契约: + +```ts +interface UniversalRow { + sourceType: string + sourceId: string + status: string + locale: string + sourceUrl?: string + slug?: string + title?: string + excerpt?: string + bodyHtml?: string + publishedAt?: string + modifiedAt?: string + taxonomyRefs: Array<{ taxonomy: string; sourceTermId: string }> + attachmentRefs: Array<{ role: string; sourceId?: string; sourceUrl?: string }> + seo: Record + knownFields: Record + extras: Record + rawLocation: string +} +``` + +契约版本变更必须提升 Schema Version 并保留转换测试。 + +### 7.4 HTML 清洗 + +处理顺序: + +```text +原始 HTML 保留在 Source Snapshot +→ WordPress Shortcode/主题字段适配 +→ URL 和媒体引用提取 +→ HTML 白名单清洗 +→ 链接/媒体路径重写 +→ 生成 Revision Data +``` + +默认删除或限制: + +- script、object、embed。 +- 事件属性。 +- `javascript:`、危险 Data URL。 +- 未批准 iframe。 +- 外部追踪像素。 +- 未知 Form。 + +清洗差异必须按内容记录到 Import Report。 + +### 7.5 Checksum 与幂等 + +Normalized Checksum 输入: + +```text +canonical_json({ + universal_row_version, + adapter_version, + mapping_version, + sanitizer_version, + locale, + mapped_content, + taxonomy_relations, + media_reference_identities, + route_source_values +}) +``` + +规则: + +- JSON Key 稳定排序。 +- 时间统一 UTC ISO 8601。 +- 不包含 Import Job ID、处理时间等非业务字段。 +- 相同 Snapshot 重跑不得产生新 Revision。 +- 相同 Source Record 在新 Snapshot 中无业务变化时同样 Skip。 +- 适配器、映射或清洗版本变化可显式触发重新标准化。 + +### 7.6 事务和失败 + +- Job 创建和 Snapshot 绑定在事务中完成。 +- 每个 Source Record 的内容、Revision、分类关系和路由写入使用数据库事务。 +- 单条失败不回滚整个 Import,但 Job 最终为 `succeeded_with_issues`。 +- 结构损坏、Snapshot Hash 不符或数据库约束错误可使整个 Job 失败。 +- 重跑使用新的 Import Job,但复用已有 Snapshot。 +- 不支持的对象必须计数并输出原因,不得静默跳过。 + +### 7.7 导入报告 + +报告至少包含: + +- Snapshot 和 Adapter/Mapping/Sanitizer 版本。 +- 按 WP 类型和状态统计的输入基数。 +- Created/Updated/Skipped/Failed/Unsupported。 +- 内容修订数量。 +- Taxonomy/Term/关系数量。 +- 媒体 Ready/Missing/Quarantined/Unsupported/Ambiguous。 +- HTML 清洗统计。 +- URL Created/Conflict/Unresolved。 +- 行级错误和 Raw Location。 +- 未覆盖来源声明。 + +--- + +## 8. 显式发布模型 + +### 8.1 发布语义 + +导入只更新 `entry_locales.current_revision_id`。如果内容从未发布,`published_revision_id` 保持 NULL;如果已经发布过,旧 Published Revision 继续对构建可见。 + +发布只执行: + +```text +published_revision_id = current_revision_id +``` + +不得 UPDATE `content_revisions`。 + +### 8.2 CLI 安全要求 + +```bash +pnpm sf publish \ + --site hesupipe \ + --import-job \ + --actor