夜雨聆风学习资料网

ARTICLE · 1167110

Spring Boot 接 OnlyOffice,真正难的不是打开 Word,而是把文件保存回来

Spring Boot 接 OnlyOffice,真正难的不是打开 Word,而是把文件保存回来
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 密钥一致,这套在线编辑链路才算真的接完。

页面能打开,只完成了一半。文件能稳稳地回到自己的存储里,才是另一半。

相关学习资料