夜雨聆风学习资料网

ARTICLE · 1075658

SpringBoot + OnlyOffice:在线编辑 Word,真正难的是保存

SpringBoot + OnlyOffice:在线编辑 Word,真正难的是保存
页面能打开,Word 也能正常修改,右上角甚至提示“已保存”。

结果去对象存储里一看,原文件一个字都没变。

第一次接 OnlyOffice,这个地方很容易判断错。编辑器里的保存,只代表内容暂存在 OnlyOffice Document Server,业务系统想拿到最终文件,还得老老实实接住它的回调。

整个链路其实就四步:

浏览器向 SpringBoot 获取编辑配置,OnlyOffice 根据配置下载原始文件,用户在线编辑,编辑完成后 OnlyOffice 再调用 SpringBoot,把修改后的文件地址交回来。

这里我一般先检查两个地址:

document.url
editorConfig.callbackUrl

这两个地址必须是 OnlyOffice 服务器能访问的地址。你在浏览器里能打开,不代表部署在 Docker 或内网机器里的 Document Server 也能打开。大量“下载文件失败”“回调没有日志”,最后查出来都是网络和地址写错了。

生成编辑配置

SpringBoot 不需要处理 Word 内容,只负责生成一份编辑器配置。

@RestController
@RequestMapping("/api/office")
publicclassOfficeEditorController{

privatefinal FileRepository fileRepository;
privatefinal OfficeTokenService tokenService;

publicOfficeEditorController(FileRepository fileRepository,
                                  OfficeTokenService tokenService)
{
this.fileRepository = fileRepository;
this.tokenService = tokenService;
    }

@GetMapping("/config/{fileId}")
public Map<String, Object> config(@PathVariable Long fileId,
                                      LoginUser loginUser)
{

        StoredFile file = fileRepository.mustFind(fileId);

        String documentKey = file.id() + "_" + file.version();

        Map<String, Object> config = new LinkedHashMap<>();
        config.put("documentType", "word");

        config.put("document", Map.of(
"fileType", "docx",
"key", documentKey,
"title", file.originalName(),
"url", "https://file.example.com/api/files/"
                        + file.id() + "/download?ticket=" + file.readTicket()
        ));

        config.put("editorConfig", Map.of(
"mode", "edit",
"lang", "zh",
"callbackUrl", "https://app.example.com/api/office/callback/"
                        + file.id(),
"user", Map.of(
"id", String.valueOf(loginUser.id()),
"name", loginUser.displayName()
                )
        ));

        config.put("token", tokenService.sign(config));
return config;
    }
}

key 不能随手拿文件名顶上。OnlyOffice 会用它识别文档和编辑缓存,同一份内容使用同一个 key,文件版本变化后就要生成新 key,否则可能打开旧缓存,甚至串到别的文档。官方也要求不同文档使用唯一 key,并限制长度不超过 128 个字符。

我一般直接用“文件 ID + 版本号”,省得拿 MD5、时间戳到处拼。保存成功后版本号加一,下次打开自然就是新 key。

前端拿到配置后初始化编辑器即可:

const config = await fetch(`/api/office/config/${fileId}`).then(r => r.json());

new DocsAPI.DocEditor("office-editor", {
    ...config,
width: "100%",
height: "100%"
});

保存别直接覆盖原文件

OnlyOffice 会通过 callbackUrl 通知文档状态。状态 2 表示文档已结束编辑并准备保存,状态 6 表示强制保存产生了当前版本;回调接口必须返回 {"error":0},否则编辑器会认为保存失败。

public record OfficeCallback(
int status,
        String key,
        String url,
        String filetype
)
{
}
@PostMapping("/callback/{fileId}")
public Map<String, Integer> callback(@PathVariable Long fileId,
                                     @RequestBody OfficeCallback callback,
                                     HttpServletRequest request)
{

    officeTokenVerifier.verifyCallback(request, callback);

if (callback.status() != 2 && callback.status() != 6) {
return Map.of("error", 0);
    }

    Path temporaryFile = fileStorage.createTemporary(fileId);

try {
        onlyOfficeDownloader.download(callback.url(), temporaryFile);

        fileStorage.replaceAtomically(
                fileId,
                temporaryFile,
                callback.filetype()
        );

        fileRepository.increaseVersion(fileId);
return Map.of("error", 0);
    } catch (Exception ex) {
        log.error("OnlyOffice保存失败,fileId={}, key={}",
                fileId, callback.key(), ex);
return Map.of("error", 1);
    } finally {
        fileStorage.deleteQuietly(temporaryFile);
    }
}

这段代码有个细节:先下载到临时文件,校验成功后再原子替换。

不要拿回调里的 URL 直接覆盖原文件。下载到一半网络断了,原文件也被你清空了,这种事故比“保存失败”难收拾得多。

另外,状态 2 通常是在最后一个编辑者关闭文档后触发,并不是用户每敲一个字就回调;开启强制保存后,状态 6 才会在保存动作发生时返回当前版本。

Word 转 PDF

格式转换不要自己装 LibreOffice 起进程,OnlyOffice 已经提供了转换接口。当前文档建议向 /converter 发送 JSON 请求,旧文章里常见的 /ConvertService.ashx 是早期地址。

public URI convertToPdf(StoredFile file){

    String convertKey = file.id() + "_" + file.version() + "_pdf";

    Map<String, Object> command = Map.of(
"async", false,
"filetype", "docx",
"outputtype", "pdf",
"key", convertKey,
"title", file.originalName(),
"url", file.publicDownloadUrl()
    );

    ConvertResult result = restClient.post()
            .uri(documentServer + "/converter?shardkey={key}", convertKey)
            .body(Map.of("token", tokenService.sign(command)))
            .retrieve()
            .body(ConvertResult.class);

if (result == null || !result.endConvert()) {
thrownew IllegalStateException("Word 转 PDF 未完成");
    }

return URI.create(result.fileUrl());
}

public record ConvertResult(
boolean endConvert,
        String fileUrl,
        Integer error
)
{
}

转换返回的 fileUrl 也只是临时结果地址。业务系统拿到后要及时下载,再存进自己的文件系统或对象存储,别把这个地址直接写进数据库长期使用。

生产环境还得补三件事:JWT 校验、短期下载凭证、回调幂等。

尤其是回调里的 url,不能什么地址都下载。至少限制 OnlyOffice 服务域名,否则这个接口稍微写松一点,就可能被利用去探测内网。

OnlyOffice 负责的是“编辑器”和“文档转换”,文件权限、版本号、历史记录、最终落盘仍然是 SpringBoot 的活。把 url、key、callbackUrl 这条链路接稳,在线 Word 编辑也就没多少玄学了。

相关学习资料