news 2026/9/14 22:40:14

基于 SpacetimeDB 的实时协作画布权限设计:Viewer/Editor/Owner 角色模型与即时强制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于 SpacetimeDB 的实时协作画布权限设计:Viewer/Editor/Owner 角色模型与即时强制

基于 SpacetimeDB 的实时协作画布权限设计:Viewer/Editor/Owner 角色模型与即时强制

【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB

本指南聚焦 SpacetimeDB 实时协作绘图应用(Paint App)中的**权限管理(Permissions)**能力:画布创建者如何成为所有者、如何为协作者分配 viewer(只读)与 editor(可绘制)角色、角色变更为何能在毫秒级即时生效(甚至打断进行中的笔触)、以及被移除用户为何会立即被踢出。你将掌握一套可在 SpacetimeDB 中直接落地的角色数据模型、reducer 鉴权与客户端双端强制方案,其完整需求与实现均来自当前仓库的 Paint App 提示词体系 与对应 TypeScript 源码。

一、需求背景:08 级权限特性在整个提示词体系中的位置

Paint App 提示词体系(prompts/README.md)采用"特性积木 + 组合提示词 + 语言模板"的 DRY 结构:features/目录存放 16 个独立特性块,composed/目录存放 12 个逐级累加的组合提示词(01 basic → 12 full),language/目录存放语言/后端专用说明。本指南依据的 08_permissions.md 是第 08 级组合提示词,它在前 7 级(基础绘制、实时光标、图形、选择、图层锁定、在线状态、评论、版本历史)之上新增权限能力;对应的特性块是 features/09_realtime_permissions.md。使用方式为:将语言文件与组合提示词拼接,例如language/typescript-spacetime.md+composed/08_permissions.md,后端采用 SpacetimeDB TypeScript 模块、前端采用 React + Vite。

原文档对权限的需求可完整归纳为以下 5 条核心规则:

  • 画布创建者即所有者(owner);
  • 所有者可为协作者设置角色:viewer(仅可观看)editor(可绘制)
  • 角色变更即时生效——若用户正在绘制途中被降级为 viewer,其笔触立即取消;
  • 所有者可移除画布成员,被移除者立即被踢出
  • viewer 会看到 "View Only" 徽章,且所有工具被禁用;editor 无权修改权限或移除他人。

下面结合仓库中的真实实现,逐层拆解这套权限体系如何落地。

二、数据模型:用 CanvasMember 表承载角色状态

权限的核心状态落在成员关系表中。在仓库实现 schema.ts 中,CanvasMember表定义了画布与用户的成员关系,role字段取值限定为'owner' | 'editor' | 'viewer'

export const CanvasMember = table( { name: 'canvas_member', public: true, indexes: [ { name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'], }, { name: 'canvas_member_user_identity', algorithm: 'btree', columns: ['userIdentity'], }, ], }, { id: t.u64().primaryKey().autoInc(), canvasId: t.u64(), userIdentity: t.identity(), role: t.string(), // 'owner' | 'editor' | 'viewer' invitedAt: t.timestamp(), } );

几个值得注意的设计点:

  • 索引设计canvasIduserIdentity各建一个 B-tree 索引,支撑两类高频查询——"某画布的所有成员"(按画布过滤,用于成员列表与权限遍历)与"某用户的成员关系"(按身份过滤,用于订阅与个人视图)。
  • 画布表Canvas冗余ownerIdentity:同文件 schema.ts 中Canvas表记录了ownerIdentity,与CanvasMember.role = 'owner'相互印证。reducer 中大量以canvas.ownerIdentityctx.sender(当前调用者身份)比对来判定"是否是所有者"。
  • 创建即入会:在 create_canvas reducer 中,创建画布的同时向CanvasMember插入一条role: 'owner'的成员记录,并顺带创建默认图层Layer 1——保证"创建者即所有者"这一不变式从第一行数据开始就成立。

三、服务端强制:reducer 内的角色鉴权

SpacetimeDB 的 reducer 天然在服务端执行,所有写入操作的权限判断都发生在 reducer 内部,客户端无法绕过。仓库 index.ts 中封装了三个鉴权辅助函数,构成完整的权限判定链路:

function requireMember(ctx: any, canvasId: bigint): void { let isMember = false; for (const member of ctx.db.canvasMember.canvas_member_canvas_id.filter(canvasId)) { if (member.userIdentity.toHexString() === ctx.sender.toHexString()) { isMember = true; break; } } if (!isMember) throw new SenderError('Not a member of this canvas'); } function requireEditor(ctx: any, canvasId: bigint): void { let role = ''; for (const member of ctx.db.canvasMember.canvas_member_canvas_id.filter(canvasId)) { if (member.userIdentity.toHexString() === ctx.sender.toHexString()) { role = member.role; break; } } if (role !== 'owner' && role !== 'editor') { throw new SenderError('View-only access'); } }
  • requireMember:要求调用者是画布成员,否则抛SenderError。评论、聊天、版本快照等"成员级"操作调用它。
  • requireEditor:要求角色为ownereditor,viewer 会被拒绝,错误信息为 "View-only access"。所有绘制相关 reducer(add_strokeadd_shapeadd_text_elementupdate_shape、图层操作、clear_canvasundo/redo等)都在入口处调用它。
  • requireLayerEditable:在requireEditor之上叠加图层锁校验——若图层被其他用户锁定则拒绝写入,这是第 05 级"图层锁定"与第 08 级权限的联动点。

以笔触提交为例,add_stroke reducer 的入口两行即完成双重校验:requireEditor(ctx, canvasId)拦截 viewer,requireLayerEditable(ctx, layerId)拦截被他人锁定的图层,之后才执行插入、写撤销记录、记录活动日志与刷新画布活跃时间。

四、角色管理:只有所有者能改权限

权限变更本身也是 reducer,且必须校验调用者是画布所有者。仓库实现集中在 index.ts:

spacetimedb.reducer( 'set_member_role', { canvasId: t.u64(), memberIdentity: t.identity(), role: t.string() }, (ctx, { canvasId, memberIdentity, role }) => { const canvas = ctx.db.canvas.id.find(canvasId); if (!canvas) throw new SenderError('Canvas not found'); if (canvas.ownerIdentity.toHexString() !== ctx.sender.toHexString()) { throw new SenderError('Only the owner can change roles'); } if (memberIdentity.toHexString() === ctx.sender.toHexString()) { throw new SenderError('Cannot change your own role'); } for (const member of ctx.db.canvasMember.canvas_member_canvas_id.filter(canvasId)) { if (member.userIdentity.toHexString() === memberIdentity.toHexString()) { ctx.db.canvasMember.id.update({ ...member, role }); return; } } throw new SenderError('Member not found'); } );

对应地,remove_member reducer 执行"踢人":同样要求ownerIdentity === ctx.sender、禁止移除自己,删除CanvasMember记录后,还会级联清理该成员在本画布的CanvasPresenceCursor记录——这正是"被移除者立即被踢出"的实现基础:其在线状态与光标数据从公开表中消失,其他协作者通过订阅推送立刻看到其消失,后续任何写入请求都会因requireMember抛错而被拒绝。

从源码结构可以推断,"角色变更即时生效"存在两条路径:

  1. 读路径:所有写 reducer 每次都实时读取CanvasMember.role,不缓存、不过期——下一次写入请求到来时立即使用最新角色判定;
  2. 写路径set_member_rolectx.db.canvasMember.id.update(...)触发变更推送,所有订阅了canvas_member表的客户端立刻收到新角色。

这意味着即便用户正在拖动鼠标绘制,只要服务端处理完降级请求,其后续每一次update_cursor之外的真实写入(如add_stroke)都会被requireEditor以 "View-only access" 拒绝,实现"mid-stroke 取消"。

五、客户端强制:View Only 徽章与工具禁用

权限不能只靠服务端,客户端体验同样关键。仓库前端 client/src/App.tsx 中:

  • 角色判定:通过myMembership?.role === 'viewer'推导isViewer(App.tsx),即从订阅到的成员表数据实时计算,角色被降级后isViewer随之翻转。
  • View Only 徽章:在画布区顶部(App.tsx)与成员面板(App.tsx)渲染view-only-badge
  • 工具禁用:工具栏中除 select(选择)与 comment(评论)外,所有绘制工具按钮在isViewer时被disabled(App.tsx);绘制与编辑事件处理器(如 App.tsx、App.tsx)也以if (!conn || isViewer) return提前短路,双保险避免发出无意义请求。
  • 成员管理 UI:分享/成员模态框中,仅当isOwner且目标成员非 owner、非本人时,才渲染角色下拉框(Editor/Viewer)与移除按钮;变更通过conn.reducers.setMemberRole(...)conn.reducers.removeMember(...)调起服务端 reducer(App.tsx)。成员列表中每个成员展示role文本,viewer/editor 一眼可辨。

六、验收标准:权限特性如何被量化验证

grading_checklist.md 为权限特性定义了 4 条验收项:

  • Owner 可以设置角色;
  • Viewer 无法绘制;
  • 角色变更即时生效(标记 ⭐,作为 SpacetimeDB 差异化优势的测试点);
  • 踢人立即生效(同样标记 ⭐)。

其中 ⭐ 项是整套提示词体系刻意设计的关键差异化场景——它们要求"状态变更必须在下一笔操作/下一次渲染中立刻体现",这正是依赖服务端订阅推送与 reducer 内实时鉴权的 SpacetimeDB 擅长的部分,也是评测该后端实时能力的重要抓手。

七、总结:一套可复用的权限范式

从 08_permissions.md 的 5 条需求出发,Paint App 在 SpacetimeDB 上形成了完整的权限闭环:

需求落地位置(仓库相对路径)
创建者即 ownerschema.ts 的 Canvas/CanvasMember 表、index.ts 的 create_canvas
viewer/editor 角色分配index.ts 的 set_member_role
角色变更即时生效reducer 实时读取 role + 订阅推送,配合requireEditor拒绝写入
移除即踢出index.ts 的 remove_member 级联清理
View Only 徽章与工具禁用client/src/App.tsx 的 isViewer 分支
editor 无权限管理权set_member_role / remove_member 均校验ownerIdentity

这套方案的核心经验可以抽象为:权限状态存表、鉴权逻辑进 reducer、变更靠订阅即时扩散、UI 按角色分支渲染。四层各司其职——表结构保证"谁是什么角色"可查询、可订阅;reducer 保证任何写入都经过服务端权威判定;订阅推送保证角色翻转在毫秒级传导到所有协作者;客户端分支保证 viewer 从交互层面就被引导为只读模式。对于任何需要"多人协作 + 实时权限变化"的应用(文档、白板、编辑器),这套范式都可以直接借鉴。

【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 22:39:18

SpringCloud---Seata

(一).介绍SeataSeata是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata为用户提供了AT TCC SEGA 和 XA事务模式,打造了一站式的分布式解决方案,在介绍Seata之前,先要介绍一下什么是分布式事务…

作者头像 李华
网站建设 2026/9/14 22:39:04

iOS 蓝牙开发大坑!CoreBluetooth 多设备并发连接,这些隐性坑千万别踩

做 BLE 跨端开发的工程师应该深有体会:安卓蓝牙多设备并发调试相对自由,换到 iOS 的 CoreBluetooth,各种隐形限制能把人折腾崩溃。 很多人单设备调试一切正常,一旦同时连接 2 台、3 台 BLE 设备,就会遇到各种玄学问题&…

作者头像 李华
网站建设 2026/9/14 22:38:42

2025年全球洗碗机市场趋势与技术发展分析

1. 市场概况与数据解读根据QYResearch最新发布的行业报告显示,2025年全球洗碗机市场销售额预计将达到169.0亿美元。这个数字背后反映的是全球厨房电器市场正在经历的结构性变革。作为从业十余年的家电行业分析师,我认为这个预测数据具有坚实的市场基础。…

作者头像 李华
网站建设 2026/9/14 22:37:25

zcode的推广力度对我来说已经不香了

每天300万、500万token,因为不耐用,所以,很多skill, mcp, 以及 memory 都无法使用和识别。 如果为了每天这么5句提示词左右的用量就重新安装和切换,还是不如回到workbuddy 和Trae ; 为什么呢&am…

作者头像 李华