我们团队把在线文档从 WPS 切到 OnlyOffice 之后,第一个被产品经理盯上的需求就是:能不能像 WPS 那样,管理员一个按钮,把某个正在改文档的协作者实时变成只读。
OnlyOffice 的编辑器本身开放能力并不弱,初始化时就能在 config 里传 permissions 配置:每个打开文档的人可以是编辑、评论、审阅或只读。但这套权限有个隐坑——它是“打开时定稿”的。用户在编辑器里点开文档的一瞬间,权限就已经固定下来,后面想收回编辑权,常规做法只能让那个人重新打开文档,既不友好也没法做到“实时”。如果不刷新页面就能收回,很多人下意识觉得实现不了。其实 OnlyOffice 提供了一个很容易被忽略的动态权限命令:executeCommand('setPermissions'),它能在编辑器已经打开的情况下,实时调整当前会话的编辑、下载、打印等能力,效果上相当接近 WPS 协作里的“转只读”操作。
这篇文章我就把这套动态权限API从部署、初始化到命令调用、服务端配合、踩坑排查整个链路讲清楚。适合打算把 OnlyOffice 集成到自有系统里、又不想被权限设计坑害的后端同学,也适合正在给协作文档做“管理员强制只读”这类功能的前端和全栈工程师。
1. 动态权限API到底解决了什么问题
1.1 静态 permissions 配置的局限
OnlyOffice 在打开文档时,需要在初始化配置里塞一个 permissions 对象,控制这个编辑器实例里的人到底能干什么。典型的字段有这些:
| 权限字段 | 作用 | 说明 |
|---|---|---|
| edit | 是否允许编辑 | false 时打开就是只读模式 |
| download | 是否允许下载 | 控制下载原文件按钮 |
| 是否允许打印 | false 时隐藏打印按钮 | |
| review | 是否允许审阅模式 | 控制修订/审阅能力 |
| comment | 是否允许批注 | false 时不显示批注工具 |
| fillForms | 是否允许填写表单 | 对 xlsx/form 文档有效 |
| modifyFilter | 是否允许修改筛选器 | 主要针对表格文档 |
这套静态 permission 适合的场景是:文档权限在打开那一刻就确定了,比如普通人看合同、财务改表格、秘书批注流程稿。它的问题也显而易见——用户一旦把编辑器打开了,权限就绑定在这个会话上,后端数据库里哪怕把他改成只读,浏览器里那个人照样能继续打字。
我们之前遇到一个真实事故:运营同事把一份活动方案发给供应商协作,过程中发现对方在乱改,管理员只能把对方整体踢出去,但踢之前对方已经回车了一段错误内容。踢出去再重新登录是能解决,可是正在编辑的那个人还需要刷新页面,如果他那里有未保存的操作,还会弹一堆提示,体验很割裂。
1.2 动态命令的价值:不动页面,即时收回
动态权限API就是为了解决“已经打开的编辑器如何重新分配权限”这个问题。核心是executeCommand系列,包括调整权限、关闭文档、刷新历史记录等。
举个直观例子:
window.docEditor.executeCommand("setPermissions", { edit: false, comment: false, download: false, print: true });这段代码执行后,当前连接的编辑器会立刻进入“只能看、不能改”的状态:编辑工具栏禁用、右键菜单的修改项消失、保存按钮变灰。用户不需要刷新,不需要重新打开文件,视觉上就像 WPS 里被管理员切换成了“查看模式”。
这里的实时收回不是 WPS 云端那种“账号级别踢下线”,而是会话级别的。其优点是可以做到精细控制——文档里可能还有别人需要继续编辑,不需要一杆子打死整份文档。
1.3 为什么不直接改 config 里的 permissions
有同学会问:我重新调用一次new DocsAPI.DocEditor,给一个新的 permissions 配置,是不是也能达到效果?
能,但代价很大。重新初始化编辑器意味着整个 iframe 要重建,用户正在输入的内容、光标位置、滚动条位置全部丢失。多人协作时,重新加载还会导致其他用户看到“有人进出文档”的通知,甚至出现连接占用冲突。动态命令是在现有编辑器实例上操作,几乎是无感的。
所以理解两者的关系很重要——静态 permissions 决定“打开时默认是什么角色”,动态setPermissions决定“编辑过程中能不能中途换角色”。两者配合才能做出真正可用的权限系统。
2. 部署与初始化:动态权限API的运行前提
2.1 Docker 部署 OnlyOffice Document Server
要玩动态权限API,首先得有一台 OnlyOffice Document Server。我强烈建议自己部署而不是用公共 demo,因为权限接口、回调地址、JWT 密钥这些都是要绑定自己域的。
最省事的部署方式是 Docker。官方镜像onlyoffice/documentserver支持一键起服务,但目录映射和资源配置不能随便糊弄。我实际使用的基础命令是:
sudo docker run -d -p 80:80 -p 443:443 --restart=always \ -v /opt/onlyoffice/logs:/var/log/onlyoffice \ -v /opt/onlyoffice/data:/var/www/onlyoffice/Data \ -v /opt/onlyoffice/db:/var/lib/onlyoffice \ -v /opt/onlyoffice/cache:/var/lib/onlyoffice/db/cache \ -e JWT_ENABLED=true \ -e JWT_SECRET=my_strong_secret_key \ --name onlyoffice-ds \ onlyoffice/documentserver:latest这里特别提示:数据目录一旦跑起来就不要乱动,尤其是/var/lib/onlyoffice/存放了文档缓存和 PostgreSQL 数据,删掉等于把之前在线编辑的历史记录全部清空。另外JWT_SECRET必须记好,后面前端生成编辑器配置 token、后端验证回调时都要用到同一个密钥。
启动后访问http://your-ip/healthcheck,如果返回一串true字符,说明服务正常了。我第一次部署时卡在这里很久,排查才发现是 80 端口被 nginx 占用了,导致的假死现象。如果遇到端口冲突,建议把外部端口映射成 8080、8443 这类非常规端口,但注意前端集成时跨域策略要跟着调整。
2.2 Vue3 项目里初始化编辑器
OnlyOffice 官方没有 React/Vue 二开封装得特别舒服的包,最常见的方式是自己管理生命周期:在页面里引入 api.js,然后创建编辑器。
以 Vue3 为例,初始化代码大概是这样的:
// 引入文档服务器上的 api.js // <script src="http://your-oc-server/web-apps/apps/api/documents/api.js"></script> const globalDocEditor = ref(null); function openDocument(fileUrl, fileKey, userInfo, docPermissions) { const config = { document: { fileType: 'docx', url: fileUrl, title: '合作协议.docx', key: fileKey, permissions: { edit: docPermissions.edit ?? true, download: docPermissions.download ?? true, print: docPermissions.print ?? true, comment: docPermissions.comment ?? true, review: docPermissions.review ?? false, } }, documentType: 'word', editorConfig: { lang: 'zh-CN', callbackUrl: 'https://api.yourapp.com/callback', user: { id: userInfo.id, name: userInfo.name, } }, token: generateEditorToken(fileKey, userInfo, docPermissions) }; globalDocEditor.value = new DocsAPI.DocEditor('editor-placeholder', config); }这里的token不是登录 token,而是用 OnlyOffice 的 JWT 规则对 config 对象签名后得到的字符串,用来防止用户篡改权限。也就是说,用户传过来的permissions如果和 token 不一致,文档服务器会拒绝加载。
一个容易踩坑的地方:文档 key 的生成。OnlyOffice 会拿 key 当文档缓存的标识。同一个文件不管打开多少次,key 应该保持一致,这样历史记录和协同状态才能串起来。如果你每次打开都重新随机一个 key,等于每次都是“新文档”,动态权限、历史版本全部对不上。
2.3 集成时需要知道的访问域名问题
OnlyOffice 编辑器默认会对 document.url 做域名校验:如果文档服务器的地址和打开页面的域名不一致,会拒绝加载或白屏。开发环境最常见的问题就是这个。
解决办法有两个方向:
- 把文档服务要访问的域名加到 Document Server 白名单,官方支持通过环境变量配置;
- 更省心的是让前端页面的域和 OnlyOffice 服务的域一致,或者用反向代理把两者统一。我自己用的是 Nginx 代理,
/onlyoffice路径转发到文档服务器,前端和编辑器都在同一个主域下,跨域问题直接消失。
3. setPermissions 命令的实操拆解
3.1 命令语法与字段选择
动态权限最重要的一条命令就是setPermissions,调用方法统一走executeCommand:
docEditor.executeCommand("setPermissions", { edit: false, download: false, print: true, review: false, comment: false, fillForms: false, modifyFilter: false });刚开始我把这个命令当成“全量替换权限”,后来发现它更像是“当前会话的状态机切换”。比如我想让当前用户只保留批注权,可以这样:
docEditor.executeCommand("setPermissions", { edit: false, download: true, print: true, review: false, comment: true });执行后,用户不能再修改正文,但还能加批注。这个组合非常适用于“管理层审阅修改稿”的场景。
各字段的生效范围不一样,尤其要注意:modifyFilter只对 xlsx 类型文档有意义,写在 docx 里不会报错,但也不会有感知变化。fillForms主要影响包含表单域的文档,普通文档直接用不上。如果对这些字段理解不透,上线后很容易出现“明明设置了权限,按钮还在”的诡异现象。
3.2 执行时机的掌握
很多人调用setPermissions后没反应,不是命令写错了,而是调用时机不对。关键原则是:必须等编辑器内部加载完再调用。如果用户刚打开页面,api.js 还在初始化,你直接executeCommand,很多版本会直接报“editor is not defined”或者干脆静默失败。
稳妥做法是在onAppReady回调之后执行。初始化时可以挂事件:
const docEditor = new DocsAPI.DocEditor('editor', config); docEditor.on('onAppReady', () => { // 此时才能安全执行命令 });另外,setPermissions改变的是当前已连接会话的权限。用户新开一个标签页打开同一份文档,还是会走初始配置的 permissions 和 token。这个认知很重要,否则就会出现“我把某用户改成只读,结果他关掉页面重新打开,又变成可编辑了”的问题。
3.3 动态权限对协同其他用户的影响
很多协作功能是基于权限的,比如历史记录、修订、批注。动态切换到只读后,其他在线用户会感知到这个人的角色变化吗?答案是:OnlyOffice 的协同机制里,人物状态会有一个“半实时”同步,但如果想让对方的状态在聊天栏里立即显示为“只读查看者”,还是建议配合服务端的用户信息推送。
在实际多人编辑中,如果 A 用户被动态切为只读,他之前输入了一半的内容会保留在文档里,但之后无法继续输入。如果当前那个位置还有未提交的改动,编辑器不会自动帮他保存。所以“实时收回编辑权”业务上要处理“当前未保存内容”的问题,不能只靠一条命令结束战斗。
我自己在项目里的做法是:强制只读前先触发一次保存动作,把当前改动落盘,然后再调用setPermissions。这样既保住用户的工作成果,又完成了权限收口。
4. 服务端同步与回调校验:防止绕过是重中之重
4.1 为什么不能只靠前端命令
setPermissions只是前端命令,它可以改变浏览器里的 UI 状态,但无法改变后端托管在 OnlyOffice Document Server 里的真实验证逻辑。理论上,一个懂行的人完全可以用控制台手工构造请求,绕过前端限制去尝试保存。
所以我在项目里反复强调:动态权限API是“体验层”,服务端校验才是“安全层”。两端必须配合。
OnlyOffice 的保存过程会向后端配置的callbackUrl发一个回调请求,里面带上 status、url、key、users 等信息。主要状态有这些:
| status | 含义 | 处理动作 |
|---|---|---|
| 1 | 一个用户开始编辑/文档有变动 | 可以记录日志 |
| 2 | 文档已准备好保存 | 根据 url 下载文件内容,存到自己的存储 |
| 3 | 保存发生错误 | 记录错误,排查 |
| 4 | 用户关闭文档/连接断开 | 可以清理会话记录 |
如果只在浏览器里把按钮隐藏了,但回调接口还无条件把所有保存请求都接受,用户依然可以通过构造回调请求把内容提交上来。所以服务端必须维护一份“当前用户是否允许编辑”的名单,在回调里校验调用者身份,不允许编辑的用户抛异常。
4.2 服务端强制只读的落地方式
我用的方案是:在生成编辑器配置的阶段,就把“是否允许编辑”体现进 token 里。后端在构建 config 时,根据用户权限生成对应的 token,签名后返回前端。当用户被标记为强制只读后,后端接口立刻拒绝给他签发“可编辑”的 token。
伪代码大概这样:
def build_editor_config(request): user = get_user(request) can_edit = user.can_edit and not user.force_readonly permissions = { "edit": can_edit, "download": True, "print": True, "comment": True, "review": False, } config = { "document": { "fileType": "docx", "url": file_url, "key": file_key, "permissions": permissions }, "documentType": "word", "editorConfig": { "lang": "zh-CN", "callbackUrl": callback_url, "user": {"id": user.id, "name": user.name} } } config["token"] = sign_jwt(config, jwt_secret) return config动态权限命令负责让已经在浏览器里的人“现在立刻失去编辑能力”,服务端 token 则保证他下次打开文档、保存文档时,同样没有编辑权限。两个机制缺一个,权限体系都不完整。
4.3 保存回调里的校验
就算文章是只读的,OnlyOffice 也可能把一些状态变更的消息发到回调 URL,比如 status=1 表示有人打开了。所以不能简单地把“status=2 才校验”。
我的建议是:回调接口统一做三件事。
第一,验证签名。JWT 签名不对直接返回{"error":1},别做任何业务处理。
第二,根据 key 反查文档信息,再根据回调请求里的用户 ID 判断该用户当前是否在强制只读名单里。
第三,如果用户被强制只读,除非保存内容来自上一次可编辑期间的合法变更,否则拒绝落库。更简单的做法是只记录日志,不更新正式文档。
实战中我踩过一个大坑:用户先以编辑身份打开文档,我后台把他改成只读,但他在被改之前点了保存,回调顺序是“保存成功 -> 变只读”,这不丢数据。但如果回调接口不做校验,就会出现“用户已经被管理员封禁为只读,结果之前点击保存的请求还在后台被正常处理”的问题,数据更新的时间顺序完全乱套。所以服务端一定要以“当前时刻的权限”作为保存判断依据,而不是请求发出时的权限。
5. 像 WPS 一样完整实现“管理员收回编辑权”
5.1 前后端联动的完整流程
要模拟 WPS 那种“管理员点一下,对方马上变只读”的体验,单靠前端写死按钮是不够的,至少需要一条推送链路。
我给一个参考工作流:
- 管理员在后台系统点击“强制只读”按钮;
- 后端更新数据库里的用户权限状态,标记
force_readonly=true; - 后端通过 WebSocket 或消息推送到对应用户的浏览器会话(因为文档服务器和后端业务服务是两个系统,一般走自己的 Socket 服务);
- 用户浏览器收到消息后,调用
docEditor.executeCommand('setPermissions', {edit: false, ...}); - 同时前端界面上弹出一个小提示,比如“管理员已将你切换为只读模式”;
- 用户后续任何打开文档的请求,后端生成 token 时都不会再给
edit: true。
推送消息可以设计成:
{ "type": "force_readonly", "fileKey": "agreement-2025", "reason": "文档已进入收稿阶段", "timestamp": 1731234567890 }客户端收到后执行:
socket.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'force_readonly') { docEditor.executeCommand('setPermissions', { edit: false, comment: false, download: true, print: true }); showToast('管理员已将你切换为只读'); } };这套流程做完,体感上已经很接近 WPS 的实时权限回收了。
5.2 只针对单个用户而不是所有人
setPermissions这条命令有个特点:它是对当前编辑器实例生效的。也就是说,如果一个文档同时开着多个用户的编辑器,你在某个用户浏览器里执行命令,只会让这个实例变成只读,不会影响其他人。
这个特性其实是好事,可以做精确控制。管理员想踢的是 A 用户,那就只往 A 的 WebSocket 发消息,其他协作者的编辑器不受影响。如果所有用户的编辑器都被执行了setPermissions,那就相当于整篇文档变成了“全局只读”。
记得维护一份在线用户与会话 ID 的映射关系。OnlyOffice 的用户标识是用editorConfig.user.id传过去的,我们后端推送时可以根据这个 ID 找到对应的 WebSocket 连接,实现精准定向。
5.3 历史记录和批注权限的细节
动态权限里比较容易忽略的是历史记录。如果用户从可编辑变成只读,他想查看历史修改记录,很多版本默认是允许的。业务上,如果“只读”不代表“可以追溯”,就可能出现越权看到敏感修改过程的情况。
想彻底一点,可以把 review 也置为 false。因为历史记录在 OnlyOffice 里通常跟审阅权限挂钩。设置示例:
docEditor.executeCommand("setPermissions", { edit: false, review: false, comment: false });批注是另一个重灾区。把 edit 设置成 false 之后,用户可能还能新建批注。有些场景这是合理的(收稿后仍希望收集意见),但如果你希望彻底清空对方的操作能力,一定要把 comment 也设置成 false。
顺带回答一个经常被问到的问题:能不能通过 API 直接拿到 OnlyOffice 里的批注列表?实践中没有特别优雅的官方开放接口,一般靠两种方式:一是开放导出,把文档导出成带批注的文件再人工解析;二是改造会话,把批注数据通过回调或额外存储同步出来。如果项目要求比较轻,我会优先让用户在 OnlyOffice 界面里管理批注,不要过度承诺 API 能力。
6. 常见问题排查与避坑实录
6.1 权限命令不生效的几种原因
| 症状 | 常见原因 | 验证方法 |
|---|---|---|
| executeCommand 报错 | 编辑器还没就绪 | 检查是否在 onAppReady 之后调用 |
| 设置了 edit:false,但双击文字还能编辑 | 权限字段传入错误 | 确认字段名是edit,不是editable |
| 表格里还能筛选 | 忘记设置 modifyFilter | 对 xlsx 需补modifyFilter: false |
| 只读后还能下载原文件 | 没把 download 设为 false | 检查命令参数 |
| 用户重新打开文档又恢复可编辑 | 初始配置 permissions 仍为 true | 必须改服务端 token 和初始 config |
初期集成时最常见的问题就是把动态命令当作“服务端权限修改”。其实它只是改前端会话状态,后端不跟着改,重开就恢复了。一定要记住这句结论:动态权限只是临时状态,真正的持久权限在服务端生成配置和回调校验里。
6.2 部署与 JWT 的深坑
只要涉及 OnlyOffice,JWT 绝对是最大坑位。前端生成 config token、文档服务器配置 JWT、后端校验回调,这三个环节必须用同一个 secret,少一个都会出问题。
我遇到过Invalid token错误,第一反应是检查 JWT_SECRET 是否一致,结果不是。真正原因是前端生成的 token 是用 JSON.stringify(config) 签名的,而我在后端写回调校验时错误地把整个 config 又包了一层。所以调试这类问题别急着怀疑环境,先用官方推荐的方式确认签名原材料是不是同一个对象结构。
Docker 部署时还有一个经验:动态权限命令如果发现“没有任何反应”,先检查文档服务器和你的业务系统是不是同域。跨域 cookie、iframe 安全策略都会导致 JS 执行不到编辑器实例上。
6.3 线上事故案例:误把所有人踢出去
有次我们环境出问题时,我为了方便全项目测试,直接写了个接口对所有在线用户执行executeCommand("close"),想着模拟“文档被强制关闭”。结果那条命令把整份文档的所有编辑会话直接关掉了,正在编辑的人弹了保存提示,没保存的内容差点丢。
如果只是想“收回编辑权”,千万不要用 close 来代替 setPermissions。close是结束整个编辑会话,没有后续重新授权机制;setPermissions才是在保留会话的前提下调整能力。顺序应该是:先触发保存 -> 再执行 setPermissions -> 如果实在需要强行关文档,再考虑 close。
6.4 性能与缓存问题
动态权限API本身性能开销很小,真正影响体验的是文档服务器缓存。比如你给用户切回只读后,他重新打开文档,还是能改,很多时候不是权限没生效,而是文档缓存让他读到了旧配置。
解决办法是在服务端生成新的文档 key。只要 key 变了,OnlyOffice 会认为这是一次新会话,重新获取配置。但要谨慎:key 一变,历史记录和协同上下文也会重置,所以这不是随意频繁使用的操作。
内存方面,OnlyOffice 容器在多人并发打开文档时容易吃满。权限动态切换不会显著增加内存压力,但如果你的服务器只有 2G 内存,建议把内存上限调到 4G,否则会出现文档打开到一半卡死,权限命令也执行不了的连锁故障。
7. 落地设计的一些个人建议
跟动态权限API打交道这么久,我的体会是:不要把setPermissions当成一个单独的魔法接口,它应该被设计成“权限服务”的一部分。你需要先有后台的角色管理系统、用户在线状态系统、WebSocket 推送机制,然后再用 OnlyOffice 的这套命令去落地交互层。
我给后来者的建议是,在开发环境把“管理员强制只读”功能做成一个调试按钮,放在编辑器页面的侧边栏。它的价值不止是演示,更在于能快速验证每个权限字段的真实效果。我当年就是靠这个按钮,才搞清楚modifyFilter和comment在不同文档类型上的差异。
如果项目里还有 Moodle 或 Vue 这类集成场景,优先把 token 生成逻辑抽成公共服务,不要散落在各个页面。动态权限是一条命令的事,但围绕它的业务闭环往往会牵连到文档 key、回调地址、用户白名单、历史版本等一串设计。把这些基础打好,后续再做单项权限回收、批量回收、定时自动回收都会顺畅很多。