Skip to content
bitzorcas
中EN

Guide

Documents 白板、协作会话与实时边界

深入讲解 Whiteboard 版本控制、成员、归档、内存协作操作、默认空 Hub、多实例风险与生产实时架构。

Last updated

Documents 同时包含持久化白板聚合和简化文档协作服务。前者能保存画布 JSON 并用整数版本拒绝陈旧写入;后者只在单个进程内保存文本与参与者,并通过默认空 Hub 记录推送日志。两者没有形成同一套实时协作协议。

1. 白板模型

Whiteboard 保存 Name、Description、Type、可选 KnowledgeBaseId、Active/Archived 状态、Data JSON 字符串、CurrentVersion、AllowCollaboration、DefaultAccessLevel 和成员 JSON。

Create, Data={}, Version=1UpdateData(expected=current) / version +1ArchiveUpdateData rejected

Active

Archived

Create 默认 Data 为 {}、CurrentVersion 为 1。UpdateData 要求请求 Version 与当前值相同,然后替换整段 Data 并加一。源码没有验证 Data 是合法 JSON、没有大小上限,也没有数据库级条件更新,所以两个并发请求仍要依赖仓储并发行为。

2. 白板 HTTP 用例

方法与路由行为当前安全边界
POST /api/v1/whiteboards创建 Active 白板docs.whiteboard.create;不验证 KB,不执行成员规则
GET /api/v1/whiteboards/{id}返回完整 Datadocs.whiteboard.view,带 IAuthorizedRequest
GET /api/v1/whiteboards列表筛选docs.whiteboard.view,带 IAuthorizedRequest
PUT /api/v1/whiteboards/{id}/data全量替换 + version checkdocs.whiteboard.update
POST/DELETE .../{id}/members增删成员 JSONdocs.whiteboard.update,成员不参与后续授权
POST /api/v1/whiteboards/{id}/archiveActive → Archiveddocs.whiteboard.update
DELETE /api/v1/whiteboards/{id}软删除docs.whiteboard.delete

权限目录已定义四个白板权限码:docs.whiteboard.view、docs.whiteboard.create、docs.whiteboard.update、docs.whiteboard.delete(资源标识 whiteboard)。所有白板命令与查询都实现 IAuthorizedRequest,带显式 ResourceDescriptor(DocumentsPermissions.Module, DocsPermissions.WhiteboardResource) 和具体 AuthorizationAction。成员关系目前仍不参与后续对象级授权,这是下一步要补的边界。

3. 带版本的画布保存

白板客户端处理版本冲突
type Whiteboard = {
whiteboardId: string;
data: string;
currentVersion: string; // 当前 DTO 暴露 string,服务端聚合实际是 int。
};
export async function saveCanvas(
board: Whiteboard,
canvas: unknown,
): Promise<Whiteboard> {
const response = await fetch(
`/api/v1/whiteboards/${board.whiteboardId}/data`,
{
method: "PUT",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
// 先在客户端序列化;当前服务端不会验证 JSON 语法或 schema。
data: JSON.stringify(canvas),
version: Number(board.currentVersion),
}),
},
);
if (response.status === 409) {
// VersionMismatch 后重新读取、合并,不盲目重放全量 Data。
throw new Error("canvas changed; reload and merge before retrying");
}
if (!response.ok) throw new Error(`save failed: ${response.status}`);
return response.json() as Promise<Whiteboard>;
}

DTO 把 CurrentVersion 映射成 string,而请求使用 int。SDK 应显式转换并校验安全整数;更理想的是修正 DTO 类型,避免跨语言客户端出现隐式转换差异。

4. 协作会话聚合

持久化 CollaborationSession 包含 TenantId、DocumentId、Active/Inactive、CreatorId、EndedAt 和 ParticipantsJson。领域规则允许:

  • Active 会话加入非空 User/DisplayName;
  • 同一 User 不能有两条活跃记录;
  • 离开把参与者标记为不活跃;
  • 已离开的 User 再加入会追加新记录;
  • End 幂等地切换 Inactive 并记录时间。

但当前 SimpleCollaborationService 没有使用 repository/read model 保存这个聚合。它在 ConcurrentDictionary<string, State> 中按 DocumentId 管理另一份内存状态。

5. SimpleCollaborationService 的真实流程

NullRealtimeCollaborationHubConcurrentDictionarySimpleCollaborationServiceUserNullRealtimeCollaborationHubConcurrentDictionarySimpleCollaborationServiceUserJoin(documentId, userId, displayName)GetOrAdd(documentId)create session with "default-tenant"Join participantfire-and-forget user joinedApplyOperation(insert/delete/replace)mutate in-memory CurrentContentappend operation logfire-and-forget operation + full content

关键边界:

  • TenantId 被硬编码为 default-tenant;
  • Dictionary 只按 DocumentId 分区,没有租户、会话或服务器节点维度;
  • 初始 CurrentContent 是空字符串,不读取 Document.Content;
  • 结果不会保存回 Document 或 CollaborationSession 表;
  • 进程重启、扩缩容或请求落到另一节点会丢失/分叉;
  • Hub 调用 fire-and-forget,异常不可观察,也可能继续使用已取消 token;
  • 默认 NullRealtimeCollaborationHub 只记录日志,不传输 SignalR/WebSocket 消息;-没有任何 HTTP/Hub 端点调用 ICollaborationService。

6. 操作语义不是 OT/CRDT

CollaborationOperation 支持 Insert、Delete、Replace。服务只检查发起者是活跃参与者和 Position 范围,然后按到达顺序直接修改字符串:

单实例内存操作示例
// Join 接口直接接收 userId;生产网关必须改为从认证上下文派生。
var join = await collaboration.JoinSessionAsync(
documentId: "doc-42",
userId: "user-a",
displayName: "Alice",
cancellationToken);
if (join.IsFailure) return join.Error;
// OperationId 当前只校验非空,重复发送仍会重复修改内容。
var operation = new CollaborationOperation(
OperationId: Guid.NewGuid().ToString("N"),
UserId: "user-a",
Type: OperationType.Insert,
Position: 0,
Length: 0,
Content: "Hello");
// 当前服务没有 OperationId 去重;重复投递会再次插入。
var applied = await collaboration.ApplyOperationAsync(
"doc-42", operation, cancellationToken);

注释将它称作 LWW,但代码没有时间戳/逻辑时钟比较,也没有覆盖寄存器;它只是服务器接收顺序。并发客户端基于不同字符串位置提交操作时,不会做位置变换,容易错位或越界。

Delete 超出末尾时会删除到字符串末尾;Replace 超出末尾时不会先删除,而是在 Position 插入新内容。未知 OperationType 保持原内容却返回成功。OperationId 只验证非空,不去重。

7. 协作与白板没有连接

IWhiteboardCollaborationHub 的默认实现同样只记录日志。UpdateWhiteboardData handler 没有调用该 Hub;SimpleCollaborationService 操作的是自己的文本 State,不是 Whiteboard.Data。因此:

  • 开启 AllowCollaboration 不会自动建立实时通道;
  • 更新白板不会广播;
  • 成员变更不会踢出连接;
  • 协作状态不会形成 Document 版本;
  • 不能用 CollaborationSession 表恢复 SimpleCollaborationService 的状态。

8. 生产实时架构

Web / Desktop client

Authenticated realtime gateway

DocumentAccessPolicy

session coordinator
Tenant + Document partition

OT / CRDT engine

operation log + snapshot

backplane / fan-out

explicit Document version commit

必须明确的协议决策:

  1. 会话标识是否由 Tenant + Resource + Branch 组成;
  2. 操作采用 server sequence、OT 还是具体 CRDT;
  3. OperationId 的幂等窗口与唯一约束;
  4. 快照、日志截断和断线重放;
  5. ACL/成员撤销如何立即终止连接;
  6. 协作内容何时形成 Document 版本,失败如何恢复;
  7. 多实例 backplane 和分区所有权;
  8. 最大文档/白板大小、速率和每会话参与者上限;
  9. 内容与操作日志的保留、审计和隐私清除。

9. 安全问题

读模型的 FindSessionByIdAsync 和 FindActiveSessionByDocumentAsync 没有 TenantId 参数或谓词。虽然当前没有公开查询端点,任何未来调用都必须先修复接口,否则可形成跨租户会话读取。

SimpleCollaborationService 也没有验证 Document 存在、AllowCollaboration、调用者身份或资源访问。接口让调用方直接传 userId/displayName,若直接暴露会允许身份冒充。生产 Gateway 必须从认证上下文派生主体,不能相信消息体的 UserId。

10. 测试矩阵

层级必须覆盖
Whiteboard 聚合version mismatch、归档、非法/超大 Data、成员重复、DTO 类型
并发集成两个相同 expected version 的条件更新只能一个成功
协作算法重复 OperationId、乱序、并发插入、超界 Delete/Replace、未知类型
多租户相同 DocumentId 的两个租户、会话读模型 Tenant 谓词
实时安全伪造 UserId、ACL 撤销、Token 过期、重连与连接上限
多实例节点切换、重启恢复、backplane 重放、分区脑裂
持久化operation log、snapshot、Document 版本提交的原子/补偿路径

返回 Documents 总览

100%

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