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

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

小程序:

|----------------------------------------------------------------------------|----------------------------------------------------------------------------|
| | |

相关推荐
舰长1152 小时前
curl发布8.23.0修复24个漏洞
安全
迪康Defender4 小时前
终端安全运维:浅谈迪康端点安全一体化管理系统工具箱实用能力
运维·安全·信息可视化·自动化·运维开发·学习方法·远程工作
嘟哩DuliDuli5 小时前
AI创作工作区的项目上下文与多模态任务边界
android·人工智能·安全·ai·软件工程
pusheng20255 小时前
普晟传感再获“消防产业核心配套优质品牌”:以可靠传感技术,让全球合规变简单
大数据·前端·人工智能·物联网·安全
EasyGBS6 小时前
通信基站/铁塔机房无人值守,国标GB28181公网平台EasyGBS视频能力怎么去管
深度学习·安全·音视频
深盾科技_Virbox7 小时前
软许可、加密锁和云许可混合使用时,授权接口应该怎么统一?
运维·网络·安全
是真的小外套7 小时前
第15章:网络安全主动防御技术原理及应用
安全·web安全
2601_962364977 小时前
升级电脑人脸登录,选购 Windows Hello 模组常见误区
windows·安全·电脑
臻选云栗子8 小时前
PCDN晚高峰掉量原因分析及优化建议
服务器·网络·安全·网络安全·阿里云·云计算·信息与通信