SpringBoot 接 OnlyOffice,最容易踩的坑不是页面嵌不进去,而是把“编辑成功”和“文件已经落盘”当成一回事。OnlyOffice 负责编辑和组装文件,最终把文件写回磁盘、MinIO 或 OSS,还是你自己的系统负责。
整个链路其实只有三块:
SpringBoot 管文件、权限和版本; OnlyOffice Document Server 管编辑与转换; 浏览器嵌入编辑器页面。
打开文档时,SpringBoot 返回一份编辑配置。Document Server 根据 document.url 主动下载原文件,编辑结束后再请求 callbackUrl,把新文件地址交回来。所以这两个地址不是浏览器能访问就行,Document Server 所在容器也必须能访问。
先把配置收紧,不要把地址散落在业务代码里。
onlyoffice:server:http://onlyoffice-docsfile-base-url:http://word-service:8080callback-base-url:http://word-service:8080jwt-secret:${ONLYOFFICE_JWT_SECRET}生成编辑配置时,我一般把文件版本直接放进 key。这个字段不能随手写文件 ID,更不能永远不变。OnlyOffice 会根据 key 使用缓存,文件保存出新版本后,下一次打开必须换新 key,否则用户很可能看到旧内容。
@Service@RequiredArgsConstructorpublicclassOfficeEditorService{privatefinal OfficeProperties properties;privatefinal OfficeTokenSigner tokenSigner;public Map<String, Object> buildConfig(DocumentFile file, CurrentUser user){ String editKey = file.id() + "-v" + file.version(); Map<String, Object> document = new LinkedHashMap<>(); document.put("fileType", "docx"); document.put("key", editKey); document.put("title", file.name()); document.put("url", properties.fileBaseUrl() + "/api/files/" + file.id() + "/content"); document.put("permissions", Map.of("edit", user.canEdit(file),"download", user.canDownload(file),"print", user.canPrint(file) )); Map<String, Object> editor = new LinkedHashMap<>(); editor.put("mode", user.canEdit(file) ? "edit" : "view"); editor.put("lang", "zh-CN"); editor.put("callbackUrl", properties.callbackBaseUrl() + "/api/office/callback/" + file.id()); editor.put("user", Map.of("id", String.valueOf(user.id()),"name", user.displayName() )); Map<String, Object> config = new LinkedHashMap<>(); config.put("documentType", "word"); config.put("document", document); config.put("editorConfig", editor); config.put("token", tokenSigner.sign(config));return config; }}前端拿到这份配置后,交给 DocsAPI.DocEditor 即可。真正麻烦的是后面的保存。
OnlyOffice 不会直接覆盖你的原文件。用户关闭编辑器后,它会调用回调接口。状态 2 表示文件已经可以保存,状态 6 表示触发了强制保存;成功处理后,接口必须返回 {"error":0}。
@RestController@RequiredArgsConstructor@RequestMapping("/api/office")publicclassOfficeCallbackController{privatefinal DocumentStorage storage;privatefinal DocumentService documentService;@PostMapping("/callback/{fileId}")public Map<String, Integer> callback( @PathVariable Long fileId, @RequestBody OfficeCallback body){if (body.status() != 2 && body.status() != 6) {return Map.of("error", 0); }try { storage.replaceFromRemote(fileId, body.url());// status=6 只是当前编辑状态快照,不在这里切断正在进行的协作会话if (body.status() == 2) { documentService.increaseVersion(fileId); }return Map.of("error", 0); } catch (Exception ex) { documentService.recordSaveFailure( fileId, body.key(), body.status(), ex.getMessage());return Map.of("error", 1); } }public record OfficeCallback(int status, String key, String url, String filetype){ }}这里有两个地方我不会省。
第一,回调请求必须验 JWT,下载地址也要限制为可信的 Document Server 域名。否则一个伪造请求就可能让服务端去下载任意地址。
第二,不能把远程文件直接覆盖原文件。稳一点的做法是先下载到临时文件,校验大小和格式,再原子替换。下载到一半连接断了,至少不会把线上 Word 覆盖成半截文件。
Word 转 PDF 也没必要自己装 LibreOffice 起进程。OnlyOffice 提供了 /converter 接口,请求里传源文件地址、原格式和目标格式即可。
public URI convertToPdf(DocumentFile file){ Map<String, Object> payload = new LinkedHashMap<>(); payload.put("async", false); payload.put("filetype", "docx"); payload.put("outputtype", "pdf"); payload.put("key", file.id() + "-pdf-v" + file.version()); payload.put("title", file.name()); payload.put("url", fileDownloadUrl(file.id())); Map<String, Object> request = new LinkedHashMap<>(payload); request.put("token", tokenSigner.sign(payload)); ConvertResult result = restClient.post() .uri(onlyOfficeServer + "/converter") .header("Accept", "application/json") .body(request) .retrieve() .body(ConvertResult.class);if (result == null || !result.endConvert() || result.fileUrl() == null) {thrownew IllegalStateException("文档转换失败,错误码:" + (result == null ? "empty" : result.error())); }return URI.create(result.fileUrl());}public record ConvertResult(boolean endConvert, Integer error, String fileUrl){}拿到 fileUrl 后,再由 SpringBoot 下载并存进自己的文件系统。Document Server 是编辑服务,不是文件仓库,这个边界别弄混。
线上再遇到“能打开但保存失败”,我一般先看三件事:Document Server 能不能访问文件下载地址,回调有没有收到状态 2,保存成功后文件版本有没有递增。多数问题翻到这里,已经不用再怀疑前端了。
夜雨聆风