Website Content 是全局公开内容聚合,不带 TenantId。管理 API 负责创建和状态流转,公开 API 只读取 Published;输出缓存通过 publication integration event 跨实例失效。
1. 状态机
Published 与 Archived 不可编辑;Published 必须先 Unpublish 才能归档。Publish 会把 PublishedAt 改为当前时刻。
2. Scheduled 的真实含义
Schedule 只验证未来时间、写 PublishStatus.Scheduled 和 PublishedAt。当前源码没有扫描到期内容的 Job,也没有查询时自动提升状态。
所以 Scheduled 是“已登记计划”,不是“保证自动发布”。GA 需要 owner Job 按可信时钟和租约扫描,到期执行同一发布用例并发出 publication event。
3. 创建与更新
创建先把 Slug trim/lowercase,预查冲突,再经过 sanitizer 创建聚合和应用其他字段。数据库唯一索引 UX_WebsiteContent_Slug 是并发最终防线,Store 把已知冲突映射为 Website.Content.SlugExists。
Slug 创建后不可修改。允许 1..160 位小写 ASCII 字母、数字和非连续连字符;不能首尾为连字符。
// ① Endpoint 不接收 ContentId;Slug 在 Handler 中规范化。var command = new CreateContentCommand( title, slug, body, summary, category, tags, author, coverImage, seoTitle, seoDescription, seoKeywords, canonicalUrl, openGraphImage, sortWeight);
// ② Handler 先净化正文,再让聚合执行长度与 Slug 不变量。Result<ContentManagementDetails> created = await mediator.Send(command, cancellationToken);
// ③ Slug 预查不能替代数据库唯一约束与稳定冲突映射。return created;4. HTML 白名单
Sanitizer 用逐字符 tokenizer,不使用正则解析 HTML。允许排版、链接、图片和表格标签,只保留 href/src/alt/title;URL 属性只允许站内绝对路径或 HTTP(S)。
script、style、iframe、object、embed、form、svg、math、template 会连内容一起删除;事件、style、data/javascript 协议与 // 协议相对 URL 被拒绝。
它不是完整浏览器解析器。GA 仍需用 OWASP corpus、畸形嵌套、实体编码、Unicode 与浏览器差异做回归;CSP 是第二道防线。
5. 字段边界
Body 上限 500,000 字符;标题 200、摘要 500、分类 80、作者 120;最多 20 个标签,每个 50。Tags 去空白并按不区分大小写去重。
CoverImage、CanonicalUrl 与 OpenGraphImage 只做长度/trim,不做 URI scheme、同源、资源所有权或 SSRF 验证。它们是输出元数据风险面,应在 Application 增加 URL policy。
6. 管理授权
内容管理组要求认证。创建、更新、计划、发布、下线、归档使用 website.content.manage;管理详情使用 website.content.read。请求体分别限制为 1 MiB 或 32 KiB,并配置超时。
当前没有内容审批、双人复核、版本历史、草稿预览 token 或编辑并发版本。最后写入获胜,聚合没有 row version。
7. 公开读取
公开详情、列表和 Feed Store 都只选择 Published。列表页码归一化,公开 DTO 包含净化 Body、SEO、结构化数据和发布时间。
Feed 固定最近 20 条,Host 动态构建 Atom XML。Feed 的 link 优先使用 CanonicalUrl,否则 /content/{slug};CanonicalUrl 当前可能指向外域,这是内容配置而非安全导航保证。
8. 输出缓存
公开内容、Feed 和 Sitemap 使用 WebsitePublicContent output-cache policy 与统一 tag。Publish/Unpublish 保存后发布 ContentPublicationChangedIntegrationEvent,API consumer 按 tag 清除。
如果事件发布在状态提交后失败,客户端可能看到失败而内容已经改变;重试 Publish 又会遇到非法状态。GA 要证明 publisher 使用与状态提交一致的 outbox 语义,并测试 broker 延迟期间的陈旧窗口。
9. Sitemap
Sitemap 只输出 Published,单分片最多 50,000 URL,最多 50,000 分片。BaseUrl 必须 HTTP(S),只保留 authority;外域 Canonical 回退到本站 local path;XML 文本会实体转义。
分片使用 offset paging,海量内容期间并发发布可能导致跨页漂移。需要稳定排序与快照/游标合同测试,不能只测 50,001 条边界。
10. 发布用例示例
// ① 加载聚合并验证 Draft/Scheduled/Unpublished → Published。Result<Content> result = await ContentCommandSupport.MutateAndGetAsync( store, contentId, content => content.Publish(clock.UtcNow), cancellationToken);if (result.IsFailure) return Result.Failure(result.Error);
// ② 已提交状态后发布缓存与 Sitemap 消费的稳定事实。Content content = result.Value!;await events.PublishAsync(new ContentPublicationChangedIntegrationEvent( content.Id, content.Slug, true, clock.UtcNow), cancellationToken);
// ③ GA 必须用 outbox 证据证明状态和事件不会永久分叉。return Result.Success();11. 测试矩阵
覆盖每条合法/非法状态迁移、过去时间、同 Slug 并发、未知唯一约束不误映射、巨型正文/标签、sanitizer 绕过、Published-only 读取、发布事件、跨实例缓存、Feed XML、Sitemap 分片/外域 canonical/并发漂移、双 ORM 与 API 权限。
特别加入 Scheduled 到期仍不可见的当前合同测试;实现自动发布后再用新 Job 合同替换,防止文档先行。
12. 源码核查
# ① 状态迁移与 Published 读谓词。rg -n "Schedule\(|Publish\(|Unpublish\(|Archive\(|PublishStatus\.Published" \ src/Platform/Website -g '*.cs'# ② 缓存 tag 和 publication event 消费。rg -n "WebsitePublicContent|ContentPublicationChanged" src/Hosts src/Platform/Website -g '*.cs'13. 发布评审清单
- Slug 是否需要多语言、重定向和历史保留?
- Scheduled 的执行节点、时区与补偿策略是什么?
- 自动发布是否复用同一聚合方法和事件?
- 编辑是否需要 ETag/row version 防止覆盖?
- Published 编辑是否必须产生新版本?
- Sanitizer 版本升级会否重写历史正文?
- Canonical/Cover/OpenGraph URL 是否经过统一策略?
- Feed 的站点 ID、作者与绝对链接是否来自配置?
- 缓存事件丢失时如何主动修复?
- Sitemap 排序是否在双 ORM 下稳定?
- 海量 Sitemap 是否需要快照或 keyset paging?
- 内容删除、归档和搜索引擎下架如何协同?
- 审批、审计和发布责任人是否满足商业要求?