接到这个需求的时候,对方开口第一句话就是:能不能像WPS那样,我把文档链接发给别人,他正改到一半,我这边收回编辑权,他那边马上就只能看不能改了?这句话听着简单,背后其实是OnlyOffice动态权限API里最容易被低估的一件事:文档的编辑权不是打开那一刻定死的,而是要能随时被服务端收回。很多人装了OnlyOffice、连上了编辑页,就开始找“收回编辑权”的按钮,结果发现产品里根本没有这个按钮。原因为何?因为OnlyOffice的思路和WPS不太一样:WPS把“分享—权限—收回”做成了一个完整的管理闭环,而OnlyOffice只把最底层的权限组件交给你,至于“什么时候收回、收回给谁、收回后怎么踢掉正在编辑的人”,全靠你用动态权限API自己拼。
这篇内容不打算给你一堆文档翻译,而是直接按我实际落地的路径走一遍:先拆OnlyOffice的权限层级,再讲动态权限API怎么设计,接着给出Docker部署和核心代码,最后把审批回收、定时截止、离职踢人、批量收权这些常见场景全部复现一遍。适合正在做私有化在线编辑、知识库、OA审批、教学平台的朋友,无论你是后端还是前端,照着这套思路走,基本能把“实时收回编辑权”这件事稳稳接住。
1. 先说清楚:OnlyOffice的权限到底卡在哪一层
1.1 从“链接分享”说起,WPS的理念对应到OnlyOffice是什么
WPS的“收回编辑权”在产品上是非常直觉的:你点开分享面板,看到“任何人可编辑”“指定人可编辑”“仅查看”三个选项,然后直接“停止分享”,对面正在编辑的人很快就会失去编辑能力。这件事体验很轻,但底层要同时做三件事:改权限状态、通知在线客户端、让旧会话失效。OnlyOffice不是没有这些能力,而是把它们拆散了。
OnlyOffice的文档权限,拆出来至少包括这几块:初始化打开文档时传给编辑器的permissions配置、编辑会话用的document.key、JWT签发的访问令牌、服务端保存时的callbackUrl回调,以及你和OnlyOffice服务之间的文件存储鉴权。所谓“动态权限”,说白了就是把你自己的业务数据库当权限中心,在需要的时候同时改掉这几块的东西,让权限变化真正生效。WPS把这一切藏在产品背后,OnlyOffice把这一切裸露给你,你得自己把这些零件拼起来。
1.2 你以为只调用一个接口,实际要处理四层
刚接触OnlyOffice的时候,我犯过一个典型的错误:想着“收回权限”应该就一个API,调用完状态就变了。真上手才发现,最少得考虑四层。
| 层级 | 控制对象 | 收回后要达到什么效果 | 主要手段 |
|---|---|---|---|
| 编辑器配置层 | 工具栏、编辑交互、批注开关 | 页面变成只读、下载、打印按钮消失 | config.document.permissions动态生成 |
| 会话层 | 已打开发布器的在线用户 | 当前正在编辑的人马上被断开或重载 | WebSocket推送 +destroyEditor() |
| 服务鉴权层 | 后端接口、文件存储地址 | 无法通过接口继续保存或下载 | 自己在网关/服务端校验权限 |
| 数据层 | 文档版本、历史快照 | 历史版本、批注不可再访问 | 文档存储服务和版本列表统一加上鉴权 |
这四层缺一不可,尤其是服务鉴权层。很多人只改了最上面一层,结果用户刷新一下页面,又重新拿到一份可编辑的配置,等于白干。真正到生产环境,我建议把四层都想清楚,再动手写代码。
1.3 适合落地的场景,以及谁需要这篇
动态权限API不是给“个人玩玩在线Office”准备的,它天然适合那些“文档权限经常变化”的业务系统:
- 审批流:合同、报告审批通过后,起草人不能再改动内容。
- 投标/考试:截止时间一到,所有参与人自动变为只读。
- 人员变动:员工离职或转岗,名下所有文档立即锁写。
- 教学平台:Moodle里作业提交截止后,学生不能再编辑提交内容。
如果你正在做这类系统,这篇实操会非常适合你。你不需要很深的OnlyOffice源码功底,只需要有Docker基础,会写一点后端接口和前端事件处理,就能把整套逻辑接起来。
2. 动态权限API的完整设计:从打开文档到实时收回
2.1 一切从config和document.key开始
OnlyOffice每次打开一个文档,都要传一个document.key。这个key很重要,它是OnlyOffice用来识别“当前文档版本”的标识。同一个key,OnlyOffice会认为你还是那个文档,会优先复用缓存;如果你换了key,它就当成一个新文档重新处理。
做动态权限的第一件事,就是别让这个key只存在OnlyOffice的内存里,而是把key和权限状态全部落到自己的业务库。
我习惯建一张doc_permissions表,结构大致是这样:
CREATE TABLE doc_permissions ( id BIGINT PRIMARY KEY, doc_key VARCHAR(128) NOT NULL, user_id VARCHAR(64) NOT NULL, can_edit BOOLEAN DEFAULT TRUE, can_comment BOOLEAN DEFAULT FALSE, can_download BOOLEAN DEFAULT FALSE, can_fill_forms BOOLEAN DEFAULT FALSE, expire_at TIMESTAMP NULL, revoke_version INT DEFAULT 0, updated_at TIMESTAMP DEFAULT NOW() );这里面的doc_key就是传给OnlyOffice的document.key,revoke_version这个字段尤其值得注意。它不是权限字段,而是一个“权限版本号”。每次收回或修改权限,这个版本号就加一。后面做实时推送时,前端就是靠它判断“当前打开的版本是不是已经过期了”。
2.2 编辑URL里的permissions:只做第一次限定
生成在线编辑URL的时候,权限配置长这样:
{ "document": { "key": "doc_20250101_001", "permissions": { "edit": true, "download": false, "print": true, "review": true, "comment": false, "fillForms": true } }, "editorConfig": { "callbackUrl": "https://your-server/onlyoffice/callback", "user": { "id": "u_1001", "name": "张三" } }, "token": "登录后动态生成的JWT" }几个字段的含义不复杂:edit决定能不能编辑正文,comment决定能不能加批注,fillForms决定能不能填写表单域,review决定能不能开修订模式。注意,这个配置生效的时机是“打开文档时”。也就是说,已经打开的编辑器不会因为你改了这个配置就立刻变化。这恰恰是很多人说“动态权限没用”的原因,其实不是没用,是你只用了第一层,没有继续往下做。
2.3 实时收回的真正发动机:业务WebSocket + destroyEditor
想要“像WPS一样,对面正改到一半,我这边一收回,他那边马上变成只读”,最靠谱的方案是自建一条业务WebSocket通道。流程是:
- 后端接口收到“收回权限”的请求。
- 更新
doc_permissions表中的权限状态,同时让revoke_version加一。 - 向这个用户、这个文档所在的WebSocket房间推送一条消息,内容大概是
{type: 'revoke', docKey, revokeVersion}。 - 前端收到消息后,先提示用户“文档权限已被收回,正在切换为只读”,然后调用
destroyEditor()销毁当前编辑器实例,再用新的只读配置重新打开文档。
前端在Vue3里的大致写法:
const docEditor = window.DocsAPI.DocEditor('doc-container', config) ws.onmessage = (event) => { const msg = JSON.parse(event.data) if (msg.type === 'revoke' && msg.docKey === currentDocKey) { docEditor.destroyEditor() openReadOnlyVersion(currentDocKey) } }destroyEditor()会让当前编辑器销毁,正在编辑但未保存的内容会有丢失风险,所以更稳妥的做法是先在前端主动保存一次,或者等OnlyOffice自动保存的间隙再销毁。我在项目里通常会让前端收到消息后先弹一个3秒的倒计时提示,给用户一点手动保存的时间,倒计时结束再强制销毁并切只读。这个细节在业务上是加分的,用户不会觉得权限被“莫名其妙踢了”。
2.4 服务端兜底:收回的最终判定不能写在编辑器里
前端的destroyEditor()、配置里的permissions,都只是“体验层”的控制。真正防绕过,必须在后端回调里做最后一道闸。
OnlyOffice在用户保存文档时,会向callbackUrl发一个POST请求,状态status=2表示用户已保存,status=4、6等表示安全保存。你在服务端处理这个回调时,要再检查一次这张文档、这个用户当前是否还有编辑权限。如果已经被收回,就直接返回错误,让OnlyOffice认为这次保存不合法,或者只把它存成一份只读副本,不覆盖原文档。
我用Python写过一个简化版:
@app.post("/onlyoffice/callback") def onlyoffice_callback(req: dict): doc_key = req.get("key") user_id = req.get("user", {}).get("id") status = req.get("status") if status in (2, 4, 6): perm = db.get_doc_permission(doc_key, user_id) if not perm.can_edit: return {"error": 1, "message": "permission revoked"} save_file(doc_key, req.get("url")) return {"error": 0}只有这一层守住了,才能真正防止“前端被绕过”的情况。因为OnlyOffice保存文件的动作,本质上还是把你的服务端当成了文件最终落地的唯一入口。
3. 核心代码与部署实操:Docker跑起来,然后接动态权限
3.1 Docker部署OnlyOffice Document Server
动态权限API的前提是先把OnlyOffice服务本身跑通。最省事的方式永远是Docker,官方镜像一条命令就能拉起来:
docker run -i -t -d \ --name onlyoffice-documentserver \ --restart=always \ -p 8080:80 \ -v /srv/onlyoffice/logs:/var/log/onlyoffice \ -v /srv/onlyoffice/data:/var/www/onlyoffice/Data \ -v /srv/onlyoffice/lib:/var/lib/onlyoffice \ -v /srv/onlyoffice/db:/var/lib/postgresql \ onlyoffice/documentserver:latest几个关键点我得单独说:
- 端口别直接用80,除非你确定机器上没有别的Web服务,否则后面和Nginx、Moodle抢端口很麻烦。
- 容器启动后大概要等30秒左右才能完全就绪,这时候立刻访问页面会打不开,别急着排查半天。
- 磁盘目录最好都给持久化,容器删了重建,文档和历史记录不至于丢。
如果遇到OnlyOffice安装问题,十有八九都是容器启动顺序、端口占用、内存不足这三类。容器内存建议至少给2GB,低于这个数,打开大文档时会频繁变卡甚至崩溃。
3.2 JWT签名与token生成
新版OnlyOffice默认开启了JWT鉴权,你在生成编辑配置时,得把整个配置对象用JWT签名,然后放到token字段里。具体密钥就是你部署容器时设置的JWT_SECRET,两边不一致就没法通过校验。
用Node生成token很简单:
const jwt = require('jsonwebtoken') function buildEditorToken(config) { return jwt.sign(config, process.env.JWT_SECRET, { algorithm: 'HS256', expiresIn: '5m' }) }这个token的有效期建议设短一点,比如5分钟。短token的好处和动态收回是绝配:就算某个已打开的编辑器没有被前端销毁,只要token过期,它想再去OnlyOffice服务端拉取新数据或保存,都会被拒掉。对于“收回权限”这个需求来说,多一层短生命周期校验,就多一分控制力。
3.3 实现收回权限的API
收回权限的接口,本质上就是“改库 + 推消息”。我用Node写过一个很简洁的版本:
app.post('/api/doc/revoke', async (req, res) => { const { docKey, user, version } = req.body await db.transaction(async tx => { await tx('doc_permissions') .where({ doc_key: docKey, user_id: user }) .update({ can_edit: false, revoke_version: version + 1 }) }) wsServer.to(`${docKey}:${user}`).emit('revoke', { docKey, user, revokeVersion: version + 1 }) res.json({ ok: true }) })这里有两个细节值得留意。第一,revoke_version不能只存在前端,必须以数据库字段为准,因为前端刷新后可能断线重连,它需要拿着最新的版本号去向后端要新的编辑器配置。第二,WebSocket消息只管“在线的人”,离线用户不需要实时踢,等他们下次打开时,后端读取到的can_edit已经是false,自然就变成只读。所以接口设计时,实时推送和下次打开鉴权是两条独立但并行的链路。
3.4 与Vue3、Moodle的实际对接点
Vue3接入OnlyOffice,我习惯不在npm包里折腾复杂封装,直接用官方提供的DocsAPI全局对象:
<script setup> import { ref, nextTick, onBeforeUnmount } from 'vue' const docContainer = ref(null) let docEditor = null async function openDocument(docKey) { const res = await fetch(`/api/doc/${docKey}/editor-config`) const config = await res.json() await nextTick() docEditor = window.DocsAPI.DocEditor('doc-container', config) } onBeforeUnmount(() => { if (docEditor) { docEditor.destroyEditor() } }) </script> <template> <div ref="docContainer" id="doc-container" style="height: 100%"></div> </template>如果业务平台是Moodle,OnlyOffice官方是有Moodle插件的,安装后可以在活动里配置“可下载”“可打印”“可编辑”这些初始选项。但说实话,Moodle插件的权限基本是创建活动时定死的,截止日期之后能不能自动收回,插件本身不管,需要你自己在Moodle的定时任务里,或者课程关闭事件里,调用类似上面那个/api/doc/revoke接口,把权限和插件配置一起改掉。很多Moodle集成翻车,不是OnlyOffice服务有问题,而是插件和动态权限API没有打通。
4. 像WPS一样收回链接与权限:四种常见业务场景复现
4.1 审批通过后立即变成只读
这是我在OA系统里最常遇到的场景:申请人在线编辑一份请示报告,审批人一点“同意”,报告就应当立刻锁定,谁也不能再改。
实现上,我把“审批通过”事件串成一条链路:
| 步骤 | 执行方 | 具体动作 |
|---|---|---|
| 1 | 业务后端 | 审批流到达终态,查出该文档相关的所有用户 |
| 2 | 业务后端 | 更新doc_permissions,把can_edit置为false |
| 3 | WS服务 | 向所有在线编辑者推送revoke消息 |
| 4 | 前端 | destroyEditor()后按只读配置重新打开 |
| 5 | OnlyOffice回调 | callbackUrl校验不通过,拒绝未授权保存 |
这里最容易忽略的是步骤4里的提示文案。直接把编辑器销毁会显得很“粗暴”,体验上不如先提示“审批已通过,文档已锁定”,再给用户几秒钟保存时间。别小看这个交互,很多时候业务方最在意的不是技术多牛,而是“我们的人用起来会不会突然被踢蒙”。
4.2 截止时间自动回收
截止时间回收,属于“定时任务 + 动态权限”的经典组合。比如投标文件、考试答卷、订单报价,业务上经常要求某个时间点之后完全变成只读。
我的做法是加一个定时扫描任务:
UPDATE doc_permissions SET can_edit = false WHERE expire_at < NOW() AND can_edit = true;但光改库不够,因为正打开着编辑器的用户不会感知到数据库变化。所以在定时任务执行完数据库更新之后,还要把“已过期文档”里所有在线用户筛出来,统一发一轮WebSocket消息。有时候用户就算收到了消息,也不一定会立刻被销毁,因为网络抖动可能导致WS消息迟到。因此前端收到revoke后,一定要向后端确认一次当前权限版本号,确认无误再执行只读切换。
4.3 人员离职或转岗后立即断开会话
人员权限调整和文档级收权最大的区别在于:你要处理的不是一张文档,而是这个人名下所有文档。组织架构系统推送离职事件之后,后端要批量查出该用户的全部doc_key,逐个更新权限,并向其所有正在编辑的会话推送断开消息。
SSO单点登录在这里也有作用。OnlyOffice编辑器内部认得是editorConfig.user.id,而离职员工随时可能带着旧会话再次尝试打开文档。所以“人员失效”不仅要处理在线会话,还必须让SSO登录态失效。我做过的项目里,离职事件触发后,会把该用户在网关层加入黑名单,任何到/onlyoffice/*的请求都直接拒绝。这一步不做,前面代码写得再漂亮,别人换个浏览器照样可能打开旧链接。
4.4 批量收权:从单份到文件夹级
真正到了项目中期,你会发现“按单个文档收权”只是开始,业务方最后一定会要求“按文件夹批量收权”。OnlyOffice本身没有文件夹的概念,文档权限的粒度就是document.key,所以文件夹级收权必须在自己的业务系统里做。
我的方案是建一张“文件夹权限映射表”,存文件夹ID、用户ID、权限级别,生成编辑器配置时先解析出该文档属于哪个文件夹,再去查询映射表得到最终权限。批量收权的接口这样设计:先找到文件夹下所有文档,再逐个调revoke逻辑。这个模式在知识库、网盘类产品里几乎是标准做法,别指望OnlyOffice会帮你维护层级关系。
5. 常见问题与排查技巧实录
5.1 部署相关的坑:Docker、字体、端口
很多OnlyOffice部署问题,Docker一重启就消失了,但有些坑是隐藏很深的:
- 中文乱码:Linux容器里默认没有中文字体,打开中文文档全是方块。解决办法是挂载宿主机的字体目录进容器,比如
-v /usr/share/fonts:/usr/share/fonts,或者在容器里安装fonts-wqy-zenhei等中文字体。如果你用的是国产Linux系统,仿宋、黑体这些字体也要一起挂载进去,不然公文类文档渲染出来的效果很难看。 - 回调地址用了
localhost:OnlyOffice容器内部访问不到业务后端,回调就收不到。部署时务必把callbackUrl写成宿主机在内网可达的地址,不要写localhost。 - 端口冲突:Docker映射的端口如果被其他服务占用,容器虽然在跑,但页面就是打不开。建议启动前先用
ss -lntp确认端口空闲。
5.2 token与回调不生效的问题
动态权限涉及两段网络交互:一段是你自己后端生成token给前端,一段是OnlyOffice保存时回调你的后端。这两段也最容易出问题。
如果前端打开编辑器时报“token无效”或“Invalid token”,先检查你生成token时用的JWT_SECRET和Docker容器里配置的是不是同一个。很多项目配置了两次密钥,前后端各改各的,结果对不上。
如果callbackUrl收不到回调,先看OnlyOffice服务端日志,同时确认你返回的响应体是不是规范的结构{"error":0}。OnlyOffice对回调的响应格式非常敏感,返回其他格式它会认为回调失败。
5.3 批注与历史版本在动态权限下的“半残疾”状态
网上经常有人问“API怎么取OnlyOffice的批注”“OnlyOffice代码怎么查看历史修改记录”,说实话,开源版本的OnlyOffice在这两块能力并不完整。开源版没有一个方便取批注的HTTP接口,你要拿批注,常见做法是用格式转换服务导出文档,再解析docx里的comments.xml。格式转换时,有一个参数叫assemblyFormatAsOrigin,意思是以原格式为准进行组装。如果转换后发现批注、修订记录丢了,很大概率就是这个参数没有设置为true。
历史修改记录也一样,开源版没有内置的版本对比界面。如果你要动态权限收回后还能追溯历史版本,建议在每次callbackUrl收到安全保存状态时,把当时的文档文件快照存一份到自己的对象存储或NAS里。权限收回了,历史版本的访问入口也要一起锁住,不然用户还能通过老版本链接看到内容,等于收权收了个寂寞。
5.4 收权后仍然能保存或覆盖,怎么办
这是动态权限落地时被问得最多的问题:“我明明把can_edit改成false了,对方为什么还能保存?”原因基本就三类:
第一,你只改了初始配置,没有销毁已打开的编辑器。第二,对方已经打开的编辑器还在用旧的token,而这个token还没过期。第三,你的callbackUrl没有做二次校验,OnlyOffice照常把保存结果写回去了。
遇到这种情况,先按顺序排查:先看数据库里的权限是不是真的改了;再看WebSocket消息有没有推出去;最后看callbackUrl日志里,那条保存请求是从哪个用户发来的。前端、后端、回调三层全部通过,收权才能算真正生效。
5.5 一张问题速查表,留着排查时直接抄
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 改了permissions,已打开的页面还能编辑 | 动态权限只对下次打开生效 | 用WebSocket推送 +destroyEditor() |
| 收权后还能通过URL直接下载 | 文件服务没有鉴权 | 网关层统一拦截,存储地址不暴露 |
| 回调一直收不到 | 回调地址不可达或返回格式不对 | 用内网可达地址,返回{"error":0} |
| 打开中文文档乱码 | 容器缺少中文字体 | 挂载字体目录或安装字体包 |
| 保存后批注丢失 | 转换时未保留原格式 | 设置assemblyFormatAsOrigin: true |
| 历史版本找不到 | 社区版没有内置版本对比 | 自己按保存回调存快照 |
| token报217/422错误 | JWT密钥不一致 | 统一JWT_SECRET配置 |
| 收权后对方重启浏览器又可编辑 | 服务端权限没更新或token未过期 | 更新doc_permissions并缩短token有效期 |
6. 几个让我改方案的认知转变
踩过几次坑之后,我对OnlyOffice动态权限API的认知其实变了不少。
第一个转变是:别把“收回编辑权”当成一个API,而要做成一套流程。配置层、会话层、服务端回调、文件存储,任何一层漏掉,都会在真实业务里出现“明明收权了,对方还能改”的尴尬。
第二个转变是:OnlyOffice和WPS的差距不在底层能力,而在产品封装。WPS那个“停止分享”按钮背后,是一整套权限状态同步、会话通知、链接失效的机制。你要做的,其实就是把这套机制自己实现一遍。你封装得好,用户用起来一样觉得“像WPS一样顺手”。
第三个转变是:实时性不一定非要靠WS不可。如果你的场景对“实时”要求没那么高,比如只要求“下次打开时失效”,那靠短token加后端校验就够了。但如果业务明确说“正在编辑的人必须马上变只读”,那就老老实实接WebSocket。没有银弹。
我个人最后给个建议:不要一上来就做文件夹级批量收权,那会让你陷入大量边界问题。先做“单文档、单用户”的收回,打通全链路,再逐步扩展。每个环节都跑通后,动态权限在你手里就真的变成了一把随时能拧动的扳手。