前排提示:本文面向有 Spring Boot + Vue 基础的开发者,全流程覆盖 Docker 部署、服务端接口、前端集成、JWT 安全校验、回调保存机制。可直接移植到 JeecgBoot 项目。
一、整体架构说明
核心思路:后端不存储文档的编辑状态,只在用户打开时生成带 JWT 签名的editorConfig;EuroOffice 编辑完成后通过回调通知后端下载最新版本并落盘。
二、EuroOffice Document Server 部署
2.1 准备条件
- Linux / Mac(Docker 已安装)
- 一个可用的域名(开发环境可用
localhost) - 开放端口
8088
2.2 一键启动
docker run -d \ --name euro-office \ --restart=unless-stopped \ -p 8088:80 \ --add-host=host.docker.internal:host-gateway \ -e JWT_SECRET=hCc0UPuZ0dPcuE6El2GL3iVqUqFWGNW7 \ -e JWT_HEADER=Authorization \ -e EXAMPLE_ENABLED=true \ -v /home/app/oa/eurooffice/data:/var/lib/euro-office/documentserver \ ghcr.io/euro-office/documentserver:latest关键参数说明:
| 参数 | 含义 | 必须性 |
|---|---|---|
| 前后端通信签名密钥 | 必须,否则编辑器无法加载 |
| JWT 在 HTTP 头中的字段名 | 可选,默认 |
| 是否允许无 JWT 访问(仅开发环境) | 生产必须 |
| Docker 容器内访问宿主机 | Mac/Win Docker Desktop 调试必填 |
--add-host=host.docker.internal:host-gateway 的作用是:让容器里能用主机名 host.docker.internal 访问到宿主机,即让 Euro Office 容器通过 host.docker.internal 回调/访问宿主机上的 OA 服务。
⚠️ 坑点预警:
JWT_SECRET前后端必须完全一致,且生产环境务必关闭EXAMPLE_ENABLED。
2.3 验证部署是否成功
访问http://<服务器IP>:8088/或http://localhost:8088/,看到编辑器界面即表示部署成功。
2.4 生产环境 HTTPS 部署(Nginx 反向代理)
生产环境的 EuroOffice 通过 域名 提供服务:
三、数据库建表
在 MySQL 中执行以下 SQL:
四、后端 Spring Boot 集成
4.1 添加 Maven 依赖
<!-- JWT 签名(编辑配置 & 回调鉴权) --> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </dependency>4.2 配置文件(application-dev.yml / application-prod.yml)
# 核心配置,顶级 key 与 @ConfigurationProperties(prefix = "eurooffice") 对应 eurooffice: # EuroOffice Document Server 地址(浏览器访问的地址) docs-url: http://localhost:8088 # 开发环境 # docs-url: https://www.minglina.com/ds # 生产环境(Nginx 代理后的 HTTPS 地址) # OA 后端暴露给 Document Server 的回调地址(必须公网可访问) public-base-url: http://host.docker.internal:18080/oa-boot # JWT 密钥,必须与 Docker 启动时的 JWT_SECRET 环境变量完全一致 jwt-secret: hCc0UPuZ0dPcuE6El2GL3iVqUqFWGNW7 # JWT 在 HTTP Header 中的字段名 jwt-header: Authorization # 下载短链有效期(秒),默认 600 download-token-ttl-seconds: 600 # Shiro 放行回调接口(匿名访问) jeecg: shiro: excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/api/getUserInfo,/eurooffice/callback,/eurooffice/file/**4.3 核心配置类
package com.ynfy.buss.eurooffice.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @Data @Component @ConfigurationProperties(prefix = "eurooffice") public class EuroOfficeProperties { /** 浏览器访问 Document Server 的地址 */ private String docsUrl = "http://localhost:8088"; /** Document Server 回调 OA 的基址 */ private String publicBaseUrl = "http://host.docker.internal:18080/oa-boot"; /** JWT 密钥,必须与 Docker JWT_SECRET 一致 */ private String jwtSecret; /** JWT Header,默认 Authorization */ private String jwtHeader = "Authorization"; /** 下载短链有效期(秒) */ private long downloadTokenTtlSeconds = 600; }4.4 JWT 工具类
package com.ynfy.buss.eurooffice.util; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Map; @Component @RequiredArgsConstructor public class EuroOfficeJwtHelper { private final ObjectMapper objectMapper; /** 为 editorConfig 签名(发送给浏览器,由 EuroOffice 校验) */ public String signConfig(Map<String, Object> payload, String secret) { return JWT.create() .withPayload(payload) .sign(Algorithm.HMAC256(secret)); } /** 校验回调 JWT(EuroOffice → 后端) */ @SuppressWarnings("unchecked") public Map<String, Object> verifyCallback(String token, String secret) { DecodedJWT jwt = JWT.require(Algorithm.HMAC256(secret)).build().verify(token); String json = new String( Base64.getUrlDecoder().decode(jwt.getPayload()), StandardCharsets.UTF_8); return objectMapper.readValue(json, Map.class); } /** 生成下载短 token(有时间戳签名,防篡改) */ public String createDownloadToken(String docId, String secret, long ttlSeconds) { long exp = System.currentTimeMillis() / 1000 + ttlSeconds; String raw = docId + "|" + exp; String sig = hmacSha256(raw, secret); return Base64.getUrlEncoder().withoutPadding() .encodeToString((raw + "|" + sig).getBytes(StandardCharsets.UTF_8)); } /** 验证下载短 token */ public String verifyDownloadToken(String token, String secret) { String decoded = new String( Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8); String[] p = decoded.split("\\|"); if (p.length != 3) throw new IllegalArgumentException("invalid token"); long exp = Long.parseLong(p[1]); if (System.currentTimeMillis() / 1000 > exp) throw new IllegalArgumentException("token expired"); if (!hmacSha256(p[0] + "|" + p[1], secret).equals(p[2])) throw new IllegalArgumentException("signature mismatch"); return p[0]; } private String hmacSha256(String data, String secret) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); StringBuilder sb = new StringBuilder(); for (byte b : mac.doFinal(data.getBytes(StandardCharsets.UTF_8))) sb.append(String.format("%02x", b)); return sb.toString(); } catch (Exception e) { throw new IllegalStateException(e); } } }4.5 Controller 对外接口
@Tag(name = "EuroOffice协同文档") @RestController @RequestMapping("/eurooffice") @Slf4j @RequiredArgsConstructor public class EuroOfficeController extends JeecgController<EoDocument, IEoDocumentService> { private final IEoDocumentService eoDocumentService; /** 文档分页列表 */ @GetMapping("/list") public Result<IPage<EoDocument>> list(...) { ... } /** 创建空白文档(docx/xlsx/pptx) */ @PostMapping("/create") public Result<EoDocument> create( @RequestParam(defaultValue = "docx") String fileType, @RequestParam(required = false) String title) { ... } /** 上传已有文件创建文档 */ @PostMapping("/upload") public Result<EoDocument> upload( @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title) { ... } /** 获取编辑器配置(含 JWT 签名) */ @GetMapping("/config/{docId}") public Result<Map<String, Object>> config( @PathVariable String docId, @RequestParam(defaultValue = "edit") String mode, @RequestParam(defaultValue = "desktop") String type) { LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal(); return Result.OK(eoDocumentService.buildEditorPayload(docId, mode, type, user)); } /** 删除文档 */ @DeleteMapping("/delete") { ... } /** ---------- 以下为 Document Server 回调接口(匿名) ---------- */ /** Document Server 下载源文件(短 token 鉴权) */ @GetMapping("/file/{docId}") public void download(@PathVariable String docId, @RequestParam String token, HttpServletResponse response) { eoDocumentService.streamFile(docId, token, response); } /** Document Server 保存回调(EuroOffice JWT 鉴权) */ @PostMapping("/callback") public Map<String, Object> callback(@RequestParam String docId, @RequestBody String body, @RequestHeader(value = "Authorization", required = false) String authorization) { return eoDocumentService.handleCallback(docId, body, authorization); } }4.6 Service 核心逻辑
public Map<String, Object> buildEditorPayload(String docId, String mode, String editorType, LoginUser user) { EoDocument doc = getById(docId); boolean canEdit = !"view".equalsIgnoreCase(mode); // 生成下载短 token(防爬虫) String downloadToken = jwtHelper.createDownloadToken( docId, properties.getJwtSecret(), properties.getDownloadTokenTtlSeconds()); String fileUrl = trimSlash(properties.getPublicBaseUrl()) + "/eurooffice/file/" + docId + "?token=" + URLEncoder.encode(downloadToken, StandardCharsets.UTF_8); String callbackUrl = trimSlash(properties.getPublicBaseUrl()) + "/eurooffice/callback?docId=" + docId; // 构建 document 部分 Map<String, Object> document = new LinkedHashMap<>(); document.put("fileType", doc.getFileType()); document.put("key", doc.getEditorKey()); // EuroOffice 用 key 定位文档 document.put("title", doc.getTitle()); document.put("url", fileUrl); document.put("permissions", Map.of( "edit", canEdit, "comment", true, "download", true, "print", true )); // 构建 editorConfig 部分 Map<String, Object> editorConfig = new LinkedHashMap<>(); editorConfig.put("mode", canEdit ? "edit" : "view"); editorConfig.put("callbackUrl", callbackUrl); editorConfig.put("lang", "zh-CN"); editorConfig.put("user", Map.of( "id", user != null ? user.getId() : "anonymous", "name", user != null ? user.getRealname() : "Guest" )); editorConfig.put("customization", Map.of("forcesave", true, "autosave", true)); editorConfig.put("coEditing", Map.of("mode", "fast", "change", true)); // 构建完整配置并签名 Map<String, Object> config = new LinkedHashMap<>(); config.put("type", "desktop"); // desktop | mobile config.put("documentType", documentType(doc.getFileType())); // word | cell | slide config.put("document", document); config.put("editorConfig", editorConfig); // JWT 签名(EuroOffice 会校验) if (StringUtils.isNotBlank(properties.getJwtSecret())) { config.put("token", jwtHelper.signConfig(config, properties.getJwtSecret())); } Map<String, Object> result = new LinkedHashMap<>(); result.put("documentServerApiJs", trimSlash(properties.getDocsUrl()) + "/web-apps/apps/api/documents/api.js?shardkey=" + URLEncoder.encode(doc.getEditorKey(), StandardCharsets.UTF_8)); result.put("config", config); result.put("docId", docId); result.put("title", doc.getTitle()); return result; }(2)处理 Document Server 回调
public Map<String, Object> handleCallback(String docId, String body, String authorizationHeader) { Map<String, Object> ok = Map.of("error", 0); // 必须返回 error:0 表示成功 try { // JWT 鉴权(EuroOffice 回调时带 Authorization: Bearer <token>) if (StringUtils.isNotBlank(properties.getJwtSecret())) { String token = extractBearer(authorizationHeader); Map<String, Object> payload = jwtHelper.verifyCallback(token, properties.getJwtSecret()); // 提取 status/key/url } JsonNode root = objectMapper.readTree(body); int status = root.path("status").asInt(-1); String key = root.path("key").asText(null); String url = root.path("url").asText(null); // status: 2=正常关闭保存 3=错误保存 6=forcesave if ((status == 2 || status == 3 || status == 6) && StringUtils.isNotBlank(url)) { downloadAndSave(doc, url, status == 2 || status == 3); } return ok; } catch (Exception e) { log.error("EuroOffice callback failed: docId={}", docId, e); return ok; // 仍返回 ok,避免编辑器无限重试 } }五、前端集成(Vue 3)
5.1 引入编辑器 API
5.2 桌面端编辑器组件
<template> <div id="eurooffice-editor" style="width:100%;height:100vh;" /> </template> <script setup> import { ref, onMounted } from 'vue' import axios from 'axios' const props = defineProps({ docId: { type: String, required: true }, mode: { type: String, default: 'edit' } // edit | view }) const editorApiJs = ref('') onMounted(async () => { // 1. 获取编辑配置(含 JWT 签名) const { data } = await axios.get(`/eurooffice/config/${props.docId}?mode=${props.mode}`) const config = data.result // 2. 动态加载 api.js await loadScript(config.documentServerApiJs) // 3. 渲染编辑器 new DocsAPI.DocEditor('eurooffice-editor', { ...config.config, width: '100%', height: '100%', events: { onDocumentReady() { console.log('编辑器已就绪') }, onError(err) { console.error('编辑器异常', err) }, // 编辑器关闭时会触发 forcesave + 回调后端 } }) }) function loadScript(src) { return new Promise((resolve, reject) => { if (window.DocsAPI) return resolve() const s = document.createElement('script') s.src = src s.onload = resolve s.onerror = () => reject(new Error('加载 api.js 失败')) document.head.appendChild(s) }) } </script>六、常见踩坑与排错
| 症状 | 原因 | 解决方案 |
|---|---|---|
打开编辑器报 | 前后端 | 检查 |
回调返回 401 | Shiro 拦截了回调接口 | 在 |
Docker 内访问不到 |
| 改为 |
移动端编辑器加载空白 |
|
|
七、效果
pc端:
小程序: