# Univer Workspace 应用层设计 本文定义产品 HTTP、Univer Collaboration Endpoint 与 V9 Node/Resource/Asset 数据模型之间的 模块边界。具体 HTTP 契约以 `contracts/http/openapi.yaml` 为准。 ## 模块 ```text React ├── Product HTTP │ └── Application Modules │ ├── Identity │ ├── Spaces │ ├── Nodes │ ├── Resources │ ├── Blobs / BlobStore │ ├── Office Exchange │ ├── Univer Assets / File Gateway │ ├── Permissions / Trash / Views │ ├── Issues │ ├── Worktrees │ └── Operations ├── Worktree Change Feed │ └── Authenticated WebSocket cache invalidation │ └── Univer Collaboration Client └── Collaboration Endpoint ├── Login Session Authenticator ├── Collaboration Access Resolver └── Univer Collaboration Service ``` Product HTTP 与 Collaboration Endpoint 是两个入口,共享身份和权限模块,但不互相发起 HTTP 回调。Application Module 通过进程内 Interface 调用 Collaboration Service。 Worktree Change Feed 是第三个窄入口,只传播产品缓存失效信号。它复用 Collaboration Endpoint 签发的一次性 Session Ticket,但不传播 snapshot、changeset、产品字段或权限结果。 ## 数据库启动边界 `db/initialize.ts` 在创建业务 Repository 前打开数据库。磁盘数据库先经过可整体删除的 `db/migrations` 准备阶段: - Fresh:创建 V9; - V9:校验 Schema 指纹; - V8/V7/V6/V5/V4/V3/V2/V1:一致性备份后逐版本迁移到 V9; - V0:一致性备份后直接迁移到 V9; - 其他状态:拒绝启动。 旧表读取只允许存在于这个可整体删除的迁移包中。Identity、Node、Resource、权限、 Worktree 等业务模块只编译和运行 V9 Query。 V7 扩展 Operation 和 Object Deletion Job 枚举;V8 仅新增内容权限对象与协作者表;V9 仅新增 Issue 相关表,均不改写现有业务表。 ## Collaboration SDK 升级边界 SDK 1.0.0 启动时先准备产品数据库,再运行隔离的 `prepareCollaborationDatabase`,最后构造 应用 Service。Core/Worktree/History 的 schema 和迁移由已发布 SDK 拥有;Workspace 负责停写 部署、迁移期间的协同文件排他锁、一致性备份、从产品 Node 与 Worktree node intent 只读补充 Unit 创建事实、迁移副本验证和原子替换。组件版本为 Core 2、Worktree 3、History 2; Comment 1 不变。产品数据库先准备到 V9,协同迁移再从中读取创建事实。任何组件迁移失败均不发布副本,原库与备份保留,启动失败。 当前版本重复启动只读取组件版本,不再次执行协同库完整性与外键全库扫描、迁移或备份;迁移前仍校验源库,迁移后仍校验副本。History 的事件订阅、分段和读取时追赶由 SDK 自行管理, 不再从产品 Resource 查询执行旧 History backfill。升级与回退步骤见应用 README。 启动诊断按模块加载、产品库准备、协同库准备和应用初始化分阶段记录;协同迁移进一步标记备份、创建事实读取、各 SDK 迁移、校验及替换。阶段日志不改变迁移顺序和失败传播,OOM 时以同一 startupId 的最后未完成阶段定位范围。 ## Login Session Authenticator 需要登录的产品 HTTP、Changeset 与 Worktree 入口使用同一 Login Session: ```ts interface LoginSessionAuthenticator { authenticate(cookieHeader: string | undefined): | { authenticated: false } | { authenticated: true; userId: string; sessionId: string; }; } ``` 它只证明身份,不携带或缓存 Space、Node、Resource、Worktree Role。 Node、Resource 打开、按 Unit 查询、Blob 与 Trunk Asset 的指定读取接口也接受匿名访问者。 匿名身份以保留的 `ANONYMOUS_USER_ID`(`workspace:anonymous`)进入 Access Resolver,仅由 Link Sharing 或 Space 公开可读提供 viewer 权限; 所有写入和管理入口继续要求登录。匿名 Resource Open 不写 Recent。 SDK HTTP 的匿名入口集中由现有 Transport 中间件按方法和路径白名单放行,再由原有资源权限检查鉴权。 Trunk 协同协议使用固定的访客显示身份及每连接独立的 member ID,复用短期、一次性 Session Ticket; 该身份不对应产品 User,票据存储和连接生命周期沿用原实现。Snapshot、Comment/History 读取和 Unit 加入继续使用现有资源权限检查;Link Sharing 变更保留既有连接失效行为。Space 公开设置、 节点移动和删除不新增主动断连,既有订阅可能继续接收更新,断开后重连会重新鉴权。 没有新增票据代理、匿名连接登记、轮询、持久化 Session 或数据库迁移。 Workspace CLI 通过 Browser 确认的 Device Flow 获取同一种 Login Session。Server 在进程内 保存有容量上限、十分钟过期的待授权请求;已登录 Browser 确认人类可核对的验证码后,高熵 Device Code 只能兑换一次,并为 CLI 创建独立、持久化的 `login_sessions` 行。Server 重启可以 取消尚未确认或兑换的请求,但不影响已经签发的 CLI Session。Browser Cookie、密码、GitHub Token 和 Discord Token 都不会经过 Agent。 ## Access Resolver 产品 HTTP 与 Collaboration Endpoint 共用: ```ts interface AccessResolver { resolveSpace(userId: string, spaceId: string): SpaceAccess | null; resolveNode(userId: string, nodeId: string): NodeAccess | null; resolveResource(userId: string, resourceId: string): ResourceAccess | null; resolveUnit(userId: string, unitId: string): ResourceAccess | null; } ``` 返回值包含有效 Role 和服务端 Capability;`null` 表示目标不可发现。Resolver 内部统一 处理: - Space Owner 与 Team Membership; - Node 祖先链上的 Direct Grant 和 Link Sharing; - Trash 状态; - Node/Resource 以及对应 Univer/Blob 扩展映射; - Resource Node 是否有子 Node(与内容访问彼此独立)。 `SpaceAccess.member` 区分 Space Owner/成员与只靠 `public_read` 得到 `viewer` 的读者。Issue 写入按它判断, 其余模块继续只看 Role。 调用者不直接查询授权表或从客户端字段推断权限。Move 同时验证 Source、Target、同 Space 与后代链。 ## Node 与 Resource 边界 Node Module 管理树、名称和位置: ```ts interface NodesModule { listSpaceRoot(userId: string, spaceId: string, page: PageRequest): NodePage; get(userId: string, nodeId: string): NodeResponse; listChildren(userId: string, nodeId: string, page: PageRequest): NodePage; create(userId: string, input: CreateNode): NodeSummary; update(userId: string, nodeId: string, patch: PatchNode): NodeSummary; } ``` 任何 Node 都可调用 `listChildren`,是否有 Resource 不改变 Create Children 和 Browse Children Capability。 Resource Module 管理内容身份和打开: ```ts interface ResourcesModule { create( userId: string, idempotencyKey: string, input: CreateResource, ): Promise; get(userId: string, resourceId: string): ResourceResponse; open(userId: string, resourceId: string): ResourceOpenView; } ``` 创建 Resource 同时创建新 Node;本版本不把 Resource 附加到已有 Node。Canonical 页面 使用 Node ID,内容 Open 使用 Resource ID。 Resource 是由 `kind` 判别的联合。现有 `POST /api/resources` 只创建 Univer Resource; Blob Module 通过 Upload Session 接收字节,只有 Complete 才发布 Node/Resource。BlobStore 保存字节,产品数据库保存元数据和删除 Outbox;前端根据服务端检测的 MIME 和原始文件扩展名选择预览。 `.md`/`.markdown` 使用只读 GFM 预览,保留源码切换与下载;兼容历史 octet-stream 分类时, 读取后先验证 UTF-8 与控制字符,不把文件名当成内容安全保证。 Blob 内容替换使用单次 PUT 和强 ETag 并发校验,直接发布到原 Resource。 `replace_blob_content` Operation 记录上传意图;产品事务只在新字节就绪且权限仍有效时切换对象, 旧对象通过删除 Outbox 回收。失败或启动时发现中断的替换保留旧内容并清理新对象。 `.univer.html` 使用现有 Blob 身份和上传生命周期,不新增数据库表或 Unit 类型。 Browser 的 Apps 入口在侧栏团队空间之后,列出当前用户能打开的这类 Blob。主区域使用和 Node 页相同的 HTML 视图与顶栏操作;沉浸视图仍进入该 Node。 Browser 根据模板中的 Unit ID 通过既有 API 检查来源访问,再由独立 Binding 引擎 加载 headless 协同数据。HTML 文件权限不授予来源表格权限;页面脚本在隔离 iframe 中执行,来源数据访问由宿主授权。 设计见 [Univer HTML Views](../../../docs/design/html-views/README.md)。 Univer Asset Module 适配原生 File API。它将 Slide、Board、Base 等 Unit 内嵌资源保存到同一 `BlobStore`,但不创建 Node/Resource。Snapshot 只持有稳定 Asset ID;签名接口返回同域 `/content` 网关,网关在每次请求时重新解析 Unit/Worktree 权限并禁止缓存。Worktree-local Asset 在对应 Unit 合入后发布到 Trunk。 Office Exchange Module 适配 Univer Exchange Client 使用的 Universer 协议: - `source=HttpImport` 的 `/universer-api/stream/file/upload` 由 Exchange 接收;Unit Embedded Asset 的上传继续由 Univer Asset Module 处理; - import task 使用 `@univerjs-pro/exchange-node` 转换 XLS/XLSX/CSV/TSV、DOC/DOCX 或 PPT/PPTX;Workspace 文件入口按扩展名自动识别这些格式,并在当前 Space 与目录创建正式 Resource,Editor Ribbon 导入则默认创建在当前 User 的 Personal Space 根目录。两种入口都 必须通过 Resource Module,不能直接写入一个没有产品归属的 Collaboration Unit; - export task 只接受服务端 `AccessResolver` 能解析且允许打开的 Trunk Unit,服务端自行 确认 Unit Type,通过 Collaboration Service 固定当前 head 并读取包含 Sheet blocks 的恢复 材料,再由 `UnitSnapshotMaterializer` 补全 snapshot; - 转换用 source、JSON 和 output 字节保存在 `BlobStore`,任务与临时文件身份只在当前进程 保存并在两小时后失效,不写入产品数据库; - Worktree 和 Merge Preview 暂不注册 Exchange 插件,避免把 Trunk 内容误当作当前 Scope 导出。Board 没有受支持的 Office 格式,也不注册 Exchange。 这些 `/universer-api` Route 属于 Univer/Universer 兼容入口,不是产品 HTTP contract,因此 不添加到 `contracts/http`。身份仍来自 Workspace Login Session;客户端提交的 User、Role、 Unit Type 或 Unit 可见性都不作为授权依据。 ## Collaboration Access Resolver Collaboration Endpoint 不信任客户端提交的 Resource ID、Unit Type、Role 或 `editorMode`。它只接受认证 User、目标 Unit 和 Scope: ```ts type CollaborationScope = | { kind: "trunk" } | { kind: "worktree"; worktreeId: string } | { kind: "mergePreview"; worktreeId: string }; type CollaborationAction = | "readSnapshot" | "submitChangeset" | "connect" | "readComment" | "writeComment" | "issueSessionTicket"; ``` Trunk Scope 通过 `univer_resources.unit_id` 反查 Resource,再解析所属 Node: - Read/Connect 要求 Viewer 以上; - Submit 要求 Editor 以上; - Node 或祖先在 Trash 中时拒绝; - Unit 不属于现有 Resource 时,普通 Trunk Endpoint 不可访问。 Comment Endpoint 复用同一 Login Session、Unit Room 与 Access Resolver,服务 Sheet、Doc、 Slide、Base 和 Board。`UnitAction.Comment` 与 `View` 使用同一打开权限:能打开 Unit 的人可以 新增、回复和改变 solved 状态。编辑正文由 Comment Service 限定为作者本人,删除要求作者本人或 Resource Owner/Admin。未登录请求只放行评论列表,写入在进入评论策略前被拒绝。评论 anchor 随 Unit 协作数据变化,正文由 Comment Adapter 保存。 由于 Comment 协议没有 Worktree ID、revision 或合并合同,Browser 仅在 Trunk Scope 注册 Thread Comment,Worktree 与 Merge Preview 保持关闭。 History Endpoint 同样复用 Login Session 与 Access Resolver,但服务五类 Trunk Unit。Viewer 可以列出版本、作者并读取版本 changeset;恢复版本会生成标准 Collaboration changeset,因此仍 由 Submit 权限要求 Editor 以上。History Adapter 的记录是权威 Trunk Unit/changeset 的派生索引; SDK 自动索引并在 History 读取时从 Core 追赶;它不能替代 Collaboration Database Adapter。Browser 只在 Trunk Scope 按 Unit 类型 注册标准 SDK History UI,Worktree 与 Merge Preview 保持关闭。 Worktree 和 Merge Preview 先解析 Worktree Visibility,再校验 Trunk Resource 或激活目标 Node 的实际访问权限。Visibility 不替代底层权限。 ## Recent Seam 成功的 Product Resource Open 才调用 Recent Repository: ```ts interface RecentResourceRecorder { recordOpened(userId: string, resourceId: string, openedAt: number): void; } ``` Collaboration Snapshot 重试、Node 元数据加载、Worktree Preview 和失败打开都不写 Recent。 ## Issues `modules/issues` 只读写产品数据库,权限来自 `AccessResolver.resolveSpace`: - 无法发现的 Space 返回 404;Personal Space 返回 409,因为调用者本来就能看到自己的空间, 明确的错误比隐藏更有用; - 读取对所有能发现 Team Space 的登录用户开放(含公开读),写入要求 `member`; - 标题、正文与状态由作者或 triage 权限修改,标签、Assignee 与引用文件只有 triage 权限可改, 标签定义只有 Owner/Admin 可改; - 引用文件以调用者身份经 `resolveNode` 解析,Trash 中或已不可读的 Node 只返回 `available: false`, 不暴露名称; - 每次写入成功后通过 `onChanged({ spaceId, audienceUserIds })` 通知 Owner 和全部成员,推送失败不影响写入结果。 Issue 写入不是跨系统写入,不使用 Operation,也不接受 `Idempotency-Key`。客户端遇到结果未知时先读列表 或时间线,再决定是否重试。 ## Operation Runner 跨产品数据库与 Collaboration Service 的写操作以 Idempotency Key 固化意图和服务器生成 的 Node/Resource/Unit/Upload ID。Runner 只处理当前 Operation Kind 和 V3 JSON: - 首次调用预留 ID; - 同 Key 同意图返回已有状态; - 同 Key 不同意图返回 Conflict; - Retryable 错误保留 Pending 并由 Recovery Job 续跑; - Non-retryable 错误进入 Failed,由显式 Retry 重新排队。 业务代码没有旧字段回退、旧表 Union、双写或旧 Route 转发。 ## Web 应用 AI 或 CLI 通过另一 Login Session 修改 Worktree 时,Worktree Module 在完整产品操作成功后 向变更前后可发现该 Worktree 的在线用户发布 `worktreesChanged`。Browser 收到信号后使 `["worktrees"]` Query 失效;连接建立时服务端先发送 `worktreeChangeFeedReady`,Browser 同样执行一次失效,从而覆盖断线期间遗漏的 best-effort 通知。创建事件在产品 Worktree 与 Operation 都已保存后发布;merge/discard 事件在 `processed_at` 与相关恢复状态收敛后发布。 该通道不替代 Collaboration Worktree 的 per-Worktree 状态连接,也不建立可供其他 Module 任意发布的全局 Event Bus。 Web 应用的 Tree Row 总是 Node: - `/nodes/{nodeId}` 是 Canonical URL; - `hasChildren` 控制树展开; - `resource === null` 表示纯组织 Node; - `resource !== null` 时用 Resource ID 调 Open API; - `resource.kind === "univer"` 时打开 Univer Editor; - `resource.kind === "blob"` 时根据 MIME 与原始文件扩展名选择预览,未知类型显示下载; - 权限、Move、Trash 全部传 Node ID; - Recent 和 Worktree 内容操作使用 Resource ID。 OpenAPI 生成类型是 Web 应用与服务端的唯一 HTTP 结构约束。旧 Route 不在 OpenAPI 中, Express 的未知 `/api/*` 路由直接返回 404,不落入 Web SPA Fallback。 ## 内容保护授权 产品 `content-permissions` Repository 保存 ACL,Univer integration 实现 SDK Authz 路由,并在 `commitChangeset` 按 SDK 分析出的权限需求授权。文件 Owner 与 Admin 继承对象权限。查看和编辑范围都保存; 编辑范围为 `OneSelf` 时,非 Owner 只在查看范围为所有协作者时保留 View;否则 View 只来自对象角色。复制、打印、导出服从对象 strategies。 Snapshot、history 和 export 不按对象范围过滤。权限始终与文件访问权限取交集;Editor 可创建, 创建者和 Owner/Admin 可管理。Worktree 使用独立 Authz 路径读取当前 Trunk ACL,拒绝管理; 直接提交和合并都在 commit 阶段重新检查。现有产品 HTTP OpenAPI 不新增接口: `/universer-api/authz` 及 Worktree scope 路由仍属于 SDK 协议适配。 ## 智能工作台首次创建引导 从未创建过 Worktree 的用户可在智能工作台获取 Workspace Agent 下载和 CLI/Skill 安装引导。 已有可见任务时,以简洁提示提供入口,保留任务列表和审核界面。