Skip to content
bitzorcas
中EN

Guide

Documents 正文、版本、发布与回滚

深入讲解 Document 聚合的完整快照、版本号、哈希、发布状态、回滚、Diff、并发和客户端编辑流程。

Last updated

Documents 把正文和版本历史放在同一个 Document 聚合内。每次内容变更都会产生一个完整快照,并同步更新聚合的当前正文与当前版本号。这种设计读取简单、回滚直接,但聚合行会随历史持续增大,也需要明确并发写入策略。

1. Document 保存什么

数据存储方式语义
Title/Description/ContentDocsDocument 普通列当前权威内容
ContentType/LanguageCode普通列声明格式与语言
StatusDraft/Published/Archived发布生命周期
CurrentVersion字符串当前快照号
VersionsVersionsJson所有完整快照
TagsTagsJson大小写不敏感唯一标签
AccessRulesAccessRulesJson主体访问级别数据,当前未执行
StorageKey/FileSize普通列预留存储元数据,当前没有调用 SetStorage 的业务链路

初始创建会同时写入正文和 1.0.0 版本。快照使用 UTF-8 编码计算字节数,并对正文计算 SHA-256;哈希用于内容事实,不是请求签名或权限令牌。

2. 版本演进

CreateNewVersionCreateNewVersionRollback(1.0.0)

1.0.0
首次完整快照

1.0.1
普通编辑

1.0.2
再次编辑

1.0.3
回滚到 1.0.0 的内容

回滚不是把 CurrentVersion 改回旧号码,也不删除后续版本。它读取目标快照的 Content,再以当前 Patch + 1 创建新版本,ChangeSummary 记录回滚来源。因此历史保持单调增长,可审计地表达“现在采用了旧内容”。

当前没有修改 Major/Minor 的 API。1.0.9 之后会继续形成 1.0.10;客户端不要把版本号当浮点数比较。

3. 内容更新请求

保存一次内容编辑
PUT /api/v1/documents/doc-42/content HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-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. 发布、取消发布与归档

PublishUnpublishArchiveEdit / RollbackEdit / Rollback

Draft

Published

Archived

操作前置状态状态变化额外事实
PublishDraftPublished当前版本 IsPublished=true,设置 PublishedTime/By,产生领域事件
UnpublishPublishedDraft清空文档 PublishedTime/By;版本的 IsPublished 不会被清除
ArchivePublishedArchived产生 DocumentArchived 领域事件
重复 PublishPublished冲突不是幂等成功
Draft ArchiveDraft失败必须先发布

权限目录虽然声明 docs.document.publish,但 Publish 命令的动作是 AuthorizationAction.Update。因此当前通用授权会按照 update 语义决策,不会自动使用独立 publish 权限。

5. 回滚请求

将正文恢复为 1.0.0 的内容并生成新版本
POST /api/v1/documents/doc-42/versions/1.0.0/rollback HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-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:

  1. 按 \n 切行;
  2. 找完全相同的公共前缀;
  3. 找完全相同的公共后缀;
  4. 把中间新区域全部标记为新增,旧区域全部标记为删除;
  5. 同时存在新增和删除时,生成一条中文变更摘要。

这适合快速展示单一连续变更区,不适合多处分散修改、移动检测、字符级高亮或代码评审。前端不要把它渲染成精确补丁。

简化 Diff 响应
{
"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 投影、取消发布后的版本标记、归档后每个可变方法以及软删除后的所有读路径。

返回 Documents 总览

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%