尧图精选

SpringBoot集成ONLYOFFICE:在线协同编辑与JWT回调实战指南

🕒 发布时间:2026/9/26 18:02:20 📁 来源:尧图网络
1. 项目概述为什么要在SpringBoot里集成ONLYOFFICE先说个我踩过的坑。之前在做一个内部文档管理系统需求很直白业务部门要在网页里直接编辑Word/Excel还要能多人同时改一份标书。一开始想的方案是前端用现成编辑器组件后端存文件——结果Word排版一复杂就崩多人同时编辑直接互相覆盖项目差点黄了。后来换成了ONLYOFFICE问题才算真正解决。ONLYOFFICE是一套开源的在线办公套件核心是Document Server提供了浏览器端的文档、表格、幻灯片编辑器更重要的是它原生支持多人实时协同编辑。所谓集成本质上就是让你的SpringBoot后端跟它对接上打开文档、保存文档、处理协同编辑时的回调通知。这套东西适合谁适合那些已经用SpringBoot写了业务系统、现在需要在系统里塞进在线预览/编辑Office文件能力的团队。不管你是写OA、ERP还是项目管理系统只要涉及Word/Excel在线操作ONLYOFFICE都是目前工业级方案里性价比最高的选择之一——社区版免费自托管数据不出内网。整个集成的核心链路其实不复杂前端嵌入ONLYOFFICE编辑器的JS API后端负责两件事——生成带权限的文档访问链接存到MinIO或本地磁盘都行然后接收编辑器回调过来的地址把编辑后的文件存回你自己的存储。还有一个知识储备ONLYOFFICE从7.x版本开始默认强制JWT签名验证跟SpringBoot对接时如果JWT配置对不上回调会一直报401这是新手最容易卡死的地方。这个后面专门讲。2. 环境搭建Docker部署ONLYOFFICE Document Server2.1 用Docker Compose搭一套最省心的部署ONLYOFFICE官方推荐用Docker部署这也是我在生产环境里验证过最稳的方式。别自己编译源码除非你有大把时间折腾依赖。直接拉官方镜像一条命令起步docker run -d -p 8081:80 \ -e JWT_ENABLEDtrue \ -e JWT_SECRETyour_secret_key_here \ --name onlyoffice-docserver \ --restartalways \ onlyoffice/documentserver:latest如果只搭一套这样确实够用了。但我建议用Docker Compose因为后续要加PostgreSQL、Redis管理起来方便。写个docker-compose.ymlversion: 3.8 services: onlyoffice-docserver: image: onlyoffice/documentserver:latest container_name: onlyoffice-docserver ports: - 8081:80 environment: JWT_ENABLED: true JWT_SECRET: your_secret_key_here JWT_HEADER: Authorization JWT_IN_BODY: true volumes: - ./onlyoffice/data:/var/www/onlyoffice/Data - ./onlyoffice/logs:/var/log/onlyoffice restart: always几个关键点JWT_ENABLED: true。ONLYOFFICE从7.2版本开始默认开启JWT。如果你用的是旧版本镜像初始JWT_SECRET是空的这时候SpringBoot配置里secret也要留空否则会验签失败。Volume挂载一定要做。/var/www/onlyoffice/Data里面存了文档缓存、密钥、SSL证书。我遇到过一次容器重建后所有文档打不开就是因为没挂载数据目录旧密钥丢了新容器解密不了缓存的文档。如果服务器上已经用了80端口记得映射到别的宿主机端口比如8081。2.2 HTTPS和跨域配置这里必须提一个大部分人第一次部署都会踩的坑ONLYOFFICE编辑器是通过iframe嵌入的如果前端页面是https://而Document Server是http://浏览器会直接拦截混合内容编辑器白屏。解决方案有三个方向第一如果你有域名和证书给Document Server配上HTTPS。官方容器内置了Lets Encrypt脚本但需要把80/443端口都映射出来并配置域名环境变量environment: - LETS_ENCRYPT_DOMAINdoc.example.com - LETS_ENCRYPT_MAILadminexample.com第二如果你只是在内网或本地开发环境用不想搞证书那就统一用HTTP保证前端页面和Document Server都是HTTP访问。第三在SpringBoot的配置里把Document Server地址配成一个独立域名或者路径别跟前端页面走同一个域。跨域还有个细节如果前端域名和Document Server域名不一致需要在Document Server的local.json里配置允许跨域的域名白名单或者使用反向代理将/docservice路径转发到Document Server。我最常用的做法是加一个Nginx把/onlyoffice/路径转发到http://localhost:8081/location /onlyoffice/ { proxy_pass http://onlyoffice-docserver:8081/; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; }这样前端访问https://你的域名/onlyoffice/就不会有混合内容问题了。3. SpringBoot后端JWT签名与文档地址生成3.1 JWT机制到底是怎么回事ONLYOFFICE之所以要JWT是为了防止有人伪造文档URL或者篡改配置。前端打开编辑器时需要调用一个接口拿到编辑器配置JSON里面包含文档URL、用户信息、权限控制等等。这个配置要么是通过初始化脚本直接传给前端要么是前端再去后端拉取。如果不开启JWT多人在线编辑时任何人都可以伪造document.key造成文档冲突甚至数据丢失。所以ONLYOFFICE设计了一套机制编辑器的每一次调用、每一次回调都要带一个用JWT_SECRET签名的Token后端和 Document Server 共用同一个密钥。在SpringBoot里最简单的做法是引入java-jwt库dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency然后写一个JWT工具类Component public class OnlyOfficeJwtUtil { Value(${onlyoffice.jwt-secret}) private String jwtSecret; Value(${onlyoffice.jwt-enabled}) private boolean jwtEnabled; public String createToken(MapString, Object payload) { if (!jwtEnabled) { return ; } Algorithm algorithm Algorithm.HMAC256(jwtSecret); return JWT.create() .withPayload(payload) .withIssuedAt(new Date()) .withExpiresAt(new Date(System.currentTimeMillis() 1000 * 60)) .sign(algorithm); } public boolean verifyToken(String token) { try { Algorithm algorithm Algorithm.HMAC256(jwtSecret); JWTVerifier verifier JWT.require(algorithm).build(); verifier.verify(token); return true; } catch (Exception e) { return false; } } }这里有一个我当时调试了很久才搞明白的点ONLYOFFICE编辑器的配置Token要求payload里的字段名跟编辑器配置JSON的字段名一致。也就是说你生成Token时放入document、editorConfig这些对象Token验签后会把payload里的内容跟配置合并。所以更推荐的做法是先用Jackson把完整配置对象序列化成Map再把整个Map作为Token的payload签名。这样接收方解签后直接就能拿到完整的配置。3.2 前端需要什么样的配置接口我在实际项目里给前端提供这样一个接口SpringBoot ControllerRestController RequestMapping(/api/docs) public class DocumentController { Autowired private FileStorageService fileStorageService; Autowired private OnlyOfficeJwtUtil jwtUtil; GetMapping(/edit/{fileId}) public MapString, Object getEditConfig(PathVariable Long fileId, HttpServletRequest request) { // 1. 查数据库拿到文件信息 // 2. 生成文档访问URL带签名 // 3. 组装编辑器配置 MapString, Object config new HashMap(); // document基本信息 MapString, Object document new HashMap(); document.put(fileType, docx); document.put(key, generateDocumentKey(fileId)); document.put(title, 测试文档.docx); document.put(url, fileStorageService.generatePresignedUrl(fileId)); config.put(document, document); // 编辑器配置 MapString, Object editorConfig new HashMap(); MapString, Object user new HashMap(); user.put(id, user_001); user.put(name, 张三); editorConfig.put(user, user); editorConfig.put(lang, zh-CN); config.put(editorConfig, editorConfig); // 回调URL MapString, Object editorConfigInner new HashMap(); editorConfigInner.put(callbackUrl, https://your-backend/api/docs/callback/ fileId); editorConfig.put(customization, editorConfigInner); // 如果开启JWT整个config作为payload if (jwtUtil.isEnabled()) { MapString, Object payload new HashMap(); payload.put(config, config); config.put(token, jwtUtil.createToken(payload)); } return config; } private String generateDocumentKey(Long fileId) { // key必须保证唯一且稳定通常用文件ID修改时间 return fileId _ System.currentTimeMillis(); } }前端拿到这个JSON后直接交给DocsAPI.DocEditor()初始化就行。3.3 一个Visual Studio Code风格的配置技巧这里有个细节如果document.key不变ONLYOFFICE会认为文档没修改过直接加载缓存。但如果你允许多人同时编辑每次进入编辑页都生成新key的话会导致已打开编辑器的人收到文档已重新加载的提示协同体验很差。正确做法是key用文件ID保证稳定但文件被保存后要更新一个版本号作为key的一部分。这样多人同时编辑同一文件key不变大家协同在同一份文档上任何一个用户保存了新版本其他用户的编辑器会检测到key变化提示重新加载避免丢数据。4. 回调处理最核心也最容易出问题的一环4.1 回调到底在做什么用户在前端编辑器里点保存ONLYOFFICE会向后端发一个HTTP POST请求——这就是回调。这个回调里会带上编辑后的文档地址Document Server内部的临时地址后端需要去把这份新文档拿下来存回自己的存储系统MinIO、OSS或者本地磁盘。回调URL就是你之前给前端配置里的callbackUrl。这个URL必须是 Document Server 能访问到的地址——如果你的Document Server是Docker部署的它在容器内部localhost指向的是容器本身所以回调URL要填宿主机或内网IP。这是我帮别人排查问题时遇到最多的情况之一。先写一个最简单的回调接收接口PostMapping(/api/docs/callback/{fileId}) public ResponseEntityMapString, Object callback( PathVariable Long fileId, RequestBody MapString, Object body) { // 回调状态 Integer status (Integer) body.get(status); // status2 表示文档已就绪/保存完成 if (status ! null status 2) { // 回调中带有下载地址 String downloadUrl (String) body.get(url); // 去下载新文档并保存 byte[] fileData downloadFile(downloadUrl); fileStorageService.saveFile(fileId, fileData); } // 必须返回JSON: {error: 0} MapString, Object result new HashMap(); result.put(error, 0); return ResponseEntity.ok(result); }注意ONLYOFFICE要求回调接口返回{error: 0}如果返回其他值Document Server会认为保存失败导致用户看到无法保存的错误提示。4.2 校验回调里的JWT前文提到的JWT坑就是这个环节。ONLYOFFICE在发送回调请求时会把JWT Token放在Authorizationheader里也可能是请求体里的token字段取决于你的配置。所以回调接口里必须先验签PostMapping(/api/docs/callback/{fileId}) public ResponseEntityMapString, Object callback( PathVariable Long fileId, RequestHeader(value Authorization, required false) String authHeader, RequestBody(required false) MapString, Object body) { // 优先取header里的token String token authHeader ! null authHeader.startsWith(Bearer ) ? authHeader.substring(7) : null; // header没有就从body里取 if (token null body ! null body.get(token) ! null) { token (String) body.get(token); } if (!jwtUtil.verifyToken(token)) { // 验签失败返回错误Document Server会重试 MapString, Object error new HashMap(); error.put(error, 1); return ResponseEntity.status(401).body(error); } // 验签通过继续处理保存逻辑 // ... }我在生产环境里遇到过一种情况ONLYOFFICE的Docker镜像里JWT_IN_BODY设置成true时Token会放在body里而不是header。如果两边配置不一致回调永远验签失败。所以部署文档服务器的时候JWT_IN_BODY的值要跟SpringBoot端逻辑保持统一。4.3 回调的完整流程我来完整描述一下保存时的链路帮助你把流程在脑子里串起来用户点击保存按钮或协同编辑时自动保存Document Server生成一份编辑后的文档放到自己的临时存储Document Server向后端回调URL发送POST请求status2body里带下载URL后端收到回调先验JWT通过后去下载URL拿到文件字节流后端把字节流写入MinIO或本地磁盘、OSS更新数据库里的版本号、修改时间回调返回{error: 0}Document Server确认保存成功。这中间有个陷阱Document Server生成的临时下载URL有效期不长通常几分钟。如果后端下载得慢URL就过期了文件拉不下来。所以下载操作要放在回调处理流程里立刻执行不要塞进异步队列里慢慢攒。5. 前端集成Vue3里嵌入编辑器5.1 引入ONLYOFFICE SDKONLYOFFICE提供了前端SDK最简单的方式是直接用标签引入script srchttps://your-onlyoffice-server/web-apps/apps/api/documents/api.js/script然后再页面里加一个divtemplate div div idplaceholder refeditorHost/div /div /template关键是把之前SpringBoot接口返回的配置JSON传进去export default { data() { return { editor: null, }; }, mounted() { this.initEditor(); }, methods: { async initEditor() { const response await fetch(/api/docs/edit/123); const config await response.json(); this.editor new window.DocsAPI.DocEditor(placeholder, config); }, }, beforeUnmount() { if (this.editor) { this.editor.destroyEditor(); } }, };注意config里的document.url必须浏览器和Document Server都能访问到。如果是MinIO就生成带签名的临时URL并把有效期设长一点比如7d——不然用户打开页面后等半小时签名过期文档就加载不出来了。5.2 多tab协同的实现ONLYOFFICE最大的价值是协同编辑。打开同一个文档的多个用户编辑器会通过Document Server内部的WebSocket通道同步彼此的光标位置和修改。SpringBoot后端在这个环节里只需要做一件事保证同一文件的document.key一致。不需要自己去实现WebSocket或者操作冲突解决——这些Document Server已经处理掉了。但在真实项目里我发现一个需要自己处理的点当一个用户打开了文档A另一个用户也打开了文档A如果第一个用户关闭了页面第二个用户的编辑器会显示xxx已离开编辑室的提示。这个用户信息来自editorConfig.user。所以要保证每次打开文档时传入的用户ID是真实唯一的如果多个用户共用同一个user.id协同编辑时会串号光标会跳到别人的位置。我见过有团队把user.id写成固定的admin结果两个人同时编辑时疯狂互相拉扯光标聊天记录里也全是乱名。这个细节别看小体验差很大。5.3 高级用法获取批注信息热搜词里有一条api怎么取onlyoffice的批注我在这里展开讲。ONLYOFFICE的批注是存储在文档元数据里的Document Server会把批注信息包含在回调状态变化中。具体来说回调body里如果有actions数组会包含用户的批注操作{ actions: [ { type: 1, userid: user_001, username: 张三 } ] }要完整获取批注列表最可靠的方式是解析文档本身。ONLYOFFICE保存的是标准docx格式你可以把docx当zip解压去word/comments.xml里读批注word/people.xml里读批注人信息。我写过一个工具类public ListComment extractComments(byte[] docxData) throws IOException { ZipInputStream zip new ZipInputStream(new ByteArrayInputStream(docxData)); ZipEntry entry; MapString, String peopleMap new HashMap(); ListComment comments new ArrayList(); while ((entry zip.getNextEntry()) ! null) { String name entry.getName(); if (word/people.xml.equals(name)) { DocumentBuilderFactory factory DocumentBuilderFactory.newInstance(); Document doc factory.newDocumentBuilder().parse(zip); NodeList personList doc.getElementsByTagName(w:person); for (int i 0; i personList.getLength(); i) { Element person (Element) personList.item(i); String author person.getElementsByTagName(w:author).item(0).getTextContent(); String personaId person.getAttribute(w:personId); peopleMap.put(personaId, author); } } if (word/comments.xml.equals(name)) { // 解析批注内容 // ... } } return comments; }当然如果只是想要批注数量和修改历史ONLYOFFICE自带的版本历史功能已经够用。完整解析可能需要你在深度开发阶段才做这里先知道有这条路就行。6. 存储对接把文件放到MinIO而不是本地硬盘6.1 为什么要用MinIO如果你只是个人测试文件存本地/tmp目录没问题。但做企业级系统文件肯定要集中存储。MinIO是目前跟SpringBoot配合最丝滑的开源对象存储兼容S3协议Docker部署也就几分钟的事。ONLYOFFICE集成MinIO的核心思路document.url和回调保存都指到MinIO上的文件。SpringBoot充当中间人负责生成带签名的MinIO下载链接并处理上传。6.2 SpringBoot整合MinIO引入依赖dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version /dependency工具类部分代码Component public class MinioService { Value(${minio.endpoint}) private String endpoint; Value(${minio.access-key}) private String accessKey; Value(${minio.secret-key}) private String secretKey; private MinioClient client; PostConstruct public void init() { client MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } public String getPresignedUrl(String bucket, String objectName) { try { return client.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucket) .object(objectName) .expiry(60 * 60 * 24 * 7) // 7天 .build() ); } catch (Exception e) { throw new RuntimeException(生成MinIO下载地址失败, e); } } public void uploadFile(String bucket, String objectName, byte[] data, String contentType) { try { client.putObject( PutObjectArgs.builder() .bucket(bucket) .object(objectName) .contentType(contentType) .stream(new ByteArrayInputStream(data), data.length, -1) .build() ); } catch (Exception e) { throw new RuntimeException(上传文件到MinIO失败, e); } } }这里给一个真实的流程图式描述前端打开文档→后端去MinIO拉presigned URL→把这个URL作为document.url→用户编辑→保存→Document Server回调→后端下载新文件→再传回MinIO覆盖原对象→更新数据库。整个过程MinIO跟ONLYOFFICE之间不直接建连全靠SpringBoot中转——这是安全模型上刻意的设计不要让Document Server拿到MinIO的永久密钥。6.3 版本管理如何记录历史修改记录热搜词里有onlyoffice代码查看历史修改记录。ONLYOFFICE自带的版本历史功能只在内存中保留Document Server里的缓存。如果你想让历史版本持久化得自己记录。我的方案是每次回调保存成功后不直接覆盖原文件而是先把新版本存成fileId/v1.docx、fileId/v2.docx、fileId/v3.docx数据库里用一张表存版本号、修改人、修改时间。CREATE TABLE document_version ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_id BIGINT NOT NULL, version INT NOT NULL, minio_object VARCHAR(255), modified_by VARCHAR(50), modified_time DATETIME, UNIQUE KEY uk_file_version (file_id, version) );当用户点击查看历史版本时前端再次打开编辑器传入历史版本的URL和key。ONLYOFFICE原生的版本历史弹窗也能用——它会展示Document Server内存中的版本但这个在容器重启后就丢了。所以要做持久化历史还得走自己的表。7. 常见问题与排查技巧实录7.1 编辑器白屏大概率是HTTP/HTTPS混合内容问题。检查一下浏览器控制台看有没有Mixed Content报错。解决方案前面讲过统一协议或套一层Nginx反向代理。还有一种情况是api.js路径写错了。ONLYOFFICE的SDK路径是/web-apps/apps/api/documents/api.js不要拼漏web-apps这层。7.2 回调报401JWT配置不一致。排查步骤确认Document Server容器的JWT_SECRET和SpringBoot里的onlyoffice.jwt-secret完全一致确认JWT_IN_BODY的配置跟回调接收逻辑对齐查看Document Server日志——docker logs onlyoffice-docserver | grep -i error如果看到Invalid payload基本就是验签失败。7.3 文档保存不了一直转圈回调URL不可达是关键。在Document Server容器里执行docker exec -it onlyoffice-docserver bash curl -X POST http://你的后端地址/api/docs/callback/123 \ -H Content-Type: application/json \ -d {status:2}如果容器里curl不通说明容器访问不到后端的地址。我遇到过的情况是后端监听在127.0.0.1容器里访问不到。把后端口绑定改成0.0.0.0或者用host.docker.internal这个Docker特殊域名来填回调URL。7.4 中文文件名乱码SpringBoot接收回调时文件名在JSON里是UTF-8一般不会乱。但如果你的下载链接带中文文件名需要URL编码。MinIO生成presigned URL时文件名建议用英文或UUID把中文名存到数据库表字段里。对象存储里用UUID做objectName是中大型项目的通用做法。7.5 Docker容器升级后文档全打不开前面提到过的数据卷挂载问题。升级镜像时不要轻易删容器如果必须重建务必挂载/var/www/onlyoffice/Data。否则Document Server的密钥变了之前缓存的文档无法解密即使文件还在MinIO里已打开的老文档也会加载失败。7.6 多人协同编辑但改动经常丢失检查一下前端页面是否有组件在周期性刷新。ONLYOFFICE编辑器内部跟Document Server有长连接如果页面因为路由切换或者状态变更导致编辑器组件被销毁重建连接会断未保存的改动会丢失。Vue里特别注意v-if控制编辑器容器的场景宁可让编辑器一直挂载也不要频繁销毁重建。8. 我踩过的坑和最终的实践总结前面零散说了不少问题这里统一整理几个我在真实项目中踩过的坑供你参考。第一document.key的生成逻辑千万别图省事用System.currentTimeMillis()。同一个文件被不同用户打开如果时间戳不同Document Server就当成两份不同文档处理协同编辑直接失效。key的正确姿势是用文件ID版本号版本号只在保存后递增。第二关于MinIO的presigned URL有效期。很多人设置5分钟结果用户打开文档页面超过5分钟加载时URL已过期白屏半天。我在生产环境用的是7天甚至30天。ONLYOFFICE编辑器打开的文档URL会在加载时就保存在Document Server内部之后Document Server会用它做操作记录URL过期可能导致一些高级协同功能失效。所以有效期宁长勿短。第三ONLYOFFICE Document Server是很吃内存的。多人编辑时内存占用轻松到2GB以上。我建议生产环境至少4GB内存最好是8GB。Docker默认没限制内存的话容器可能会把宿主机内存吃光导致OOM。可以在Compose里加mem_limit: 4g第四如果需要在上传PDF文件时做XSS过滤这句来自热搜词注意ONLYOFFICE编辑器对PDF的处理是转成图片预览不直接执行PDF里的脚本所以安全性相对较好。但如果你自行开发上传解析功能还是要做文件类型白名单校验和内容扫描。我个人在实际体会是ONLYOFFICE的集成难点不在调通而在稳定。调通Demo可能一晚上就够了但要在多人同时编辑、保存频繁、容器时不时重启的生产环境里保持稳定拼的全是细节——key怎么生成、回调怎么容错、存储怎么设计。这套东西用好了真就是花不到一个月时间给企业加上一个媲美商业Office套件的在线协同能力。最后再分享一个小技巧ONLYOFFICE提供了内置的健康检查接口/healthcheckDocker容器每30秒会调一次。如果要监控Document Server状态SpringBoot里写个定时任务去轮询这个接口再加个告警能避免很多半夜被用户叫醒的尴尬。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →