Skip to content

仿飞书云文档的企业云盘在线协同编辑:SpringBoot+Vue3 打通 documentKey、双 JWT 与 forcesave 回存

🌐 演示地址https://ruoyioffice.com | 📦 源码1·GitHubruoyi-office | 📦 源码2·GitCoderuoyi-office | 📦 源码3·Giteeruoyi-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 类文件在浏览器里直接编辑,关闭页面时自动回存并写入版本历史。

企业云盘在线协同编辑 - 四端点链路与 forcesave 回存全景

▲ 全景图:浏览器加载独立编辑页 → 后端签发 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 步:

  1. 前端用 buildCloudFileEditorUrl() 拼出独立编辑页地址,window.open 新标签打开;
  2. 编辑页调 GET /oa/file/onlyoffice/config,后端校验权限、生成 documentKey、签发两个 JWT,返回完整 config;
  3. 前端动态加载 Document Server 的 api.jsnew DocsAPI.DocEditor(containerId, config) 挂载编辑器;
  4. Document Server 拿 config 里的 document.url(带 download token)回业务后端拉文件字节流;
  5. 用户编辑,前端每 90 秒或在「编辑器侧刚保存完」时调 POST /oa/file/onlyoffice/forcesave
  6. 后端通过 Command Service 让 Document Server 强制保存,Document Server 回调 POST /oa/file/onlyoffice/callback,后端下载新文件、写入对象存储、更新主表并生成新版本。

1.2 关键抽象:会话是短的,文件是长的

这是整个设计的核心分野。「协同会话」由 documentKey 标识,生命周期只有几分钟到几小时;「文件」由 oa_file_info.id 标识,生命周期是整个企业的存续期。

两者不能混为一谈:

概念标识由谁维护变化时机
协同会话document.keyOnlyOffice 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扩展名
文字处理worddoc、docx、txt、md
电子表格cellxls、xlsx、csv
演示文稿slideppt、pptx
仅预览(不可编辑)wordpdf

后端在 FileOnlineEditUtils 里判定,前端在 online-edit.ts 里判定。前端这份决定了列表页「在线编辑」按钮是否出现,后端那份决定了请求能不能通过——只有前端判定会被绕过,只有后端判定用户体验会很差,两份都要有。


二、系统设计:4 个端点撑起一条闭环

2.1 端点职责划分

后端 OaFileOnlyOfficeController 只暴露 4 个业务端点,职责边界非常清晰:

端点方法调用方鉴权方式职责
/oa/file/onlyoffice/enabledGET浏览器oa:file:query探测是否开启在线编辑,未开启时前端降级为下载
/oa/file/onlyoffice/configGET浏览器oa:file:query + 分享权限生成 documentKey、签发双 JWT、返回完整 config
/oa/file/onlyoffice/downloadGETDocument Serverdownload JWT按 token 里的 fileUrl 取预签名地址并回传字节流
/oa/file/onlyoffice/callbackPOSTDocument Servercallback JWT接收 status,下载编辑后文件并回存
/oa/file/onlyoffice/forcesavePOST浏览器oa:file:query + 分享权限通过 Command Service 触发 Document Server 强制保存

其中 downloadcallback 都标注了 @PermitAll + @TenantIgnore——因为 Document Server 是无登录态的独立容器,它既没有 Session 也没有 tenant-id 请求头。安全性完全由 JWT 承担。

2.2 核心设计决策

决策点方案理由
编辑器嵌在哪独立路由页 /oa/cloud/file-editorwindow.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 行但被列表页、历史抽屉、编辑页三处复用:

typescript
/** 企业云盘可在线编辑扩展名白名单(与后端 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 的做法是把编辑器注册成一个脱离权限体系的核心路由

typescript
// 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 保证它不污染侧边栏。

企业云盘在线编辑页 - OnlyOffice 独立编辑器与顶部操作条

▲ 独立编辑页 /oa/cloud/file-editor?id=115&editable=1:顶部是文件名、只读/可编辑标记、历史记录与关闭按钮,下方整块交给 OnlyOffice 编辑器,占满视口高度。

URL 拼接则要同时兼容三种部署形态,这是踩过坑之后写死的:

typescript
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 是四个布尔量的与运算,任何一个不成立就降级为只读:

typescript
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」作为种子:

java
// 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 使用:

java
private final ConcurrentHashMap<Long, String> activeDocumentKeys = new ConcurrentHashMap<>();
// ...
if (canEdit) {
    activeDocumentKeys.put(fileId, key);
}

4.2 双 JWT:最小权限的两条通道

这是整篇文章最值得抄走的设计。Document Server 会用两个不同的 URL 回访业务后端,两个 URL 携带的 token 装的东西完全不同:

java
// 通道一:下载令牌,只装文件地址
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 校验配置本身未被篡改:

java
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 行,但把「防污染、防串档、防租户丢失」三件事都做了:

java
@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 里解析真实编辑人:

java
// 协同编辑时以 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].useractions[last].userid 的顺序降级解析,两条都取不到才回退到 token 里的用户。之所以要做降级,是因为不同版本 Document Server 的 body 结构有差异,history 节点在部分场景下并不下发。

4.5 forcesave:让「已保存」变成真的保存

Document Server 底部那个「已保存」只是编辑器缓存态。要在用户不关页面的前提下拿到文件,必须调 Command Service 的 forcesave 命令:

java
@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 换预签名地址,再把字节流转发过去」:

java
@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

typescript
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 路径二:监听编辑器保存事件

onDocumentStateChangeevent.datatrue 表示有未落盘的改动,false 表示编辑器侧刚保存完。后者正是发起 forcesave 的最佳时机:

typescript
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 有机会到达:

typescript
/** 关闭前尽量触发一次回存,再销毁编辑器(促使 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 的字体与静态资源,低带宽环境下可能等好几分钟。与其让用户看着白屏猜,不如把等待时间说清楚:

typescript
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

java
// 历史版本:强制只读
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=6handleCallback 第一件事就是检查这一位并直接 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 引用。带来三个好处:

  1. 版本回滚零成本:历史版本的 URL 一直有效,不需要额外的快照存储;
  2. documentKey 自动滚动:URL 变了,key 就变了,不用手工维护版本计数器;
  3. 并发安全:两个人同时回存也不会互相覆盖对象,最坏情况是版本表里多一条记录。

代价是存储占用增长。RuoYi Office 用「每文件最多保留 50 版」的滚动裁剪来兜底,具体实现见配套文章《企业云盘文档版本历史设计》。

6.5 公共组件复用:云盘与合同共用一套编辑器

OnlyOfficeEditor 组件通过 fileId 是否存在自动切换配置接口,让云盘和合同模块共用同一份编辑器封装:

typescript
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 的行。在线编辑相关的关键字段:

字段类型说明
idbigint文件 ID,也是 documentKey 种子的一部分
parent_idbigint父文件夹 ID,0 表示根目录
file_typetinyint0 文件夹 / 1 文件,config 接口只接受 1
file_namevarchar(255)文件名,作为 OnlyOffice 的 document.title
file_suffixvarchar(20)扩展名(不含点),白名单判定依据
file_urlvarchar(500)当前生效文件地址,每次回存后更新
file_sizebigint字节数,回存时按 content.length 更新
owner_idbigint所有者,权限判定的第一优先级
current_version_noint当前版本号,与 oa_file_version 联动

7.2 OnlyOffice 配置项

配置前缀为 yudao.onlyoffice,云盘与合同模块共用:

yaml
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-urlcallback-base-url两个不同方向的地址,同机部署时也很可能不一样。前者要浏览器能访问,后者要容器能访问。Docker Desktop 环境下后者常用 host.docker.internal,Linux 服务器上则要用宿主机内网 IP。
  • jwt-secret 必须与容器环境变量 JWT_SECRET 一字不差,否则 Document Server 会静默拒绝 config,前端表现为「一直转圈」。
  • enabled: falseisEnabled() 返回 false,config 接口抛 FILE_ONLYOFFICE_DISABLED,前端捕获后隐藏「在线编辑」按钮——功能开关要一路贯通到 UI,不能只在后端拦。

对应的 Docker Compose 关键片段:

yaml
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 步)

  1. 进入「企业云盘」,上传一份 Word 或 Excel 文件;
  2. 点击操作列的「在线编辑」,新标签页打开独立编辑器;
  3. 首次加载会显示带秒数的等待提示(正常 10~30 秒,低带宽更久);
  4. 修改内容,观察编辑器底部状态从「正在保存」变为「已保存」;
  5. 等待约 90 秒或直接点右上角「关闭」,触发 forcesave;
  6. 回到列表打开「历史记录」抽屉,可以看到刚生成的新版本与编辑人;
  7. 点击任一历史版本的「预览」,编辑器以只读模式打开该版内容。

本地启动

bash
# 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

源码仓库

平台地址
GitHubhttps://github.com/yuqing2026/ruoyi-office
GitCodehttps://gitcode.com/zhouzhongyan/ruoyi-office
Giteehttps://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 支持一下!

联系我们

获取报价、演示和二开方案

微信咨询二维码

微信咨询

17156169080

添加时备注「RuoYi Office」

在线体验商业版