ARTICLE · 1167110
Spring Boot 接 OnlyOffice,真正难的不是打开 Word,而是把文件保存回来
结果刷新页面,刚改的内容全没了。
这种问题我一般不先查前端。OnlyOffice 编辑器能正常加载,说明初始化配置大概率已经通了,真正容易断的是后半段:OnlyOffice 通知 Spring Boot,Spring Boot 再把修改后的文件下载回来。
这条链路少一步,页面看着再正常也没用。
整个流程其实只有三个角色:
Spring Boot:管理文件、权限、版本和保存回调; OnlyOffice Document Server:负责在线编辑和格式转换; 浏览器:加载编辑器页面,不直接保存 Word。
这里有个很容易想错的地方:OnlyOffice 不会拿着你的文件路径直接覆盖原文件。编辑结束后,它会调用 callbackUrl,在请求里给出修改后文件的下载地址,业务服务必须主动下载并保存。
先把编辑配置组出来
Spring Boot 不需要渲染整个编辑器,只要给前端返回一份配置。
@Service
@RequiredArgsConstructor
publicclassWordEditorService{
privatefinal DocumentRepository documentRepository;
privatefinal OfficeTokenService officeTokenService;
@Value("${office.public-file-url}")
private String publicFileUrl;
@Value("${office.callback-url}")
private String callbackUrl;
public Map<String, Object> buildEditorConfig(long documentId, LoginUser operator){
DocumentFile file = documentRepository.require(documentId);
String documentKey = DigestUtils.sha256Hex(
file.getId() + ":" + file.getVersion()
);
Map<String, Object> config = new LinkedHashMap<>();
config.put("documentType", "word");
config.put("document", Map.of(
"fileType", file.getExtension(),
"key", documentKey,
"title", file.getOriginalName(),
"url", publicFileUrl + "/documents/" + file.getId() + "/content",
"permissions", Map.of(
"edit", file.canEdit(operator.getUserId()),
"download", true,
"print", true
)
));
config.put("editorConfig", Map.of(
"mode", "edit",
"lang", "zh-CN",
"callbackUrl", callbackUrl + "/office/callback/" + file.getId(),
"user", Map.of(
"id", operator.getUserId().toString(),
"name", operator.getDisplayName()
),
"customization", Map.of(
"forcesave", true,
"autosave", true
)
));
config.put("token", officeTokenService.sign(config));
return config;
}
}
document.key 这里不能随手写文件 ID,也别每次刷新都生成一个随机 UUID。
同一个文件版本进入编辑器时,key 应该保持一致;文件成功保存、版本号变化后,再生成新的 key。OnlyOffice 会根据 key 识别编辑会话和缓存,已保存的新版本继续沿用旧 key,用户看到的可能还是旧文件。官方也明确要求文档编辑保存后重新生成 key。
JWT 也别为了省事关掉。编辑配置、转换请求、保存回调都应该校验签名,否则别人构造一个 callback 请求,就有机会覆盖你的业务文件。
保存回调才是关键
OnlyOffice 回调的状态不少,但真正需要落盘的通常是两个:
2:编辑结束,文件可以保存;6:用户触发强制保存,当前版本可以保存。
状态 1 只是正在编辑,4 是关闭但没有修改,收到以后直接返回成功即可。
@RestController
@RequiredArgsConstructor
@RequestMapping("/office")
publicclassOfficeCallbackController{
privatefinal RestClient restClient;
privatefinal DocumentStorage documentStorage;
privatefinal DocumentRepository documentRepository;
privatefinal OfficeTokenService officeTokenService;
@PostMapping("/callback/{documentId}")
public Map<String, Integer> callback(
@PathVariable long documentId,
@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestBody OfficeCallback body) {
officeTokenService.verifyCallback(authorization, body);
if (body.status() != 2 && body.status() != 6) {
return Map.of("error", 0);
}
byte[] editedFile = restClient.get()
.uri(body.url())
.retrieve()
.body(byte[].class);
if (editedFile == null || editedFile.length == 0) {
thrownew IllegalStateException("OnlyOffice 返回了空文件");
}
String newPath = documentStorage.replaceAtomically(
documentId,
editedFile,
body.filetype()
);
documentRepository.finishSave(
documentId,
newPath,
editedFile.length
);
return Map.of("error", 0);
}
public record OfficeCallback(
String key,
int status,
String url,
String filetype
){
}
}
这里我不建议直接把下载流覆盖到原文件。
网络抖一下,文件只写了一半,原件也没了。稳妥一点的做法是先写临时文件,校验成功后再原子替换:
public String replaceAtomically(long documentId, byte[] content, String extension){
Path target = resolve(documentId, extension);
Path staging = target.resolveSibling(target.getFileName() + ".saving");
try {
Files.write(staging, content,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
Files.move(staging, target,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
return target.toString();
} catch (IOException ex) {
thrownew UncheckedIOException("保存在线编辑文件失败:" + documentId, ex);
}
}
回调还要按幂等接口处理。Document Server 重试一次,不能把版本号连加两次。实际项目里可以拿 documentId + key + status 做保存记录,处理过的回调直接返回 {"error":0}。
还有一个经常被误判的问题:用户关闭编辑器后,回调不一定马上到。OnlyOffice 会先组装最终文件,通常需要等待几秒,官方文档给出的常见情况大约是关闭编辑后十秒左右。
所以别一关页面就查数据库,然后认定保存失败。
.doc转 .docx,别让编辑器硬扛
老系统里经常存着一堆 .doc 文件。
我的处理习惯是,进入编辑前统一转成 .docx,下载时再按业务要求导出 PDF 或其他格式。OnlyOffice 的转换服务通过 /converter 接收 JSON 请求,可以同步返回转换结果地址。
@Service
@RequiredArgsConstructor
publicclassOfficeConvertService{
privatefinal RestClient officeClient;
privatefinal OfficeTokenService tokenService;
public String convert(String sourceUrl, String sourceType, String targetType){
Map<String, Object> request = new LinkedHashMap<>();
request.put("async", false);
request.put("filetype", sourceType);
request.put("outputtype", targetType);
request.put("key", DigestUtils.sha256Hex(sourceUrl + ":" + targetType));
request.put("title", "converted." + targetType);
request.put("url", sourceUrl);
request.put("token", tokenService.signPayload(request));
ConvertResult result = officeClient.post()
.uri("/converter")
.body(request)
.retrieve()
.body(ConvertResult.class);
if (result == null || !result.endConvert() || result.fileUrl() == null) {
thrownew IllegalStateException("文档转换未完成");
}
return result.fileUrl();
}
public record ConvertResult(
boolean endConvert,
String fileType,
String fileUrl,
int percent
){
}
}
转换后的地址也只是临时下载地址,不能直接存进数据库当永久文件地址。后端仍然要下载文件,写入自己的对象存储,再记录最终路径。
最后还有个部署问题,比代码问题常见得多。
document.url 和 callbackUrl 必须是 Document Server 能访问的地址。Spring Boot 跑在宿主机,OnlyOffice 跑在 Docker 里时,配置成 localhost:8080 基本必挂——容器里的 localhost 指向它自己。
所以我上线前一定会从 OnlyOffice 容器里分别执行一次:
curl -I http://business-service:8080/documents/18/content
curl -I http://business-service:8080/office/callback/18
文件下载地址通、回调地址通、JWT 密钥一致,这套在线编辑链路才算真的接完。
页面能打开,只完成了一半。文件能稳稳地回到自己的存储里,才是另一半。