news 2026/10/21 11:33:48

JeecgBoot + EuroOffice 协同文档在线编辑全栈接入指南(支持 JWT 安全校验)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JeecgBoot + EuroOffice 协同文档在线编辑全栈接入指南(支持 JWT 安全校验)

前排提示:本文面向有 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_SECRET

前后端通信签名密钥

必须,否则编辑器无法加载

JWT_HEADER

JWT 在 HTTP 头中的字段名

可选,默认Authorization

EXAMPLE_ENABLED

是否允许无 JWT 访问(仅开发环境)

生产必须false

host.docker.internal

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>

六、常见踩坑与排错

症状原因解决方案

打开编辑器报token invalid

前后端jwt-secret不一致

检查application.yml和 DockerJWT_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端:

小程序:

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 11:30:15

权限模型落地:RBAC表设计与接口鉴权实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 11:29:18

从 H.264 裸流到 RTSP 推流:一次完整的工程实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 11:26:10

EmulatorJS 3 步跑通:浏览器模拟器前端,本地就能开玩

EmulatorJS 3 步跑通&#xff1a;浏览器模拟器前端&#xff0c;本地就能开玩 【免费下载链接】EmulatorJS A web-based frontend for RetroArch 项目地址: https://gitcode.com/GitHub_Trending/em/EmulatorJS 想在网页里挂一个能跑老游戏的入口&#xff0c;又不想自己搭…

作者头像 李华
网站建设 2026/10/21 11:24:41

转:文化变革

个人理解&#xff1a; 真正的文化转型从不先改思想&#xff0c;而是先改行为 只有当你的目标是完成任务的时候&#xff0c;文化变革才可能真正实现 思考并不能使我们养成一种新的实践方式&#xff0c;而具体的行动却可以帮助我们形成一种新的思维方式 不能把数据作为唯一的标准…

作者头像 李华
网站建设 2026/10/21 11:24:41

聊聊 Agent 工程化:Demo 很好写,生产却处处是坑

前言 很多同学初学 Spring AI Alibaba 智能体时&#xff0c;都会觉得 Agent 开发非常简单&#xff1a;定义工具、注入模型、写一段提示词、调用 agent.call() 即可运行。 但这种只能跑通的 Demo Agent&#xff0c;绝对无法上线生产。 日常开发中&#xff0c;90% 的 Agent 线上问…

作者头像 李华