Documents 把正文和版本历史放在同一个 Document 聚合内。每次内容变更都会产生一个完整快照,并同步更新聚合的当前正文与当前版本号。这种设计读取简单、回滚直接,但聚合行会随历史持续增大,也需要明确并发写入策略。
1. Document 保存什么
| 数据 | 存储方式 | 语义 |
|---|---|---|
| Title/Description/Content | DocsDocument 普通列 | 当前权威内容 |
| ContentType/LanguageCode | 普通列 | 声明格式与语言 |
| Status | Draft/Published/Archived | 发布生命周期 |
| CurrentVersion | 字符串 | 当前快照号 |
| Versions | VersionsJson | 所有完整快照 |
| Tags | TagsJson | 大小写不敏感唯一标签 |
| AccessRules | AccessRulesJson | 主体访问级别数据,当前未执行 |
| StorageKey/FileSize | 普通列 | 预留存储元数据,当前没有调用 SetStorage 的业务链路 |
初始创建会同时写入正文和 1.0.0 版本。快照使用 UTF-8 编码计算字节数,并对正文计算 SHA-256;哈希用于内容事实,不是请求签名或权限令牌。
2. 版本演进
回滚不是把 CurrentVersion 改回旧号码,也不删除后续版本。它读取目标快照的 Content,再以当前 Patch + 1 创建新版本,ChangeSummary 记录回滚来源。因此历史保持单调增长,可审计地表达“现在采用了旧内容”。
当前没有修改 Major/Minor 的 API。1.0.9 之后会继续形成 1.0.10;客户端不要把版本号当浮点数比较。
3. 内容更新请求
PUT /api/v1/documents/doc-42/content HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "content": "# 退款规则\n\n超过 30 天需主管审批。", "changeSummary": "补充超期审批条件"}处理器要求 User 调用者,加载当前租户内聚合,调用 CreateNewVersion,随后由通用 ExistingAggregate 处理器保存。Archived 文档返回 Docs.Document.CannotModifyArchived。
编辑器的安全保存方式
type DocumentDetail = { documentId: string; currentVersion: string; content: string;};
export async function saveDraft( original: DocumentDetail, nextContent: string,): Promise<void> { // 当前服务端没有 expectedVersion,因此先重新读取只能缩短冲突窗口。 const latest = await fetch(`/api/v1/documents/${original.documentId}`, { credentials: "include", }).then((response) => response.json() as Promise<DocumentDetail>);
if (latest.currentVersion !== original.currentVersion) { // 不覆盖他人的版本:让用户比较、合并,再发起新保存。 throw new Error( `document changed from ${original.currentVersion} to ${latest.currentVersion}`, ); }
const response = await fetch( `/api/v1/documents/${original.documentId}/content`, { method: "PUT", credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify({ content: nextContent, changeSummary: "编辑器保存", }), }, );
if (!response.ok) throw new Error(`save failed: ${response.status}`);}真正的生产修复应让命令携带 ExpectedVersion,在同一事务内比较并更新;并由数据库并发令牌/条件更新兜住两个请求同时通过比较的竞态。
4. 发布、取消发布与归档
| 操作 | 前置状态 | 状态变化 | 额外事实 |
|---|---|---|---|
| Publish | Draft | Published | 当前版本 IsPublished=true,设置 PublishedTime/By,产生领域事件 |
| Unpublish | Published | Draft | 清空文档 PublishedTime/By;版本的 IsPublished 不会被清除 |
| Archive | Published | Archived | 产生 DocumentArchived 领域事件 |
| 重复 Publish | Published | 冲突 | 不是幂等成功 |
| Draft Archive | Draft | 失败 | 必须先发布 |
权限目录虽然声明 docs.document.publish,但 Publish 命令的动作是 AuthorizationAction.Update。因此当前通用授权会按照 update 语义决策,不会自动使用独立 publish 权限。
5. 回滚请求
POST /api/v1/documents/doc-42/versions/1.0.0/rollback HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Length: 0// 请求旧版本内容,但服务端仍会创建向前递增的新版本。using var response = await api.PostAsync( "/api/v1/documents/doc-42/versions/1.0.0/rollback", content: null, cancellationToken);
response.EnsureSuccessStatusCode();var version = await response.Content .ReadFromJsonAsync<DocumentVersionDto>(cancellationToken);
// 回滚会创建新版本,不能期待返回的版本号仍是 1.0.0。if (version!.VersionNumber == "1.0.0") throw new InvalidOperationException("rollback must preserve forward history");
public sealed record DocumentVersionDto( // 返回值同时携带新版本身份与旧快照的内容事实。 string VersionNumber, string Content, string ContentHash, long FileSize, string? ChangeSummary);目标版本不存在时返回 Docs.Document.VersionNotFound;Archived 文档不能回滚,因为回滚最终走创建新版本规则。
6. Diff 的实际含义
GET /api/v1/documents/{id}/versions/diff?fromVersion=1.0.0&toVersion=1.0.2 返回 Additions、Deletions 和 Changes。当前算法不是 Git/Myers/LCS:
- 按
\n切行; - 找完全相同的公共前缀;
- 找完全相同的公共后缀;
- 把中间新区域全部标记为新增,旧区域全部标记为删除;
- 同时存在新增和删除时,生成一条中文变更摘要。
这适合快速展示单一连续变更区,不适合多处分散修改、移动检测、字符级高亮或代码评审。前端不要把它渲染成精确补丁。
{ "fromVersion": "1.0.0", "toVersion": "1.0.2", "additions": ["+ 超过 30 天需主管审批。"], "deletions": ["- 超过 15 天不可退款。"], "changes": ["~ 第 3 行起 1 行删除, 1 行新增"]}7. includeVersions 的陷阱
详情查询公开 includeVersions=false,但 DocumentsQueryShapeReadModelStore.FindDocumentByIdAsync 当前忽略该参数,始终加载完整聚合,映射也始终返回 Versions、Tags 和 AccessRules。这会带来三个影响:
- 历史越长,普通详情成本越高;
- ACL 和历史正文会进入原本只想读取当前文档的响应;
- API 参数给调用方造成了错误的性能预期。
生产修复应提供不含大 JSON 列的专用详情投影,并让版本列表/单版本走独立查询;不是简单地在序列化后丢字段。
8. 归档边界审查
| 聚合操作 | Archived 时 |
|---|---|
| UpdateInfo / SetOptions / CreateNewVersion | 拒绝 |
| Publish / Unpublish / Archive | 拒绝或冲突 |
| AddTag / AddAccessRule | 拒绝 |
| RemoveTag / RemoveAccessRule | 当前允许 |
| IncrementViewCount / SetStorage | 方法自身不检查归档,但当前无 handler 调用 |
如果业务定义“归档即不可变”,应统一所有改变聚合的方法,并补充参数化测试;不要只在端点层禁按钮。
9. 测试清单
[Fact]public void Rollback_Should_Create_A_New_Forward_Version(){ // Arrange:先形成 1.0.0 与 1.0.1 两个完整快照。 var document = CreateDraft(content: "v1"); document.CreateNewVersion("v2", "edit", "user-1", Now).IsSuccess.ShouldBeTrue();
// Act:采用 1.0.0 的内容,但不能删除或复用历史版本号。 var result = document.Rollback("1.0.0", "user-1", Now.AddMinutes(1));
result.IsSuccess.ShouldBeTrue(); document.Content.ShouldBe("v1"); document.CurrentVersion.ShouldBe("1.0.2"); document.Versions.ShouldContain(v => v.VersionNumber == "1.0.1");}必须增加的集成测试包括:两个并发 UpdateContent、发布与编辑竞争、超大 VersionsJson、includeVersions 投影、取消发布后的版本标记、归档后每个可变方法以及软删除后的所有读路径。