ARTICLE · 1068638
SpringBoot + ONLYOFFICE:在线编辑 Word,真正麻烦的是把文件保存回来
我第一次接 ONLYOFFICE 时,就卡在这里。
排了半天才发现,ONLYOFFICE 并不会直接修改我们服务器上的原文件。它负责加载、编辑和生成新文件,真正把文件落回业务系统,还是得靠 SpringBoot 自己处理。
整个链路其实只有四步:
浏览器向 SpringBoot 请求编辑配置,浏览器加载 ONLYOFFICE 编辑器,Document Server 下载原始 Word,编辑完成后再回调 SpringBoot,由 SpringBoot 把新文件保存回去。
这里我一般先把几个地址分清楚:
onlyoffice:
server-url:https://office.example.com
file-base-url:https://api.example.com/files
callback-base-url:https://api.example.com/office/callback
jwt-secret:${ONLYOFFICE_JWT_SECRET}
server-url 是浏览器访问编辑器的地址。
file-base-url 是 Document Server 下载原文件的地址。
callback-base-url 是 Document Server 编辑完成后通知业务系统的地址。
这三个地址最容易配乱。尤其不能随手写 localhost,因为 Document Server 访问的 localhost 是它自己,不是你的 SpringBoot。文档下载地址和回调地址都必须能被 Document Server 实际访问。启用 JWT 后,编辑配置还要使用与服务端一致的密钥签名。
编辑配置没必要在前端东拼西凑,我更习惯后端一次性组好:
@Service
@RequiredArgsConstructor
publicclassWordEditorService{
privatefinal DocumentRepository documentRepository;
privatefinal OfficeTokenSigner tokenSigner;
privatefinal OfficeProperties properties;
public Map<String, Object> buildEditor(Long documentId, LoginUser operator){
DocumentMeta file = documentRepository.mustFind(documentId);
String documentKey = file.getId() + "-v" + file.getVersion();
Map<String, Object> document = new LinkedHashMap<>();
document.put("title", file.getName());
document.put("fileType", "docx");
document.put("key", documentKey);
document.put("url", properties.getFileBaseUrl() + "/" + file.getId() + "/download");
Map<String, Object> editorConfig = new LinkedHashMap<>();
editorConfig.put("mode", "edit");
editorConfig.put("lang", "zh-CN");
editorConfig.put(
"callbackUrl",
properties.getCallbackBaseUrl() + "/" + file.getId()
);
editorConfig.put("user", Map.of(
"id", operator.id().toString(),
"name", operator.displayName()
));
editorConfig.put("customization", Map.of("forcesave", true));
Map<String, Object> config = new LinkedHashMap<>();
config.put("documentType", "word");
config.put("document", document);
config.put("editorConfig", editorConfig);
config.put("token", tokenSigner.sign(config));
return config;
}
}
前端拿到配置后,只负责创建编辑器:
new DocsAPI.DocEditor("word-editor", editorConfig);
这地方有个坑,我一般会专门盯一下 document.key。
它不能永远只用文件 ID。相同的 key 会被认为是同一个编辑会话,甚至可能继续读取缓存中的旧文件。正式保存结束后,再次打开编辑页面应该生成新的 key,所以比较稳妥的做法是“文件 ID + 版本号”。协同编辑中的用户则必须使用同一个 key,才能进入同一会话。
页面打开只是前半截,回调保存才是这套功能真正容易出问题的地方。
ONLYOFFICE 回调中的 status=2 表示文档已经可以正式保存,status=6 表示触发了强制保存。回调参数里的 url,才是修改后文件的临时下载地址。
public record OfficeCallback(
Integer status,
String key,
String url,
String filetype
){
}
@RestController
@RequiredArgsConstructor
publicclassOfficeCallbackController{
privatefinal HttpClient httpClient;
privatefinal DocumentStore documentStore;
privatefinal DocumentRepository documentRepository;
privatefinal OfficeUrlGuard officeUrlGuard;
@PostMapping("/office/callback/{documentId}")
public Map<String, Integer> callback(
@PathVariable Long documentId,
@RequestBody OfficeCallback body){
if (body.status() == null ||
(body.status() != 2 && body.status() != 6)) {
return Map.of("error", 0);
}
if (!officeUrlGuard.isTrusted(body.url())) {
return Map.of("error", 1);
}
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(body.url()))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(
request,
HttpResponse.BodyHandlers.ofInputStream()
);
if (response.statusCode() != 200) {
return Map.of("error", 1);
}
try (InputStream input = response.body()) {
documentStore.atomicReplace(documentId, input, body.filetype());
}
if (body.status() == 2) {
documentRepository.increaseVersion(documentId);
}
return Map.of("error", 0);
} catch (Exception ex) {
return Map.of("error", 1);
}
}
}
这里我不会让回调传来的 URL 随便下载。
这个接口天然带着 SSRF 风险,至少要校验协议、域名和端口,只允许访问自己的 Document Server。保存文件也别直接覆盖原文件,先写临时文件,校验完成后再原子替换。否则下载到一半网络断了,旧文件也跟着废了。
另外,status=6 只是编辑过程中的阶段性保存,不要急着修改文档版本号。否则新用户再次打开时会拿到新 key,直接和当前协同编辑会话分家。
Word 转 PDF 反而简单一些,调用 Document Server 的 /converter 接口即可。请求使用 JSON,想直接接收 JSON 响应,需要带上 Accept: application/json。
public ConvertResult convertToPdf(DocumentMeta file){
Map<String, Object> command = new LinkedHashMap<>();
command.put("async", false);
command.put("filetype", "docx");
command.put("outputtype", "pdf");
command.put("key", file.getId() + "-pdf-v" + file.getVersion());
command.put("title", file.getName());
command.put("url", fileUrlService.createReadUrl(file));
command.put("token", tokenSigner.sign(command));
ConvertResult result = officeClient.post()
.uri("/converter")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.body(command)
.retrieve()
.body(ConvertResult.class);
if (result == null || !Boolean.TRUE.equals(result.endConvert())) {
thrownew IllegalStateException("Word 转 PDF 未完成");
}
return result;
}
public record ConvertResult(
Boolean endConvert,
String fileUrl,
Integer error
){
}
拿到 fileUrl 后,再由后端下载到对象存储,不要把这个临时地址直接当永久下载地址返回给前端。
ONLYOFFICE 接入代码并不算多。真正要守住的是文件 URL 可达、key 跟着版本变化、回调覆盖保存、JWT 密钥一致,以及下载地址不能失控。
这几个地方没处理好,页面看着能编辑,文件最后还是可能丢。