SpringBoot3+OnlyOffice 自动保存版本:定时回存、间隔合并与 50 版上限怎么配

SpringBoot3+OnlyOffice 自动保存版本:定时回存、间隔合并与 50 版上限怎么配

🌐 文档地址https://ruoyioffice.com

📦 源码1·GitHubhttps://github.com/yuqing2026/ruoyi-office

📦 源码2·GitCodehttps://gitcode.com/zhouzhongyan/ruoyi-office

📦 源码3·Giteehttps://gitee.com/yqzy1688/ruoyi-office

💬 微信:17156169080(备注「RuoYi Office」)
用户在浏览器里改了 40 分钟劳动合同,OnlyOffice 底部一直显示"已保存",关页后业务系统的历史版本却没有新增;另一种修法是每分钟生成一版,半天刷出几百条历史。问题不在编辑器有没有自动保存,而在"编辑器缓存"和"业务版本"被当成了同一件事。RuoYi Office 用五个配置项把两层拆开:编辑器负责防丢稿,前端按间隔请求 forcesave,Document Server 回调业务端写快照,同一窗口内滑动覆盖最近 autosave,超出窗口才递增版本号。

▲ 默认每 600 秒检查一次脏文档;同一合并窗口更新最近 autosave,跨窗口才创建 vN+1,单文件默认最多保留 50 版


引言:"已保存"到底保存到哪一层?

OnlyOffice 的"已保存"首先表示编辑内容已经进入 Document Server 的编辑缓存,不自动等于 OA 云盘、合同或项目文档已经拿到一份可恢复的业务文件。

层次 解决的问题 用户看到什么 是否形成业务版本
编辑器 autosave 浏览器编辑防丢 OnlyOffice 底部"已保存"
Command Service forcesave 让文档服务立即回传 通常无明显打断 触发 status=6 后才是
业务版本历史 可预览、恢复、命名 v12、v13、自动保存
命名版本 标记里程碑 "法务定稿"等备注 是,来源转为 manual

如果这四层不拆,产品会出现两个反直觉:

  1. 用户以为"已保存"就能在历史里找到;
  2. 实施把快照间隔调得很短,版本号和对象存储一起膨胀。

本文专讲自动保存如何稳定落到版本历史,又不让版本号爆炸。OnlyOffice 的 Docker 安装、双 JWT 和 documentKey 生成已有独立文章,不在这里重复。


一、产品能力与特点

1.1 五个参数把"防丢"和"留版"分开

系统在 基础设施 → 文件管理 → 在线文档配置 提供五项参数。

▲ 配置页顶部直接说明"编辑器草稿不是版本历史";默认开启编辑器自动保存和业务快照,间隔 600 秒,间隔内合并,最多保留 50 版

参数键 默认值 业务含义
online-doc.editor-autosave.enabled true 控制 OnlyOffice customization.autosave
online-doc.snapshot.enabled true 是否定时触发业务快照
online-doc.snapshot.interval-seconds 600 前端 forcesave 周期,也是合并窗口
online-doc.version.coalesce-autosave true 连续 autosave 是否更新最近一版
online-doc.version.max-count 50 单文件最多保留的版本数

配置缺失时用默认值;间隔低于 60 秒时按 60 秒执行;版本上限小于 1 时回退到默认 50。

1.2 谁会消费这套配置

消费方 使用配置做什么 当前边界
企业云盘 定时回存、版本合并、版本裁剪 完整支持版本历史
项目文档 定时回存与配置下发 与项目 ACL 联动
合同 OnlyOffice 编辑器 autosave 与配置下发 当前没有云盘式版本表和独立 forcesave 端点
前端 OnlyOfficeEditor dirty 检测、定时 forcesave 只在 editable + fileId 时启动

这是一套平台配置,但不同业务域仍要拥有自己的权限校验和回存语义。企业云盘、项目文档有独立版本链;合同正文按合同服务回存,不能把云盘的版本合并能力直接写成合同已具备。共用 OnlyOffice 也不能绕过云盘 ACL、合同状态或项目成员权限。


二、业务流程怎么串

2.1 打开 Word,编辑器拿到间隔而不是写死 10 分钟

用户在企业云盘点"在线编辑"。后端返回 documentServerUrl、签名后的编辑器 config、documentKeyforceSaveIntervalMssnapshotEnabled

▲ 在线编辑页能直接改 Word;编辑器底部"已保存"只说明文档服务侧缓存完成,是否进入版本历史取决于业务快照链路

前端优先使用后端返回的 forceSaveIntervalMs

  • 快照关闭,后端返回 0;
  • 快照开启,默认返回 600000 毫秒;
  • 小于 60 秒的配置被后端夹到 60 秒;
  • 老接口没返回时,前端才使用 props 或 600000 毫秒兜底。

2.2 只有文档变脏,定时器才请求 forcesave

onDocumentStateChangedata=true 表示文档有尚未保存到 Document Server 的修改。编辑器 ready 后启动定时器;到点时只有 documentDirty=true 才调用后端。

这能避免用户打开文档只看不改,却每 10 分钟制造一次无意义 Command Service 请求。

后端再做三道门:

  1. OnlyOffice 必须启用;
  2. 当前用户至少有在线编辑或预览权限;
  3. 必须拿到当前 documentKey

然后向 /coauthoring/CommandService.ashxc=forcesave,命令体用 JWT 签名。error=0 表示请求成功,error=4 表示没有变更,两者都可接受。

2.3 Document Server 以 status=6 回调,业务端才换文件

forcesave 不是直接把版本行插进数据库,它只是要求 Document Server 立即回传当前文档。回调只处理 status=2(关闭后保存)和 status=6(强制保存)。

业务端下载回调 URL 的文件,重新上传到文件服务,得到新的 fileUrl,再更新云盘文件并创建来源为 autosave 的版本。

这一步还会解析最后编辑人:

  • 优先读 history.changes 最后一条 user;
  • 其次读 actions 里的 userid;
  • 再退回打开编辑器时 JWT 中的用户。

协同编辑时,版本上的编辑人因此更接近真正最后改动者。

2.4 同一窗口不递增,跨窗口才创建新版本

第一次 autosave 会创建新版本;后续 autosave 如果满足四个条件,就更新最近一条:

  1. 开启 coalesce-autosave
  2. 本次来源是 autosave;
  3. 最近一条来源也是 autosave;
  4. 最近版本的 updateTime 加合并窗口仍晚于当前时间。

更新时会替换最近版本的 URL、大小、文件名、编辑人和备注,并刷新 updateTime。因为窗口是滑动的,持续编辑会持续更新这一版,避免长会话机械地每 10 分钟涨一个版本号。

2.5 历史抽屉仍然可以预览、命名和恢复

▲ 历史抽屉显示 v8 到 v14、来源和编辑人;命名当前版会把 autosave/upload 转成 manual,恢复旧版则复制内容创建一个新版本

恢复不是把 isCurrent 指针拨回旧行。系统会下载目标版本内容、重新上传成新文件,再创建来源为 restore 的新版本。这样版本链保持线性,恢复动作本身也可审计。

超过 max-count 后,系统按版本倒序保留最新 N 条,删除更旧的非当前版本。它只删版本记录;底层对象存储文件是否回收,还需要独立的文件生命周期策略,不能夸成"自动清理全部存储"。


三、设计怎么落地

3.1 设计决策

决策点 方案 理由
编辑器防丢与业务留版 两个开关 有的客户只要缓存,不要高频留历史
间隔来源 后端配置下发毫秒值 多业务端一致,实施无需发前端版
最小间隔 60 秒 防止误配 1 秒打爆 Document Server
空闲文档 dirty=false 不 forcesave 只读打开不制造请求
回存触发 Command Service + status=6 可在长编辑会话中拿到业务文件
版本合并 按 autosave 来源和滑动窗口 防止版本号机械膨胀
恢复策略 恢复即新版本 历史线性、动作可审计
保留上限 删除最旧非当前版本 约束版本表体量,不误删当前版

3.2 表结构:当前文件与历史版本分开

#mermaid-svg-3KlzEOKkWd0NVrya{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3KlzEOKkWd0NVrya .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3KlzEOKkWd0NVrya .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3KlzEOKkWd0NVrya .error-icon{fill:#552222;}#mermaid-svg-3KlzEOKkWd0NVrya .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3KlzEOKkWd0NVrya .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3KlzEOKkWd0NVrya .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3KlzEOKkWd0NVrya .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3KlzEOKkWd0NVrya .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3KlzEOKkWd0NVrya .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3KlzEOKkWd0NVrya .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3KlzEOKkWd0NVrya .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3KlzEOKkWd0NVrya .marker.cross{stroke:#333333;}#mermaid-svg-3KlzEOKkWd0NVrya svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3KlzEOKkWd0NVrya p{margin:0;}#mermaid-svg-3KlzEOKkWd0NVrya .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-3KlzEOKkWd0NVrya .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-3KlzEOKkWd0NVrya .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-3KlzEOKkWd0NVrya .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-3KlzEOKkWd0NVrya .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-3KlzEOKkWd0NVrya .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3KlzEOKkWd0NVrya .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-3KlzEOKkWd0NVrya .node rect,#mermaid-svg-3KlzEOKkWd0NVrya .node circle,#mermaid-svg-3KlzEOKkWd0NVrya .node ellipse,#mermaid-svg-3KlzEOKkWd0NVrya .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3KlzEOKkWd0NVrya .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-3KlzEOKkWd0NVrya .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-3KlzEOKkWd0NVrya .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3KlzEOKkWd0NVrya .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-3KlzEOKkWd0NVrya .edgeLabel .label text{fill:#333;}#mermaid-svg-3KlzEOKkWd0NVrya :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} has
resolves
OA_FILE_INFO
bigint
id
PK
string
file_url
bigint
file_size
int
current_version_no
OA_FILE_VERSION
bigint
id
PK
bigint
file_id
FK
int
version_no
string
file_url
string
source
boolean
is_current
datetime
update_time
INFRA_CONFIG
string
category
string
config_key
string
value
ONLINE_DOC_SETTINGS

oa_file_info.file_url 是当前正文;每个版本保存独立 URL。配置仍放通用 infra_config,但 category 固定为 online-doc,页面不会把别的系统参数混进来。

3.3 时序:forcesave 成功不等于版本已写完

文件与版本表 文件服务 Document Server 云盘服务 OnlyOfficeEditor 文件与版本表 文件服务 Document Server 云盘服务 OnlyOfficeEditor #mermaid-svg-0cULPqlStsbLZccf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0cULPqlStsbLZccf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0cULPqlStsbLZccf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0cULPqlStsbLZccf .error-icon{fill:#552222;}#mermaid-svg-0cULPqlStsbLZccf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0cULPqlStsbLZccf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0cULPqlStsbLZccf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0cULPqlStsbLZccf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0cULPqlStsbLZccf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0cULPqlStsbLZccf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0cULPqlStsbLZccf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0cULPqlStsbLZccf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0cULPqlStsbLZccf .marker.cross{stroke:#333333;}#mermaid-svg-0cULPqlStsbLZccf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0cULPqlStsbLZccf p{margin:0;}#mermaid-svg-0cULPqlStsbLZccf .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0cULPqlStsbLZccf text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-0cULPqlStsbLZccf .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0cULPqlStsbLZccf .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-0cULPqlStsbLZccf .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-0cULPqlStsbLZccf .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-0cULPqlStsbLZccf #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-0cULPqlStsbLZccf .sequenceNumber{fill:white;}#mermaid-svg-0cULPqlStsbLZccf #sequencenumber{fill:#333;}#mermaid-svg-0cULPqlStsbLZccf #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-0cULPqlStsbLZccf .messageText{fill:#333;stroke:none;}#mermaid-svg-0cULPqlStsbLZccf .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0cULPqlStsbLZccf .labelText,#mermaid-svg-0cULPqlStsbLZccf .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-0cULPqlStsbLZccf .loopText,#mermaid-svg-0cULPqlStsbLZccf .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-0cULPqlStsbLZccf .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0cULPqlStsbLZccf .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-0cULPqlStsbLZccf .noteText,#mermaid-svg-0cULPqlStsbLZccf .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-0cULPqlStsbLZccf .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0cULPqlStsbLZccf .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0cULPqlStsbLZccf .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0cULPqlStsbLZccf .actorPopupMenu{position:absolute;}#mermaid-svg-0cULPqlStsbLZccf .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-0cULPqlStsbLZccf .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0cULPqlStsbLZccf .actor-man circle,#mermaid-svg-0cULPqlStsbLZccf line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-0cULPqlStsbLZccf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} User 编辑 WorddocumentDirty=true到间隔 POST /forcesaveCommandService c=forcesave + JWTerror=0/4callback status=6 + url下载当前文档上传新文件更新 fileUrl合并最近 autosave 或创建 vN+1 User

前端收到 forcesave 命令成功后会清 documentDirty,真正的业务版本仍由后续回调完成。运维排障时必须把 Command Service 响应和 callback 日志分开看。

3.4 前端只在"可编辑、有关联文件、文档已变脏"时回存

对应 2.2。定时器不是无脑轮询版本接口。

typescript 复制代码
async function requestForceSave() {
  if (!props.editable || !props.fileId || !documentKey || forceSaving) {
    return;
  }
  forceSaving = true;
  try {
    await forceSaveOaFileOnlyOffice(props.fileId, documentKey);
    documentDirty = false;
  } finally {
    forceSaving = false;
  }
}

function startForceSaveTimer() {
  stopForceSaveTimer();
  if (!props.editable || !props.fileId || resolvedForceSaveIntervalMs <= 0) {
    return;
  }
  forceSaveTimer = window.setInterval(() => {
    if (documentDirty) {
      void requestForceSave();
    }
  }, resolvedForceSaveIntervalMs);
}

单次失败被忽略,下一轮仍会重试;路由卸载时还会调用 flushAndDestroy,尽量再回存一次后销毁编辑器。

3.5 后端签名 forcesave,允许"无变更"

对应 2.2。Command Service 使用当前 documentKey,不靠文件名猜会话。

java 复制代码
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 token = JWTUtil.createToken(cmd, secret);
String url = StrUtil.removeSuffix(properties.getServerUrl(), "/")
        + "/coauthoring/CommandService.ashx";
String raw = HttpRequest.post(url)
        .body(JSONUtil.toJsonStr(Map.of("token", token)))
        .timeout(15_000)
        .execute()
        .body();
Integer error = JSONUtil.parseObj(raw).getInt("error");
if (error != null && error != 0 && error != 4) {
    throw exception(FILE_ONLYOFFICE_FORCESAVE_FAILED);
}

error=4 代表当前没有可强制保存的变更,不应该被包装成系统故障。

3.6 版本服务先判断合并,再决定是否递增

对应 2.4。合并使用最近版本的 updateTime,形成滑动窗口。

java 复制代码
OnlineDocSettingsRespDTO settings = loadOnlineDocSettings();
FileVersionDO latest = TenantUtils.executeIgnore(
        () -> fileVersionMapper.selectCurrentByFileId(fileInfo.getId()));
LocalDateTime latestTime = latest == null ? null
        : (latest.getUpdateTime() != null
                ? latest.getUpdateTime() : latest.getCreateTime());
if (latest != null
        && settings.shouldCoalesceAutosave(
                source, latest.getSource(), latestTime)) {
    latest.setFileUrl(fileInfo.getFileUrl());
    latest.setFileSize(fileInfo.getFileSize());
    latest.setFileName(fileInfo.getFileName());
    latest.setEditorUserId(editorUserId);
    latest.setEditorUserName(
            StrUtil.blankToDefault(editorUserName, "用户"));
    latest.setRemark(remark);
    fileVersionMapper.updateById(latest);
    return latest;
}
Integer nextNo = TenantUtils.executeIgnore(
        () -> fileVersionMapper.selectMaxVersionNo(fileInfo.getId())) + 1;

只有不是合并场景,才清当前标记、插入 versionNo=nextNo 的新行,并更新主文件的 currentVersionNo


四、怎么配置:不是越频繁越安全

文档场景 建议间隔 是否合并 上限建议 说明
普通制度、会议纪要 600 秒 50 默认方案,平衡恢复粒度与存储
合同多人协同 300 秒 80 修改价值高,可缩短间隔
大体积演示文稿 900 秒 30 避免高频上传大文件
只读归档 关闭快照 --- 保留既有 权限层还要同时禁止编辑
临时演示环境 600 秒 20 控制磁盘,但不要声称等于生产策略

"一分钟一版"并不等于一分钟新增一个版本号:开启合并后,持续编辑会更新最近 autosave。若希望每隔固定时间留下不可覆盖的里程碑,应关闭合并或由用户手动命名版本,但要接受更多存储和历史噪声。

4.1 三个不能夸大的边界

  1. 前端定时器不是后台任务。 浏览器休眠、断网或页面被强制关闭时,不保证准点触发;
  2. 版本上限不等于对象存储回收。 当前代码裁剪版本行,历史文件物理清理需要文件生命周期治理;
  3. autosave 不是电子归档。 合同归档还要结合业务状态、权限只读和审计规则。

五、失败场景与运维定位

5.1 编辑器显示已保存,回调却没进业务系统

这是最容易误判的一类故障。编辑器侧成功只说明 Document Server 接住了内容,业务历史还依赖反向回调。

排查顺序应固定:

text 复制代码
浏览器 documentDirty
  → POST /oa/file/onlyoffice/forcesave
  → CommandService error
  → callback status=6/2
  → editedUrl 下载
  → 文件服务上传
  → file_info 更新
  → file_version 合并或新增
现象 优先检查 不要先做
从不出现自动保存版本 snapshot.enabled、前端返回间隔 重写版本号算法
forcesave 500 documentKey、JWT secret、Command Service 地址 把异常全部吞掉
Command error=4 文档是否真的改过 当系统故障报警
有回调但没新 URL editedUrl 下载、文件服务上传 只刷新前端历史列表
最近版本一直被更新 coalesce=true 与滑动窗口 误报版本丢失
版本持续暴涨 合并关闭、source 不是 autosave 直接删当前版本

5.2 回调为什么必须恢复租户上下文

OnlyOffice Document Server 发回调时没有浏览器登录态。业务端从签名 token 中读取 tenantId;兼容旧 token 时,再从文件表按 fileId 查询租户。

若仍拿不到有效租户,当前实现直接失败,不允许把版本写进 tenant_id=0。这个选择比"先存下来再说"安全,因为错租户文件比一次保存失败更难修。

上下文 来源 用途
bizId JWT payload 找到云盘文件
bizType JWT payload 防止合同/项目回调串业务
tenantId JWT 或文件表 在正确租户内更新
userId/userName 回调 history/actions 或 JWT 记录最后编辑人
editable JWT payload 只读会话拒绝回存

5.3 页面关闭时的 flush 只是"尽量"

组件卸载会停止定时器,再调用一次 forcesave,等待默认 800~1500 毫秒后销毁编辑器。这个动作能覆盖正常路由切换,但不能承诺覆盖:

  • 浏览器进程崩溃;
  • 电脑断电;
  • 系统强杀 WebView;
  • 网络在关闭瞬间中断;
  • 浏览器对后台页签定时器降频。

因此真正的防丢基础仍是 OnlyOffice 自身 autosave;业务快照提高可恢复性,但不是本地事务日志。

5.4 长会话滑动合并的产品取舍

shouldCoalesceAutosave 用最近版本的 updateTime 判断窗口。每次合并都会刷新 updateTime,所以连续编辑 3 小时,可能只保留一条持续更新的 autosave。

这个行为的优点:

  • 历史列表不会每 10 分钟多一行;
  • 对象和数据库增长更慢;
  • 用户看到的版本更像"阶段",不是定时器日志。

代价:

  • 无法保证每 10 分钟都有一个永久时间点;
  • 最近 autosave 被持续覆盖;
  • 强审计场景不能只依赖合并版本。

如果客户要求"任何 10 分钟点都可追溯",应关闭合并并提高版本上限,同时评估对象存储成本。不要一边要求固定快照,一边又要求历史永不增长。

5.5 删除版本记录不代表删除物理文件

当前 trimOldVersions 删除超过上限的最旧非当前行。每次回存上传的新对象可能仍留在文件存储。

完整的存储治理还需要:

  1. 版本记录删除后登记待清理 URL;
  2. 确认没有主文件、其它版本或业务附件引用;
  3. 延迟一段时间再删对象;
  4. 删除失败进入重试任务;
  5. 审计保留期内禁止物理清理。

这类垃圾回收不应该塞进回调事务同步执行,否则对象存储抖动会拖慢每次保存。


六、跨业务域一致性

同一个 OnlyOfficeEditor 被云盘、项目文档和合同调用,配置可以统一,但三者的业务门禁不同。

业务域 谁能编辑 版本如何看 关闭编辑的条件
企业云盘 ACL 可在线编辑 历史抽屉 分享只读、权限收回
项目文档 项目成员与文档 ACL 项目文档版本 项目完成/归档、成员移除
合同正文 合同权限与状态 当前按合同正文回存,不等同云盘版本链 合同归档、审批状态限制

平台层可以统一:

  • JWT 签名方式;
  • documentKey 传递;
  • 五项在线文档配置;
  • 只读会话不回存。

但 dirty 定时 forcesave 只有在组件拿到 fileId 和对应 forcesave 端点时才启动。当前企业云盘、项目文档具备这条链;合同只消费编辑器 autosave/配置,不能宣传为已经支持相同的定时业务快照与 coalesce。

业务层必须分别负责:

  • 当前用户能不能改;
  • 回调写哪张主表;
  • 是否存在版本表、版本来源叫什么;
  • 归档后是否允许恢复;
  • 编辑人如何进入审计。

"共用一个编辑器"不等于"共用一套业务 Service"。把合同 fileUrl 直接交给云盘回调更新,会造成最难追的跨表写错。


七、验收矩阵

7.1 配置验收

编号 配置 预期
S01 snapshot=false forceSaveIntervalMs=0,不启定时器
S02 interval=30 运行时夹到 60 秒
S03 interval=600 下发 600000 毫秒
S04 max-count=0 回退默认 50
S05 配置缺失 五项使用 DTO 默认值
S06 infra 配置读取失败 业务用默认值并记录 warning

7.2 保存验收

编号 场景 预期
A01 打开后只看不改 不发定时 forcesave
A02 编辑后到间隔 发 forcesave
A03 Command error=0 等待 status=6 回调
A04 Command error=4 接受为无变更
A05 只读会话回调 拒绝回存
A06 回调 status 非 2/6 忽略,不换业务文件
A07 关闭编辑页 尽量 flush 后销毁

7.3 版本验收

编号 场景 预期
V01 首次 autosave 创建新版本并设当前
V02 窗口内再次 autosave 更新最近版本,不递增
V03 coalesce=false 每次有效回存新建版本
V04 最近来源是 manual 下次 autosave 新建版本
V05 命名当前 autosave 来源转 manual,备注更新
V06 恢复 v8 内容复制成新的当前版本
V07 超过 max-count 删除最旧非当前记录
V08 历史脏数据 tenant_id=0 取最大版本号时兼容,避免冲突

7.4 安全验收

编号 攻击或误用 预期
P01 篡改 forcesave fileId ACL 校验拒绝
P02 篡改 documentKey Document Server 拒绝或无会话
P03 callback token 签名错误 业务端拒绝
P04 token 过期 拒绝下载或回调
P05 bizType 不匹配 不写云盘文件
P06 tenantId 缺失 失败,不写 tenant 0

一轮验收至少要同时看页面、网络请求、后端回调日志和版本表。只看历史抽屉多了一行,无法证明 JWT、租户和最后编辑人都正确。


八、快速体验

在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)

推荐体验流程:

  1. 进入基础设施 → 文件管理 → 在线文档配置;
  2. 确认业务快照开启、间隔 600 秒、合并开启;
  3. 进入 OA → 企业云盘,选择 Word 文件在线编辑;
  4. 修改一段文字,观察 OnlyOffice 底部"已保存";
  5. 等待配置间隔或关闭编辑页,打开历史记录;
  6. 确认出现来源为"自动保存"的版本;
  7. 在窗口内继续修改,观察最近版更新而非每次递增;
  8. 命名当前版本,再恢复一个旧版,确认恢复生成新版本。

源码地址:


常见问题(FAQ)

OnlyOffice 显示"已保存",为什么版本历史没有新增?

因为"已保存"首先是编辑器缓存状态。业务版本需要 forcesave 或关闭会话触发 status=6/2 回调,再由业务服务上传文件并写版本表。

自动保存间隔可以小于 60 秒吗?

配置可以被写入,但运行时会夹到最小 60 秒,避免误配置造成高频回存压力。

开启间隔合并后,会不会永远只有一个版本?

持续编辑的 autosave 使用滑动窗口,可能长期更新最近一版;命名版本、恢复版本或其它来源会打断连续 autosave 条件。是否需要固定里程碑,应结合业务配置。

恢复旧版本是把 current 指针改回去吗?

不是。系统复制旧内容、上传成新文件,再创建来源为 restore 的新版本,历史链不会倒退。

版本最多保留 50 条,会自动删除对象存储里的旧文件吗?

当前能力主要裁剪最旧的非当前版本记录。对象存储物理文件回收需要独立生命周期或垃圾回收机制。


结语

在线文档可靠保存的关键,不是把定时器调到最短,而是把编辑器缓存、业务快照、版本合并、命名里程碑和恢复审计分成五个可解释的动作。

你们更在意"5 分钟内可恢复",还是"历史版本尽量少而清晰"?多人合同和普通制度文档会配同一套间隔吗?欢迎在评论区交流。


💡 想要体验 RuoYi Office 的强大功能?

🌐 在线演示https://ruoyioffice.com/web/(账号 admin / admin123)

📦 源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office

💬 技术咨询 :添加微信 17156169080,备注「RuoYi Office」

如果觉得不错,请给个 Star 支持一下!

相关推荐
huaweichenai1 小时前
spring boot使用hutool实现调用外部接口
java·spring boot
dkbnull2 小时前
Spring Boot配置文件敏感信息加密解密
spring boot·后端
白宇横流学长2 小时前
图书个性化推荐系统的设计与实现【源码+文档】
spring boot
RuoyiOffice2 小时前
SpringBoot3+Vue3+Flowable 企业审批平台全链路:从表单设计、节点权限到移动审批怎么落地
spring boot·vue3·uniapp·springboot3·flowable·审批系统·ruoyi office
Bs_MoneyMagnet3 小时前
基于springboot+vue的图书馆预约系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·毕业设计·图书管理系统·计算机毕业设计
小蒜学长3 小时前
基于小程序的绘画作品创作与分享社区系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·绘画作品·创作分享社区
RuoyiOffice3 小时前
SpringBoot3+Vue3 企业级管理平台架构:多租户、RBAC、数据权限、工作流与微服务一次讲透
spring boot·vue3·springboot3·rbac·多租户·数据权限·ruoyi office
砍材农夫4 小时前
spring|spring web|拦截器、过滤器、aop
java·spring boot·spring·spring cloud
weixin_BYSJ198713 小时前
【计算机毕设】基于SpringBoot与Vue的文物保护档案管理系统08621
vue.js·spring boot·spring cloud·微服务·架构·django·课程设计