仿飞书云文档的企业云盘在线协同编辑:SpringBoot+Vue3 打通 documentKey、双 JWT 与 forcesave 回存
🌐 演示地址:https://ruoyioffice.com | 📦 源码1·GitHub:ruoyi-office | 📦 源码2·GitCode:ruoyi-office | 📦 源码3·Gitee:ruoyi-office | 💬 微信:17156169080(备注「RuoYi Office」)
大多数企业云盘做到「上传、下载、分享」就停了。用户点开一份《2026年度预算表.xlsx》,能做的只有下载到本地、改完再传一遍、然后手动改名成「V2 最终版」。飞书云文档之所以让人回不去,是因为它把「打开即编辑、编辑即保存、保存即留痕」做成了默认行为。RuoYi Office 把这套体验做成了企业云盘能力:基于 OnlyOffice Document Server 9.4,用一个独立编辑页路由 + 4 个后端端点,让 doc/docx/xls/xlsx/ppt/pptx/csv/txt/md 共 9 类文件在浏览器里直接编辑,关闭页面时自动回存并写入版本历史。

▲ 全景图:浏览器加载独立编辑页 → 后端签发 config(含 documentKey 与双 JWT)→ Document Server 用 download token 拉文件 → 用户编辑 → forcesave/关闭触发 callback(status=2/6)→ 后端下载新文件、写 OSS、更新主表并生成版本。
引言:在线编辑到底难在哪?
在线编辑难的从来不是「把编辑器嵌进 iframe」,而是编辑器和业务系统之间那条双向、异步、无登录态的数据通道。落地时至少要过五关:
- 文档身份问题:OnlyOffice 用
document.key唯一标识一份「协同会话」。key 相同则复用缓存,key 变化才会重新拉取文件。key 生成规则设计错了,轻则改完刷新看到旧内容,重则多人编辑串档。 - 回调没有登录态:Document Server 是独立容器,它回调业务后端时不带 Cookie、不带 Token、也不带租户 Header。多租户系统里这一刀切下去,版本记录会全部落到
tenant_id = 0,用户在列表里一条都看不到。 - 「已保存」是假的:编辑器底部显示「已保存」只代表内容进了 Document Server 的缓存。业务端要拿到文件,必须等
status=2(会话关闭保存)或status=6(强制保存)的回调。用户改完直接关浏览器标签页,很可能什么都没存下来。 - 只读会话也会回调:预览历史版本时如果不拦截,一次误触就会把老版本内容当成新内容回存,直接覆盖最新稿。
- 私有对象存储不可直连:文件存在 MinIO/OSS 私有桶里,Document Server 拿不到直链,必须由后端做一层带签名的中转下载。
| 现状做法 | 直接后果 |
|---|---|
| 下载 → 本地改 → 重新上传 | 版本靠文件名「V2 最终版 最终真的最终」维护,谁改的、改了什么全靠问 |
| 只做只读预览(kkFileView) | 「看得见改不了」,协作还是回到微信传文件 |
| 嵌了编辑器但不做 forcesave | 用户关标签页 = 白改半小时,投诉率最高的一类问题 |
| 回调不带租户上下文 | 多租户环境下版本历史「写进去了但查不出来」 |
RuoYi Office 的解法一句话概括:用 4 个后端端点(config / download / callback / forcesave)+ 2 个方向的 JWT,把「无状态的文档服务器」和「有租户、有权限的业务系统」焊死在一起。
一、业务设计:从「文件柜」到「云文档」
1.1 一次点击背后的完整链路
在线编辑不是一个接口,而是一条闭环链路。用户在云盘列表点「在线编辑」,实际发生了 6 步:
- 前端用
buildCloudFileEditorUrl()拼出独立编辑页地址,window.open新标签打开; - 编辑页调
GET /oa/file/onlyoffice/config,后端校验权限、生成 documentKey、签发两个 JWT,返回完整 config; - 前端动态加载 Document Server 的
api.js,new DocsAPI.DocEditor(containerId, config)挂载编辑器; - Document Server 拿 config 里的
document.url(带 download token)回业务后端拉文件字节流; - 用户编辑,前端每 90 秒或在「编辑器侧刚保存完」时调
POST /oa/file/onlyoffice/forcesave; - 后端通过 Command Service 让 Document Server 强制保存,Document Server 回调
POST /oa/file/onlyoffice/callback,后端下载新文件、写入对象存储、更新主表并生成新版本。
1.2 关键抽象:会话是短的,文件是长的
这是整个设计的核心分野。「协同会话」由 documentKey 标识,生命周期只有几分钟到几小时;「文件」由 oa_file_info.id 标识,生命周期是整个企业的存续期。
两者不能混为一谈:
| 概念 | 标识 | 由谁维护 | 变化时机 |
|---|---|---|---|
| 协同会话 | document.key | OnlyOffice Document Server | 文件 URL 变化时必须变 |
| 文件实体 | oa_file_info.id | 业务数据库 | 文件删除前恒定 |
| 文件内容 | oa_file_info.file_url | 对象存储 | 每次回存生成新的对象 |
| 历史快照 | oa_file_version.version_no | 业务数据库 | 每次回存 +1 |
因为 RuoYi Office 采用「每次回存写新对象、不覆盖旧对象」的策略,file_url 每存一次就变一次,documentKey 也就随之变化——这恰好满足了 OnlyOffice「内容变了 key 必须变」的硬性要求,不需要额外维护版本号计数器。
1.3 可编辑类型白名单:前后端必须一致
能不能编辑,不是看文件在不在云盘,而是看扩展名在不在白名单里。RuoYi Office 前后端各维护一份,且必须逐字对齐:
| 文档类型 | OnlyOffice documentType | 扩展名 |
|---|---|---|
| 文字处理 | word | doc、docx、txt、md |
| 电子表格 | cell | xls、xlsx、csv |
| 演示文稿 | slide | ppt、pptx |
| 仅预览(不可编辑) | word |
后端在 FileOnlineEditUtils 里判定,前端在 online-edit.ts 里判定。前端这份决定了列表页「在线编辑」按钮是否出现,后端那份决定了请求能不能通过——只有前端判定会被绕过,只有后端判定用户体验会很差,两份都要有。
二、系统设计:4 个端点撑起一条闭环
2.1 端点职责划分
后端 OaFileOnlyOfficeController 只暴露 4 个业务端点,职责边界非常清晰:
| 端点 | 方法 | 调用方 | 鉴权方式 | 职责 |
|---|---|---|---|---|
/oa/file/onlyoffice/enabled | GET | 浏览器 | oa:file:query | 探测是否开启在线编辑,未开启时前端降级为下载 |
/oa/file/onlyoffice/config | GET | 浏览器 | oa:file:query + 分享权限 | 生成 documentKey、签发双 JWT、返回完整 config |
/oa/file/onlyoffice/download | GET | Document Server | download JWT | 按 token 里的 fileUrl 取预签名地址并回传字节流 |
/oa/file/onlyoffice/callback | POST | Document Server | callback JWT | 接收 status,下载编辑后文件并回存 |
/oa/file/onlyoffice/forcesave | POST | 浏览器 | oa:file:query + 分享权限 | 通过 Command Service 触发 Document Server 强制保存 |
其中 download 和 callback 都标注了 @PermitAll + @TenantIgnore——因为 Document Server 是无登录态的独立容器,它既没有 Session 也没有 tenant-id 请求头。安全性完全由 JWT 承担。
2.2 核心设计决策
| 决策点 | 方案 | 理由 |
|---|---|---|
| 编辑器嵌在哪 | 独立路由页 /oa/cloud/file-editor,window.open 新标签 | 编辑是长时任务,弹窗里做会被误关;独立页可全屏、可分享链接 |
| documentKey 怎么生成 | MD5(bizType:fileId:versionId:fileUrl) + hashCode 后 5 位 | 内容变则 key 变;同一份内容多人打开则 key 相同,天然进入同一协同会话 |
| 一个 JWT 还是两个 | 两个:download token 只含 fileUrl,callback token 含业务上下文 | 最小权限原则。download token 即使泄露也只能拿到一份文件,拿不到 fileId/userId/tenantId |
| 租户怎么透传 | 把 tenantId 写进 callback JWT,回存时 TenantUtils.execute 恢复 | 回调无 Header,这是唯一可靠通道;另留一条从文件表反查的兼容分支 |
| 何时触发回存 | 定时 90 秒 + 编辑器保存事件 + 关闭前 flush | 三重保险,覆盖「长时间编辑」「短编辑立即关」「异常关闭」三种场景 |
| 回存是否覆盖原文件 | 不覆盖,写新对象 + 更新 file_url | 旧对象继续被历史版本引用,天然实现版本回溯 |
| 只读会话如何防污染 | callback JWT 里带 editable,为 false 时直接 return | 预览历史版本时的最后一道防线 |
三、PC 端功能实现
3.1 云盘列表:入口在哪
云盘列表页按扩展名决定操作列展示什么按钮。可在线编辑的文件展示「在线编辑」,PDF 展示「在线预览」,其余展示「下载」。

▲ 云盘主列表:左侧目录树 + 右侧文件列表,Office 文档行的操作列会额外出现「在线编辑」与「历史记录」入口。
判定逻辑在 online-edit.ts,只有 40 行但被列表页、历史抽屉、编辑页三处复用:
/** 企业云盘可在线编辑扩展名白名单(与后端 FileOnlineEditUtils 保持一致) */
const EDITABLE_EXTS = new Set([
'csv', 'doc', 'docx', 'md', 'ppt', 'pptx', 'txt', 'xls', 'xlsx',
]);
/** OnlyOffice 可预览(含 PDF 只读) */
const ONLYOFFICE_PREVIEW_EXTS = new Set([...EDITABLE_EXTS, 'pdf']);
export function resolveFileExt(fileName?: string, fileSuffix?: string): string {
const fromSuffix = (fileSuffix || '').replace(/^\./, '').toLowerCase();
if (fromSuffix) {
return fromSuffix;
}
const name = fileName || '';
const idx = name.lastIndexOf('.');
return idx < 0 ? '' : name.slice(idx + 1).toLowerCase();
}
export function isOnlineEditableFile(fileName?: string, fileSuffix?: string) {
return EDITABLE_EXTS.has(resolveFileExt(fileName, fileSuffix));
}3.2 独立编辑页:为什么不用弹窗
在线编辑是长时任务——用户可能开着改一小时。放在 Modal 里,一次误点遮罩就全没了;放在 Tab 里,切换页签会触发组件卸载。RuoYi Office 的做法是把编辑器注册成一个脱离权限体系的核心路由:
// apps/web-antd/src/router/routes/core.ts
coreRoutes.push({
component: loadOaCloudFileEditor,
meta: { hideInMenu: true, ignoreAccess: true, title: '云盘文件' },
name: 'OaCloudFileEditorStandalone',
path: '/oa/cloud/file-editor',
});ignoreAccess: true 让这个路由不参与动态菜单鉴权(真正的权限校验在后端 config 接口做),hideInMenu: true 保证它不污染侧边栏。

▲ 独立编辑页 /oa/cloud/file-editor?id=115&editable=1:顶部是文件名、只读/可编辑标记、历史记录与关闭按钮,下方整块交给 OnlyOffice 编辑器,占满视口高度。
URL 拼接则要同时兼容三种部署形态,这是踩过坑之后写死的:
export function buildCloudFileEditorUrl(query: {
editable?: 0 | 1 | '0' | '1';
id: number | string;
versionId?: number | string;
}): string {
const params = new URLSearchParams();
params.set('id', String(query.id));
if (query.editable !== undefined) params.set('editable', String(query.editable));
if (query.versionId !== undefined && query.versionId !== '') {
params.set('versionId', String(query.versionId));
}
const base = import.meta.env.BASE_URL || '/';
const qs = params.toString();
// 生产/演示:hash 路由 + BASE_URL=/web/ → /web/#/oa/cloud/file-editor?...
if (import.meta.env.VITE_ROUTER_HISTORY === 'hash') {
return `${base}#/oa/cloud/file-editor?${qs}`;
}
// 本地开发:history 路由 + BASE_URL=/ → /oa/cloud/file-editor?...
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
return `${normalizedBase}oa/cloud/file-editor?${qs}`;
}这段注释是血泪教训:写死根路径
/oa/cloud/file-editor,线上会落到文档站;写死 hash,本地 history 模式下pathname仍是/,会被路由当成首页。
3.3 可编辑判定:四个条件同时成立
编辑页的 canEdit 是四个布尔量的与运算,任何一个不成立就降级为只读:
const canEdit = computed(
() =>
editableQuery.value && // URL 上 ?editable=1
canEditType.value && // 扩展名在白名单内
hasEditPermission.value && // checkFilePermission 返回 >= 1(可编辑)
!isHistoryView.value && // 不是在看历史版本
onlyOfficeEnabled.value, // 后端开启了 OnlyOffice
);前端这层判定只是为了「不给用户假希望」——真正说了算的是后端 buildEditorConfig() 里的同名判定。
四、后端核心实现
4.1 documentKey:内容变则 key 变
OnlyOffice 的缓存策略是:同一个 key 认为是同一份文档,直接复用服务端缓存,不再回源拉文件。 所以 key 必须与文件内容一一对应。RuoYi Office 把「业务类型 + 文件 ID + 版本 ID + 文件 URL」作为种子:
// OaFileOnlyOfficeServiceImpl#buildEditorConfig
String keySeed = BIZ_TYPE_OA_CLOUD_FILE + ":" + fileId + ":"
+ (versionId == null ? "" : versionId + ":") + fileUrl;
String key = SecureUtil.md5(keySeed) + "_" + Math.abs(keySeed.hashCode() % 100000);因为回存策略是「写新对象」,fileUrl 每存一次都带新的时间戳后缀(预算表_1753423891234.xlsx),key 自然滚动。加上 hashCode 后缀是为了绕开 OnlyOffice 对 key 长度与字符集的历史限制,同时进一步降低 MD5 碰撞的影响。
编辑态下 key 还会存进一个进程内 Map,供后续 forcesave 使用:
private final ConcurrentHashMap<Long, String> activeDocumentKeys = new ConcurrentHashMap<>();
// ...
if (canEdit) {
activeDocumentKeys.put(fileId, key);
}4.2 双 JWT:最小权限的两条通道
这是整篇文章最值得抄走的设计。Document Server 会用两个不同的 URL 回访业务后端,两个 URL 携带的 token 装的东西完全不同:
// 通道一:下载令牌,只装文件地址
String downloadToken = createToken(MapUtil.builder(new HashMap<String, Object>())
.put("fileUrl", fileUrl)
.put("nonce", IdUtil.fastSimpleUUID())
.build());
// 通道二:回调令牌,装齐回存所需的全部业务上下文
// 回调接口带 @TenantIgnore,必须把租户写入 JWT,回存时再恢复,
// 否则版本记录 tenant_id=0,用户在列表里查不到
Long tenantId = TenantContextHolder.getTenantId();
String callbackToken = createToken(MapUtil.builder(new HashMap<String, Object>())
.put("bizType", BIZ_TYPE_OA_CLOUD_FILE)
.put("bizId", fileId)
.put("fileName", fileName)
.put("editable", canEdit) // ← 只读会话防污染的关键位
.put("userId", userId)
.put("userName", loginUserName)
.put("tenantId", tenantId) // ← 多租户透传的唯一通道
.put("nonce", IdUtil.fastSimpleUUID())
.build());
String base = StrUtil.removeSuffix(StrUtil.blankToDefault(properties.getCallbackBaseUrl(), ""), "/");
String documentUrl = base + "/admin-api/oa/file/onlyoffice/download?token=" + downloadToken;
String callbackUrl = base + "/admin-api/oa/file/onlyoffice/callback?token=" + callbackToken;nonce 保证每次签发的 token 都不同,exp 统一为 4 小时(TOKEN_EXPIRE_SECONDS = 4 * 60 * 60)。此外整个 config 还会再用同一把 jwtSecret 签一层,供 Document Server 校验配置本身未被篡改:
if (StrUtil.isNotBlank(properties.getJwtSecret())) {
config.put("token", JWTUtil.createToken(config,
properties.getJwtSecret().getBytes(StandardCharsets.UTF_8)));
}4.3 回存状态机:只认 2 和 6
OnlyOffice callback 的 status 有 1~7 共七种取值,业务端只需要关心两种:
| status | 含义 | 业务动作 |
|---|---|---|
| 1 | 有人正在编辑 | 忽略(可用于展示「他人正在编辑」标记) |
| 2 | 文档已就绪可保存(所有人关闭会话后触发) | 回存 |
| 3 | 保存出错 | 记日志告警 |
| 4 | 无修改直接关闭 | 忽略 |
| 6 | 强制保存(forcesave 触发) | 回存 |
| 7 | 强制保存出错 | 记日志告警 |
回存主逻辑不到 40 行,但把「防污染、防串档、防租户丢失」三件事都做了:
@Override
public void handleCallback(String token, Map<String, Object> body) {
Map<String, Object> payload = verifyToken(token);
Integer status = MapUtil.getInt(body, "status");
if (status == null || (status != 2 && status != 6)) {
return; // 1/3/4/7 无需回存
}
// 1. 只读会话(如预览历史版本)拒绝回存,防止老版本覆盖最新稿
Boolean editable = MapUtil.getBool(payload, "editable");
if (!Boolean.TRUE.equals(editable)) {
log.warn("[handleCallback] 只读会话拒绝回存 bizId={}", MapUtil.getLong(payload, "bizId"));
return;
}
// 2. 校验业务类型,避免不同业务共用同一 secret 时串档
Long bizId = MapUtil.getLong(payload, "bizId");
if (!BIZ_TYPE_OA_CLOUD_FILE.equals(MapUtil.getStr(payload, "bizType")) || bizId == null) {
return;
}
// 3. 从 DS 下载编辑后文件,写入对象存储的 oa/cloud 路径(唯一命名,不覆盖旧对象)
byte[] content = HttpUtil.downloadBytes(MapUtil.getStr(body, "url"));
String fileName = MapUtil.getStr(payload, "fileName", "document.docx");
String ext = StrUtil.blankToDefault(FileUtil.extName(fileName), "docx").toLowerCase();
String uniqueName = FileUtil.mainName(fileName) + "_" + System.currentTimeMillis() + "." + ext;
String newUrl = StrUtil.subBefore(
fileApi.createFile(content, uniqueName, "oa/cloud", FileOnlineEditUtils.mimeOfExt(ext)),
"?", false);
// 4. 恢复租户上下文后更新主表 + 写版本
Long tenantId = MapUtil.getLong(payload, "tenantId");
if (tenantId == null || tenantId <= 0) {
tenantId = TenantUtils.executeIgnore(() -> fileInfoMapper.selectTenantIdById(bizId));
}
TenantUtils.execute(tenantId, () -> fileInfoService.updateFileUrlAfterEdit(
bizId, newUrl, (long) content.length, editorUserId, editorUserName));
}多租户提醒:
TenantUtils.execute(tenantId, runnable)会临时把TenantContextHolder切到目标租户并关闭 ignore 标志,执行完再还原。这是在无请求上下文的线程里安全写租户数据的标准姿势。
4.4 协同编辑时「最后编辑人」怎么算
多人协同时,callback 是在最后一个人关闭会话时才触发的。此时 callback token 里记的还是「第一个打开文档的人」,把版本记在他名下显然不对。RuoYi Office 优先从 Document Server 回传的 body 里解析真实编辑人:
// 协同编辑时以 Document Server 回传的最后编辑人为准
EditorUser fromBody = resolveEditorFromCallbackBody(body);
if (fromBody != null) {
if (fromBody.userId != null) editorUserId = fromBody.userId;
if (StrUtil.isNotBlank(fromBody.userName)) editorUserName = fromBody.userName;
}resolveEditorFromCallbackBody() 按 history.changes[last].user → actions[last].userid 的顺序降级解析,两条都取不到才回退到 token 里的用户。之所以要做降级,是因为不同版本 Document Server 的 body 结构有差异,history 节点在部分场景下并不下发。
4.5 forcesave:让「已保存」变成真的保存
Document Server 底部那个「已保存」只是编辑器缓存态。要在用户不关页面的前提下拿到文件,必须调 Command Service 的 forcesave 命令:
@Override
public Map<String, Object> forceSave(Long fileId, String key) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
Integer permission = fileShareService.checkFilePermission(fileId, userId);
if (!FileSharePermissionEnum.canOnlineEdit(permission)
&& !FileSharePermissionEnum.canPreview(permission)) {
throw ServiceExceptionUtil.exception(FILE_SHARE_NO_PERMISSION);
}
// 前端未传 key 时,回退到 config 阶段缓存的 activeDocumentKeys
String documentKey = StrUtil.blankToDefault(key, activeDocumentKeys.get(fileId));
Map<String, Object> cmd = new HashMap<>();
cmd.put("c", "forcesave");
cmd.put("key", documentKey);
cmd.put("userdata", "oa-cloud-" + fileId + "-" + userId);
byte[] secret = StrUtil.blankToDefault(properties.getJwtSecret(), "ruoyi-office-onlyoffice")
.getBytes(StandardCharsets.UTF_8);
String url = StrUtil.removeSuffix(properties.getServerUrl(), "/")
+ "/coauthoring/CommandService.ashx";
String raw = HttpRequest.post(url)
.body(JSONUtil.toJsonStr(Map.of("token", JWTUtil.createToken(cmd, secret))))
.timeout(15_000).execute().body();
// error=0 成功;error=4 表示「无变更」,同样视为可接受
Integer error = JSONUtil.parseObj(raw).getInt("error");
if (error != null && error != 0 && error != 4) {
throw ServiceExceptionUtil.exception(FILE_ONLYOFFICE_FORCESAVE_FAILED);
}
return Map.of("error", error, "key", documentKey);
}error=4 必须放行,否则用户点「保存」而恰好没改动过,界面就会弹一个红色报错——这是集成 OnlyOffice 最常见的伪 Bug 之一。
4.6 私有对象存储的中转下载
Document Server 无法直连私有桶,download 端点做的就是「用 token 换预签名地址,再把字节流转发过去」:
@Override
public byte[] downloadByToken(String token) {
Map<String, Object> payload = verifyToken(token);
String fileUrl = MapUtil.getStr(payload, "fileUrl");
if (fileUrl.contains("?")) {
return HttpUtil.downloadBytes(fileUrl); // 已带签名参数,直接下载
}
String accessUrl = fileUrl;
try {
String presigned = fileApi.presignGetUrl(fileUrl, (int) TOKEN_EXPIRE_SECONDS).getCheckedData();
if (StrUtil.isNotBlank(presigned)) {
accessUrl = presigned;
}
} catch (Exception ex) {
log.warn("[downloadByToken] 获取预签名地址失败,回退原始地址:{}", fileUrl, ex);
}
return HttpUtil.downloadBytes(accessUrl);
}预签名失败时回退原始地址而不是抛异常,是为了兼容「本地存储 / 公开桶」两种部署形态。
五、前端交互实现:三重回存保险
公共组件 components/onlyoffice-editor/index.vue 被云盘与合同两个模块复用。它在 OnlyOffice 原生能力之上叠了一层「回存保障」,核心是三条触发路径。
5.1 路径一:90 秒定时 forcesave
const props = withDefaults(defineProps<Props>(), {
// OnlyOffice 底部「已保存」只是编辑器缓存;需 forcesave/关闭才会回调业务端写版本历史
forceSaveIntervalMs: 90_000,
});
function startForceSaveTimer() {
stopForceSaveTimer();
if (!props.editable || !props.fileId || props.forceSaveIntervalMs <= 0) return;
forceSaveTimer = window.setInterval(() => {
if (documentDirty) {
void requestForceSave();
}
}, props.forceSaveIntervalMs);
}只有 documentDirty 为真才发请求,避免空转打爆 Document Server。
5.2 路径二:监听编辑器保存事件
onDocumentStateChange 的 event.data 为 true 表示有未落盘的改动,false 表示编辑器侧刚保存完。后者正是发起 forcesave 的最佳时机:
onDocumentStateChange: (event: any) => {
// data === true 表示有未保存到 DS 的修改;false 表示编辑器侧已保存
documentDirty = !!event?.data;
// 编辑器侧刚保存完时,立刻请求业务端 forcesave 写版本
if (!documentDirty && props.editable && props.fileId && documentKey) {
void requestForceSave();
}
parentEvents.onDocumentStateChange?.(event);
},另外 onAppReady 里还会延迟 3 秒补一次 forcesave,用于把「上次异常退出遗留在 DS 缓存里的改动」及时落库。
5.3 路径三:关闭前 flush
组件卸载(路由离开、关标签页)时先 forcesave 再销毁,并留出等待窗口让 callback 有机会到达:
/** 关闭前尽量触发一次回存,再销毁编辑器(促使 DS 发送 status=2/6) */
async function flushAndDestroy(waitMs = 1500) {
stopForceSaveTimer();
if (props.editable && props.fileId && documentKey) {
await requestForceSave();
await new Promise((resolve) => window.setTimeout(resolve, waitMs));
}
destroyEditor();
}
onBeforeUnmount(() => {
stopElapsed();
void flushAndDestroy(800); // 卸载时尽量回存
});编辑页顶部的「关闭」按钮走 flushAndDestroy(1500),卸载钩子走 flushAndDestroy(800)——主动关闭给足时间,被动卸载尽力而为。
5.4 首次加载的耐心提示
OnlyOffice 首次加载要下载几十 MB 的字体与静态资源,低带宽环境下可能等好几分钟。与其让用户看着白屏猜,不如把等待时间说清楚:
const loadingTip = computed(() => {
if (elapsedSec.value < 15) return '正在加载在线编辑器…';
if (elapsedSec.value < 60) {
return `正在加载在线编辑器…已等待 ${elapsedSec.value}s(首次需下载字体等静态资源,请稍候)`;
}
return `正在加载在线编辑器…已等待 ${elapsedSec.value}s。低带宽环境首次可能需数分钟,第二次会明显更快;若超过 5 分钟请刷新重试`;
});这类「把系统的不确定性翻译成人话」的细节,比多加一个功能更能降低客服工单量。
六、RuoYi Office 的创新设计
6.1 双 JWT 分离:一把钥匙只开一扇门
多数开源集成方案只签一个 token,把 fileId、userId、fileUrl 一股脑塞进去,然后同时用于 download 和 callback。问题在于:download URL 会作为 config 的一部分下发到浏览器,是可见的。 一个包含 userId、tenantId 的 token 出现在前端 DevTools 里,本身就是信息泄露。
RuoYi Office 拆成两把钥匙:download token 只有 fileUrl + nonce,即使被抓包,攻击者最多重复下载同一份他本来就有权限看的文件;callback token 含全部业务上下文,但它只出现在 Document Server 与后端之间,浏览器侧不可见。
6.2 editable 位写进 JWT:只读预览的最后防线
查看历史版本时,后端会强制 editable = false:
// 历史版本:强制只读
boolean historyView = versionId != null;
if (historyView) {
var version = fileVersionService.getVersionDO(versionId);
if (!fileId.equals(version.getFileId())) {
throw ServiceExceptionUtil.exception(FILE_VERSION_NOT_EXISTS);
}
fileUrl = version.getFileUrl();
fileName = StrUtil.blankToDefault(version.getFileName(), fileName);
editable = false;
}这个 false 会一路写进 callback token。哪怕前端被篡改、哪怕 Document Server 因为某种原因发来了 status=6,handleCallback 第一件事就是检查这一位并直接 return。「用 v3 的内容覆盖 v8」这种灾难性事故,在设计层面就被堵死了。
6.3 租户写进 JWT:无上下文线程的正解
@TenantIgnore 让 callback 能被无租户 Header 的请求打通,但也意味着此时 TenantContextHolder 是空的。如果直接写版本表,MyBatis-Plus 的租户拦截器会填入 0,导致数据「写进去了,但在租户视角查不到」——排查这类问题往往要花掉大半天。
正解是把 tenantId 当作业务数据放进签名过的 token,回存时显式恢复。同时保留一条兜底:token 里没有(旧版本签发的)就用 TenantUtils.executeIgnore 从文件表反查。两条路都走不通就抛异常快速失败,而不是静默写入脏数据。
6.4 「写新对象」而非「覆盖」:版本能力的地基
每次回存都生成 原名_时间戳.扩展名 的新对象,主表 file_url 指向最新那个,旧对象继续被 oa_file_version 引用。带来三个好处:
- 版本回滚零成本:历史版本的 URL 一直有效,不需要额外的快照存储;
- documentKey 自动滚动:URL 变了,key 就变了,不用手工维护版本计数器;
- 并发安全:两个人同时回存也不会互相覆盖对象,最坏情况是版本表里多一条记录。
代价是存储占用增长。RuoYi Office 用「每文件最多保留 50 版」的滚动裁剪来兜底,具体实现见配套文章《企业云盘文档版本历史设计》。
6.5 公共组件复用:云盘与合同共用一套编辑器
OnlyOfficeEditor 组件通过 fileId 是否存在自动切换配置接口,让云盘和合同模块共用同一份编辑器封装:
async function fetchOnlyOfficeConfig(): Promise<EditorConfigResult> {
if (props.fileId) {
// 云盘:传 fileId 走 /oa/file/onlyoffice/config
const url = props.configUrl || '/oa/file/onlyoffice/config';
return requestClient.get<EditorConfigResult>(url, {
params: { fileId: props.fileId, editable: props.editable, versionId: props.versionId },
});
}
// 合同:传 fileUrl + bizType/bizId 走 /contract/onlyoffice/config
const url = props.configUrl || '/contract/onlyoffice/config';
return requestClient.get<EditorConfigResult>(url, {
params: {
fileUrl: props.fileUrl, fileName: props.fileName,
editable: props.editable, bizType: props.bizType, bizId: props.bizId,
},
});
}注释里写明了「直接请求配置接口,避免静态依赖 #/api/contract」——因为 OA 裁剪版没有合同模块前端,静态 import 会导致构建失败。这类模块可裁剪性的考量,在做多版本交付的产品里非常关键。
七、数据结构
7.1 oa_file_info(文件与文件夹统一表)
云盘没有独立的 folder 表,文件夹就是 file_type = 0 的行。在线编辑相关的关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint | 文件 ID,也是 documentKey 种子的一部分 |
parent_id | bigint | 父文件夹 ID,0 表示根目录 |
file_type | tinyint | 0 文件夹 / 1 文件,config 接口只接受 1 |
file_name | varchar(255) | 文件名,作为 OnlyOffice 的 document.title |
file_suffix | varchar(20) | 扩展名(不含点),白名单判定依据 |
file_url | varchar(500) | 当前生效文件地址,每次回存后更新 |
file_size | bigint | 字节数,回存时按 content.length 更新 |
owner_id | bigint | 所有者,权限判定的第一优先级 |
current_version_no | int | 当前版本号,与 oa_file_version 联动 |
7.2 OnlyOffice 配置项
配置前缀为 yudao.onlyoffice,云盘与合同模块共用:
yudao:
onlyoffice:
enabled: false # 总开关,false 时前端降级为下载
server-url: http://localhost:8081 # 浏览器可达:用于加载 api.js
jwt-secret: ruoyi-office-onlyoffice-secret # 必须与容器 JWT_SECRET 完全一致
callback-base-url: http://host.docker.internal:48080 # Document Server 容器可达的后端地址设计要点:
server-url与callback-base-url是两个不同方向的地址,同机部署时也很可能不一样。前者要浏览器能访问,后者要容器能访问。Docker Desktop 环境下后者常用host.docker.internal,Linux 服务器上则要用宿主机内网 IP。jwt-secret必须与容器环境变量JWT_SECRET一字不差,否则 Document Server 会静默拒绝 config,前端表现为「一直转圈」。enabled: false时isEnabled()返回 false,config接口抛FILE_ONLYOFFICE_DISABLED,前端捕获后隐藏「在线编辑」按钮——功能开关要一路贯通到 UI,不能只在后端拦。
对应的 Docker Compose 关键片段:
services:
onlyoffice-documentserver:
image: onlyoffice/documentserver:9.4
ports:
- '8081:80'
environment:
- JWT_ENABLED=true
- JWT_SECRET=ruoyi-office-onlyoffice-secret
- JWT_HEADER=Authorization八、技术亮点总结
| 设计要点 | 实现方式 | 价值 |
|---|---|---|
| 文档身份唯一化 | MD5(bizType:fileId:versionId:fileUrl) + hashCode 后缀 | 内容变则 key 变,杜绝「改完刷新还是旧内容」 |
| 双 JWT 最小权限 | download token 只含 fileUrl,callback token 含业务上下文 | 浏览器可见的 token 不携带 userId/tenantId |
| 多租户上下文透传 | tenantId 写入 callback JWT + TenantUtils.execute 恢复 | 解决 @TenantIgnore 回调写入 tenant_id=0 |
| 只读会话防污染 | callback JWT 携带 editable 位,false 直接 return | 预览历史版本不会覆盖最新稿 |
| 三重回存保险 | 90s 定时 + 保存事件触发 + 关闭前 flush | 覆盖长编辑、短编辑、异常退出三种场景 |
| forcesave 容错 | error=4(无变更)视为成功 | 消除「点保存却报错」的伪 Bug |
| 写新对象不覆盖 | 原名_时间戳.ext 写入 oa/cloud 路径 | 版本回滚零成本,并发回存互不影响 |
| 私有桶中转下载 | 预签名 URL + 字节流转发,失败回退原地址 | 兼容 MinIO/OSS 私有桶与本地存储两种形态 |
| 独立编辑页路由 | ignoreAccess 核心路由 + window.open 新标签 | 长时编辑不被误关,链接可直接分享 |
| 部署形态兼容 | BASE_URL + VITE_ROUTER_HISTORY 动态拼 URL | 一份代码同时适配 history/hash、根路径/子路径 |
| 编辑器组件复用 | 按 fileId 有无自动切换配置接口 | 云盘与合同共用一套封装,且支持模块裁剪 |
| 协同编辑归属正确 | 优先解析 history.changes / actions 里的真实编辑人 | 多人协同时版本记在最后修改者名下 |
九、快速体验
在线演示
- Web 演示:https://ruoyioffice.com/web/(账号
admin/ 密码admin123) - 操作路径:左侧菜单 → OA协同办公 → 企业云盘 → 上传一份
.docx→ 点击「在线编辑」
推荐体验流程(7 步)
- 进入「企业云盘」,上传一份 Word 或 Excel 文件;
- 点击操作列的「在线编辑」,新标签页打开独立编辑器;
- 首次加载会显示带秒数的等待提示(正常 10~30 秒,低带宽更久);
- 修改内容,观察编辑器底部状态从「正在保存」变为「已保存」;
- 等待约 90 秒或直接点右上角「关闭」,触发 forcesave;
- 回到列表打开「历史记录」抽屉,可以看到刚生成的新版本与编辑人;
- 点击任一历史版本的「预览」,编辑器以只读模式打开该版内容。
本地启动
# 1. 启动 OnlyOffice Document Server(约需 2 分钟完成初始化)
cd ruoyi-office/script/docker
docker compose -f docker-compose.onlyoffice.yml up -d
# 2. 后端开启开关(application-local.yaml)
# yudao.onlyoffice.enabled: true
cd ruoyi-office
mvn -P boot -DskipTests compile
# 3. 前端
cd ruoyi-office-vben
pnpm dev:antd # 访问 http://localhost:5800源码仓库
| 平台 | 地址 |
|---|---|
| GitHub | https://github.com/yuqing2026/ruoyi-office |
| GitCode | https://gitcode.com/zhouzhongyan/ruoyi-office |
| Gitee | https://gitee.com/yqzy1688/ruoyi-office |
常见问题(FAQ)
RuoYi Office 的企业云盘支持在线协同编辑吗?
支持。基于 OnlyOffice Document Server 9.4,覆盖 doc/docx/xls/xlsx/ppt/pptx/csv/txt/md 共 9 类扩展名的在线编辑,pdf 支持只读预览。后端 Spring Boot 3.5 + 前端 Vue3,多人同时打开同一份文件会进入同一个协同会话,编辑结果自动回存并写入版本历史。可在在线演示环境直接体验,完整能力与持续维护由商业版提供。
OnlyOffice 的 documentKey 应该怎么生成?
原则是「内容变则 key 变,内容同则 key 同」。RuoYi Office 的种子是 业务类型:文件ID:版本ID:文件URL,取 MD5 后再拼 hashCode 后 5 位。因为回存策略是写新对象、URL 带时间戳,key 会自动滚动,不需要单独维护版本计数器。如果你的回存是「原地覆盖」,就必须把版本号或文件 MD5 显式加进种子,否则会出现「改完刷新仍是旧内容」。
为什么改完文档,业务系统里还是旧文件?
九成是没触发回存。OnlyOffice 编辑器底部的「已保存」只表示内容进了 Document Server 缓存,业务端要收到 status=2(会话关闭)或 status=6(forcesave)的 callback 才会真正落库。排查三步:一看 Document Server 容器能否访问 callback-base-url;二看后端日志有没有 [handleCallback] 记录;三看前端是否配置了定时 forcesave。RuoYi Office 默认 90 秒定时 + 保存事件触发 + 关闭前 flush 三重保险。
多租户系统里,版本历史写进去了却查不出来怎么办?
典型症状是数据库里 tenant_id = 0。原因是 callback 接口标注了 @TenantIgnore(Document Server 不会带 tenant-id 请求头),此时 TenantContextHolder 为空。解法是在签发 callback JWT 时把 tenantId 写进去,回存时用 TenantUtils.execute(tenantId, runnable) 显式恢复上下文再写库。
集成 OnlyOffice 时点保存报错但内容其实没问题?
检查是不是把 error=4 当成失败了。Command Service 的 forcesave 命令在「文档自上次保存以来无变更」时返回 error=4,这是正常响应而非错误。RuoYi Office 的判定是 error != 0 && error != 4 才抛异常。另外 error=1(key 找不到)通常意味着协同会话已关闭,可提示用户刷新页面。
和只读预览方案(kkFileView)相比该怎么选?
只需要「看」就用 kkFileView,部署轻、格式覆盖广、不需要回存链路;需要「改」就用 OnlyOffice,代价是多一个几 GB 的容器和一条双向回调链路。RuoYi Office 的实际做法是两者并存:PDF、图片、压缩包等走轻量预览,Office 三件套走 OnlyOffice 在线编辑。详细选型对照可参考《在线文档编辑器选型》一文。
结语
在线协同编辑的本质,是在一个无状态的文档服务和一个有租户、有权限、有审计的业务系统之间,建立一条双向可信通道。这条通道的三块基石分别是:用 documentKey 锚定文档身份,用双 JWT 分离两个方向的最小权限,用 forcesave 把「编辑器认为的保存」翻译成「业务系统认为的保存」。
这套模式并不只适用于云盘。合同正文起草、公文套红成稿、投标文件协同撰写、项目周报共同维护——任何「多人对同一份富文本反复修改」的场景,都可以复用同一套四端点结构,只需要把 bizType 换掉、把回存后的业务动作换掉。RuoYi Office 里合同模块和云盘模块共用同一个前端编辑器组件,正是这个抽象成立的证明。
你们团队现在是怎么处理「多人改同一份文档」的?是靠共享盘 + 文件名约定,还是已经上了在线文档?在线编辑落地过程中最难啃的是权限、版本还是部署?欢迎在评论区聊聊。
如果这篇拆解对你有帮助,欢迎点赞收藏,也欢迎去仓库点个 Star——下一篇我们讲配套的《企业云盘文档版本历史设计:4 种版本来源、50 版滚动保留、恢复即新增版》。
💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
📦 源码仓库:GitHub | GitCode | Gitee
💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!
