GuidanceContent 同时是领域聚合与持久化模型。平台内容和租户内容共享一张表;TenantId 可空,因此不能使用会排除平台行的普通全局租户过滤,所有 Store 查询必须显式表达范围。
1. 表与索引
三个索引都是普通查询索引,没有业务唯一索引。数据库允许同一 TenantId/RouteKey/ComponentId/LanguageCode/RequiredRole 出现多个 Draft 或 Published,最终由排序隐式选胜者。
2. 字段合同
| 字段 | 当前约束 |
|---|---|
| ContentId | 必填,最长 36;"0" 占位由 Store 重分配 |
| RouteKey | 必填,Trim,最长 300 |
| ComponentId | 可空,Trim,最长 120 |
| Title | 必填,Trim,最长 200 |
| BodyMarkdown | 必填,Trim,大文本 |
| RequiredRole | 可空,单角色,最长 120 |
| TenantId | 可空,最长 36 |
| LanguageCode | 最长 10,当前 zh-CN/en-US |
| ContentVersion | 正整数,Create=1,Revise+1 |
| StatusName | Draft/Published 严格字符串 |
BodyMarkdown 没有内容长度上限,也没有 HTML、链接、图片或危险协议校验。服务端返回原文,安全渲染属于尚未固定的跨端合同。
3. 创建语义
// Create 会统一 Trim 字段并校验持久化列长度。var created = GuidanceContent.Create( contentId: "0", routeKey: "/cases", componentId: "case-form", title: "案件录入", bodyMarkdown: "请先填写委托人与案由。", requiredRole: "Attorney", tenantId: currentTenantId, languageCode: LanguageCode.ZhCn, clock);
// Store 将空占位 ID 替换为最终持久化 ID。if (created.IsFailure) return created.Error;Command 对 TenantId 有特殊推导:普通租户省略时写当前 Tenant;平台租户省略时写 null;平台租户也能显式为其他 Tenant 创建。Guard 不验证目标租户真实存在。
4. 语言行为
LanguageCode.Normalize 只显式识别 en-US,其余任何输入都回退 zh-CN,包括拼写错误、空值和未来语言。创建与查询均沿用该行为。持久化 Restore 却只接受精确 zh-CN/en-US,损坏数据会失败关闭。
商业 API 更适合对未知语言返回 Validation,并把 fallback 链作为产品配置;否则 en-GB 或 zh-TW 会悄悄命中简体中文。
5. 状态机
重复 Publish 返回 Guidance.Content.AlreadyPublished。Revise 的 Body 必填、Title 可空表示保留旧值;修订总是转 Draft。没有 Scheduled、Archived、Rejected 或 Unpublished。
6. 同行修订的产品后果
Published 内容被 Revise 后,同一行立即变成 Draft,Contextual Query 不再返回它。旧正文被覆盖,ContentVersion 只是当前行计数,不是可查询 Revision。系统无法回答谁在何时发布了什么,也不能 diff/rollback。
GA 目标应采用 Draft 与不可变 PublishedRevision 分离,或至少保留完整历史并维护 CurrentPublishedVersion 指针。修订工作区不得影响线上读。
7. 保存语义
Store 在 ContentId 为空占位时分配 ID,然后按全局 ID 判断 Add/Update。它不在保存时重新验证调用租户,也不接收 expected version;租户安全依赖 Handler Guard 与聚合 TenantId。
// Handler 必须在修改同行聚合前完成对象租户检查。var content = (await store.GetByIdAsync(contentId, cancellationToken)).Value;var access = GuidanceApplicationGuards.EnsureCanMutate(currentUser, content!);if (access.IsFailure) return access.Error;
// Revise 修改当前聚合并转回 Draft;Publish 再修改同一聚合。var revised = content!.Revise(newTitle, newBodyMarkdown, clock);return revised.IsFailure ? revised.Error : await store.SaveAsync(content, cancellationToken);基础聚合可能带框架 Version,但 HTTP Command 没有 ETag/ExpectedVersion,文档不能承诺并发冲突一定被稳定映射。需要用真实双 ORM 并发更新证明。
8. 软删除
Delete Handler 先读取、做 Guard,再调用 DeleteWhereAsync(content.Id == contentId)。Store 不返回 affected count,也不把 TenantId 放入删除谓词。ID 被设计为全局主键,但 TOCTOU、重复删除和并发发布需要集成测试。
删除没有 Restore API、保留期或历史引用策略。已存在的 AI Conversation 仍可能保存删除前正文;删除内容不会关闭或刷新 Session。
9. 持久化恢复
Restore 严格检查必填字段、长度、语言、正版本、枚举和 IsDeleted/DeleteTime 一致性。StatusName 未登记时抛 InvalidOperationException,不会静默变 Draft。此 fail-closed 行为已有测试。
平台/租户内容共表的迁移还要求全局 ContentId 唯一。备份恢复要验证 null TenantId 语义、状态、软删除和框架 Version。
10. 自然键设计
目标自然键至少包含 Scope(platform/tenant)、TenantId、RouteKey、ComponentId、LanguageCode 与 AudiencePolicy。Draft 可允许每编辑分支一条,Current Published 必须唯一。若用 filtered unique index,需要验证所有目标数据库/ORM 迁移能力。
RequiredRole 未来升级为 Audience Policy 时,自然键应引用稳定 PolicyId/Hash,而不是把可变角色名直接塞入唯一键。
11. Markdown 安全
BodyMarkdown 可能包含原始 HTML、javascript: 链接、远程像素、超大 data URL、嵌套内容或提示词注入。存储层不应自称安全;管理端预览与用户端渲染必须共用 allowlist sanitizer、URL policy、CSP 和资源代理策略。
若正文进入 AI Prompt,还要执行另一套数据分类与敏感信息策略。对浏览器安全的 Markdown 不等于适合发送给外部 Provider。
12. 测试矩阵
| 场景 | 当前证据 | GA 补充 |
|---|---|---|
| 字段必填/长度 | 聚合实现 | HTTP 错误合同 |
| 未知持久化状态 | 应用测试 | 数据库迁移隔离 |
| 选择与软删除 | 内存/双 ORM | 并发与 affected count |
| 发布后修订 | 实现存在 | 线上版本持续可见 |
| 重复自然键 | 未限制 | 唯一冲突 |
| 并发修订/发布 | 未覆盖 | ETag/无丢失更新 |
| Markdown 攻击 | 未覆盖 | 渲染端到端安全 |
| 删除后会话 | 未覆盖 | Prompt 撤销/清理 |
13. 检查命令
# 聚合字段、状态与同行修订。rg -n "BitzTable|ContentVersion\+\+|Status = GuidanceStatus|BodyMarkdown" \ src/Platform/Guidance/BitzOrcas.Platform.Guidance.Contracts/Content -g '*.cs'
# 不可变发布、历史和并发 API 当前预期无命中。rg -n "PublishedRevision|CurrentPublished|Rollback|ExpectedVersion|ETag" \ src/Platform/Guidance -g '*.cs'