前排提示:本文面向有 Spring Boot + Vue 基础的开发者,全流程覆盖 Docker 部署、服务端接口、前端集成、JWT 安全校验、回调保存机制。可直接移植到 JeecgBoot 项目。
一、整体架构说明

核心思路:后端不存储文档的编辑状态,只在用户打开时生成带 JWT 签名的 editorConfig;EuroOffice 编辑完成后通过回调通知后端下载最新版本并落盘。
二、EuroOffice Document Server 部署
2.1 准备条件
- Linux / Mac(Docker 已安装)
- 一个可用的域名(开发环境可用
localhost) - 开放端口
8088
2.2 一键启动
Delphi
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_SECRET |
前后端通信签名密钥 | 必须,否则编辑器无法加载 |
JWT_HEADER |
JWT 在 HTTP 头中的字段名 | 可选,默认 Authorization |
EXAMPLE_ENABLED |
是否允许无 JWT 访问(仅开发环境) | 生产必须 false |
host.docker.internal |
Docker 容器内访问宿主机 | Mac/Win Docker Desktop 调试必填 |
⚠️ 坑点预警:
JWT_SECRET前后端必须完全一致,且生产环境务必关闭EXAMPLE_ENABLED。
2.3 验证部署是否成功
访问 http://<服务器IP>:8088/ 或 http://localhost:8088/,看到编辑器界面即表示部署成功。
2.4 生产环境 HTTPS 部署(Nginx 反向代理)
生产环境的 EuroOffice 通过 域名 提供服务:

三、数据库建表
在 MySQL 中执行以下 SQL:

四、后端 Spring Boot 集成
4.1 添加 Maven 依赖
java
<!-- JWT 签名(编辑配置 & 回调鉴权) -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
4.2 配置文件(application-dev.yml / application-prod.yml)
java
# 核心配置,顶级 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 核心配置类
java
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 工具类
java
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 对外接口
java
@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 核心逻辑
java
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 回调
java
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 桌面端编辑器组件
html
<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>
六、常见踩坑与排错
| 症状 | 原因 | 解决方案 |
|---|---|---|
打开编辑器报 token invalid |
前后端 jwt-secret 不一致 |
检查 application.yml 和 Docker JWT_SECRET 环境变量 |
| 回调返回 401 | Shiro 拦截了回调接口 | 在 excludeUrls 中加入 /eurooffice/callback,/eurooffice/file/** |
Docker 内访问不到 host.docker.internal |
public-base-url 用了 localhost |
改为 host.docker.internal:端口 |
| 移动端编辑器加载空白 | type: "mobile" 配置缺失 |
buildEditorPayload 传 type=mobile |
七、效果
pc端:

小程序:
|----------------------------------------------------------------------------|----------------------------------------------------------------------------|
|
|
|