SpringBoot3+OnlyOffice 自动保存版本:定时回存、间隔合并与 50 版上限怎么配
🌐 文档地址 :https://ruoyioffice.com
📦 源码1·GitHub :https://github.com/yuqing2026/ruoyi-office
📦 源码2·GitCode :https://gitcode.com/zhouzhongyan/ruoyi-office
📦 源码3·Gitee :https://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 |
如果这四层不拆,产品会出现两个反直觉:
- 用户以为"已保存"就能在历史里找到;
- 实施把快照间隔调得很短,版本号和对象存储一起膨胀。
本文专讲自动保存如何稳定落到版本历史,又不让版本号爆炸。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、documentKey、forceSaveIntervalMs 和 snapshotEnabled。

▲ 在线编辑页能直接改 Word;编辑器底部"已保存"只说明文档服务侧缓存完成,是否进入版本历史取决于业务快照链路
前端优先使用后端返回的 forceSaveIntervalMs:
- 快照关闭,后端返回 0;
- 快照开启,默认返回 600000 毫秒;
- 小于 60 秒的配置被后端夹到 60 秒;
- 老接口没返回时,前端才使用 props 或 600000 毫秒兜底。
2.2 只有文档变脏,定时器才请求 forcesave
onDocumentStateChange 的 data=true 表示文档有尚未保存到 Document Server 的修改。编辑器 ready 后启动定时器;到点时只有 documentDirty=true 才调用后端。
这能避免用户打开文档只看不改,却每 10 分钟制造一次无意义 Command Service 请求。
后端再做三道门:
- OnlyOffice 必须启用;
- 当前用户至少有在线编辑或预览权限;
- 必须拿到当前
documentKey。
然后向 /coauthoring/CommandService.ashx 发 c=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 如果满足四个条件,就更新最近一条:
- 开启
coalesce-autosave; - 本次来源是 autosave;
- 最近一条来源也是 autosave;
- 最近版本的 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 三个不能夸大的边界
- 前端定时器不是后台任务。 浏览器休眠、断网或页面被强制关闭时,不保证准点触发;
- 版本上限不等于对象存储回收。 当前代码裁剪版本行,历史文件物理清理需要文件生命周期治理;
- 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 删除超过上限的最旧非当前行。每次回存上传的新对象可能仍留在文件存储。
完整的存储治理还需要:
- 版本记录删除后登记待清理 URL;
- 确认没有主文件、其它版本或业务附件引用;
- 延迟一段时间再删对象;
- 删除失败进入重试任务;
- 审计保留期内禁止物理清理。
这类垃圾回收不应该塞进回调事务同步执行,否则对象存储抖动会拖慢每次保存。
六、跨业务域一致性
同一个 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)
推荐体验流程:
- 进入基础设施 → 文件管理 → 在线文档配置;
- 确认业务快照开启、间隔 600 秒、合并开启;
- 进入 OA → 企业云盘,选择 Word 文件在线编辑;
- 修改一段文字,观察 OnlyOffice 底部"已保存";
- 等待配置间隔或关闭编辑页,打开历史记录;
- 确认出现来源为"自动保存"的版本;
- 在窗口内继续修改,观察最近版更新而非每次递增;
- 命名当前版本,再恢复一个旧版,确认恢复生成新版本。
源码地址:
- GitHub:https://github.com/yuqing2026/ruoyi-office
- GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office
- Gitee:https://gitee.com/yqzy1688/ruoyi-office
常见问题(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 支持一下!