夜雨聆风学习资料网

ARTICLE · 1068638

SpringBoot + ONLYOFFICE:在线编辑 Word,真正麻烦的是把文件保存回来

SpringBoot + ONLYOFFICE:在线编辑 Word,真正麻烦的是把文件保存回来
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 密钥一致,以及下载地址不能失控。

这几个地方没处理好,页面看着能编辑,文件最后还是可能丢。

相关学习资料