吉安感知网项目-后端
linwei
2026-07-10 6a77c800d56c6b4c3260c1b74ea7798d84f98ad8
feat(resource): 集成OnlyOffice文档编辑功能

- 添加OnlyOffice代理下载接口,解决Document Server无法访问远程文件URL的问题
- 实现OnlyOffice文档编辑器配置生成,包括用户信息和文档类型识别
- 集成JWT token生成机制,增强文档编辑安全性
- 添加ONLYOFFICE保存回调处理,支持文档编辑完成和强制保存状态
- 更新附件表link和md5字段,实现文档版本管理
- 配置网关跳过认证URL,允许OnlyOffice访问代理下载接口
- 添加相关依赖库,包括prime-jwt和docs-integration-sdk
5 files modified
1 files added
441 ■■■■ changed files
drone-gateway/src/main/java/org/sxkj/gateway/provider/AuthProvider.java 1 ●●●● patch | view | raw | blame | history
drone-ops/drone-resource/pom.xml 12 ●●●●● patch | view | raw | blame | history
drone-ops/drone-resource/src/main/java/org/sxkj/resource/controller/AttachController.java 240 ●●●●● patch | view | raw | blame | history
drone-ops/drone-resource/src/main/java/org/sxkj/resource/endpoint/OfficeCallbackEndpoint.java 134 ●●●●● patch | view | raw | blame | history
drone-ops/drone-resource/src/main/java/org/sxkj/resource/util/OnlyofficeJwt.java 42 ●●●●● patch | view | raw | blame | history
pom.xml 12 ●●●●● patch | view | raw | blame | history
drone-gateway/src/main/java/org/sxkj/gateway/provider/AuthProvider.java
@@ -53,6 +53,7 @@
        DEFAULT_SKIP_URL.add("/workOrder/gdPatrolTask/get-share-cache-data");
        DEFAULT_SKIP_URL.add("/workOrder/gdPatrolTask/pendingAcceptanceTasks");
        DEFAULT_SKIP_URL.add("/blade-resource/office/callback");
        DEFAULT_SKIP_URL.add("/attach/onlyoffice-download");
        DEFAULT_SKIP_URL.add("/workOrder/gdClueEvent/submitExternal");
    }
drone-ops/drone-resource/pom.xml
@@ -126,6 +126,18 @@
            <version>3.4.0.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>com.inversoft</groupId>
            <artifactId>prime-jwt</artifactId>
            <version>1.3.1</version>
        </dependency>
        <dependency>
            <groupId>com.onlyoffice</groupId>
            <artifactId>docs-integration-sdk</artifactId>
            <version>1.6.0</version>
        </dependency>
    </dependencies>
    <build>
drone-ops/drone-resource/src/main/java/org/sxkj/resource/controller/AttachController.java
@@ -14,6 +14,8 @@
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.BladeUser;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.BeanUtil;
@@ -27,17 +29,25 @@
import org.sxkj.resource.param.AttachPageParam;
import org.sxkj.resource.param.AttachParam;
import org.sxkj.resource.service.IAttachService;
import org.sxkj.resource.util.OnlyofficeJwt;
import org.sxkj.resource.vo.AttachVO;
import org.sxkj.resource.vo.AttachmentDownloadParam;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
 * 附件表 控制器
@@ -53,6 +63,15 @@
public class AttachController extends BladeController {
    private final IAttachService attachService;
    /** 远程文件URL缓存,key为attachId,value为远程文件URL */
    private static final ConcurrentHashMap<Long, String> REMOTE_URL_CACHE = new ConcurrentHashMap<>();
    /** 代理下载连接超时时间(毫秒) */
    private static final int CONNECT_TIMEOUT = 10000;
    /** 代理下载读取超时时间(毫秒) */
    private static final int READ_TIMEOUT = 30000;
    /**
     * 详情
@@ -72,6 +91,7 @@
    @ApiOperationSupport(order = 3)
    @ApiOperation(value = "附件分页", notes = "传入attach")
    public R<IPage<AttachVO>> page(AttachPageParam attach, Query query) {
        BladeUser user = AuthUtil.getUser();
        IPage<AttachVO> pages = attachService.selectAttachPage(Condition.getPage(query), attach);
        return R.data(pages);
    }
@@ -375,4 +395,222 @@
    //     return ResponseResult.success(result);
    // }
    /**
     * 获取OnlyOffice文档编辑器配置信息
     * <p>
     * 通过代理下载方式解决OnlyOffice Document Server无法直接访问远程文件URL的问题。
     * 将远程文件URL缓存到本地,并生成代理下载URL供Document Server使用。
     * </p>
     *
     * @param request  HTTP请求对象,用于构建代理下载URL
     * @param attachId 附件ID,用于查询附件信息
     * @return 返回包含用户信息和附件信息的OnlyOffice配置数据
     */
    @GetMapping("/getOnlyOfficeConfig")
    @ApiOperationSupport(order = 10)
    @ApiOperation(value = "获取OnlyOffice配置", notes = "根据附件ID查询附件信息,返回包含当前用户信息和附件信息的OnlyOffice文档编辑器配置")
    public R<Map<String, Object>> getOnlyOfficeConfig(
            HttpServletRequest request,
            @ApiParam(value = "附件ID", required = true) @RequestParam Long attachId) {
        // 1. 获取当前登录用户信息
        BladeUser user = AuthUtil.getUser();
        // 2. 根据附件ID查询附件信息
        Attach attach = attachService.getOne(Wrappers.lambdaQuery(Attach.class).eq(Attach::getId, attachId));
        if (attach == null) {
            return R.fail("附件不存在");
        }
        // 3. 构建用户信息对象
        Map<String, Object> userConfig = new HashMap<>();
        userConfig.put("name", user != null ? user.getUserName() : "未知用户");
        userConfig.put("id", user != null ? user.getUserId() : "0");
        // 4. 构建编辑器自定义配置
        Map<String, Object> customization = new HashMap<>();
        customization.put("autosave", true);
        customization.put("forcesave", true);
        // 5. 构建编辑器配置
        Map<String, Object> editorConfig = new HashMap<>();
        editorConfig.put("customization", customization);
        editorConfig.put("mode", "edit");
        editorConfig.put("callbackUrl", "http://192.168.1.33:8310/office/callback");
        editorConfig.put("lang", "zh-CN");
        editorConfig.put("user", userConfig);
        // 6. 从附件对象获取文件信息
        String fileName = attach.getName(); // 附件名称
        String fileUrl = attach.getLink(); // 附件URL地址(远程URL)
        String fileType = ""; // 文件类型(扩展名)
        // 7. 从文件名中提取文件类型
        if (fileName != null && fileName.contains(".")) {
            fileType = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
        } else if (fileUrl != null && fileUrl.contains(".")) {
            fileType = fileUrl.substring(fileUrl.lastIndexOf(".") + 1).toLowerCase();
        }
        // 8. 根据文件类型确定文档类型
        String documentType = "word"; // 默认为word类型
        if (fileType.equals("xlsx") || fileType.equals("xls")) {
            documentType = "cell";
        } else if (fileType.equals("pptx") || fileType.equals("ppt")) {
            documentType = "slide";
        }
        // 9. 将远程文件URL缓存到本地,供代理下载接口使用
        if (fileUrl != null && !fileUrl.isEmpty()) {
            REMOTE_URL_CACHE.put(attachId, fileUrl);
        }
        // 10. 构建代理下载URL,替代直接使用远程URL
        // Document Server通过代理下载接口获取文件,避免无法直接访问远程URL的问题
        String proxyDownloadUrl = buildProxyDownloadUrl(request, attachId);
        // 11. 生成文档唯一标识
        String key = generateRevisionId(fileName);
        // 12. 构建文档配置,使用代理下载URL替代远程URL
        Map<String, Object> document = new HashMap<>();
        document.put("title", fileName != null ? fileName : "未命名文档");
        document.put("url", proxyDownloadUrl); // 使用代理下载URL
        document.put("fileType", fileType);
        document.put("attachId", attach.getId());
        document.put("key", attach.getMd5());
        // 13. 构建最终返回结果
        Map<String, Object> result = new HashMap<>();
        result.put("documentType", documentType);
        result.put("document", document);
        result.put("editorConfig", editorConfig);
        // 14. 生成 JWT token
        Map<String, Object> result1 = OnlyofficeJwt.getToken(result);
        log.info("ONLYOFFICE配置生成完成,attachId: {}, 代理下载URL: {}", attachId, proxyDownloadUrl);
        return R.data(result1);
    }
    public static final Integer MAX_KEY_LENGTH = 20;
    public static final Integer ANONYMOUS_USER_ID = 4;
    public static final Integer KILOBYTE_SIZE = 1024;
    public String generateRevisionId(final String expectedKey) {
        /* if the expected key length is greater than 20
        then he expected key is hashed and a fixed length value is stored in the string format */
        String formatKey = expectedKey.length() > MAX_KEY_LENGTH
            ? Integer.toString(expectedKey.hashCode()) : expectedKey;
        String key = formatKey.replace("[^0-9-.a-zA-Z_=]", "_");
        return key.substring(0, Math.min(key.length(), MAX_KEY_LENGTH));  // the resulting key length is 20 or less
    }
    /**
     * OnlyOffice代理下载接口,用于代理下载远程文件
     * <p>
     * 当OnlyOffice Document Server无法直接访问远程文件URL时,
     * 通过本接口代理下载文件并流式返回给Document Server。
     * </p>
     *
     * @param request  HTTP请求对象
     * @param response HTTP响应对象
     * @param attachId 附件ID,用于从缓存中查找对应的远程文件URL
     * @throws IOException 当下载或写入文件流时发生I/O异常
     */
    @GetMapping("/onlyoffice-download")
    @ApiOperation(value = "OnlyOffice代理下载", notes = "代理下载远程文件供OnlyOffice Document Server访问")
    public void onlyofficeDownload(
            HttpServletRequest request,
            HttpServletResponse response,
            @ApiParam(value = "附件ID", required = true) @RequestParam Long attachId) throws IOException {
        // 1. 从缓存中获取远程文件URL
        String remoteUrl = REMOTE_URL_CACHE.get(attachId);
        if (remoteUrl == null || remoteUrl.isEmpty()) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "未找到对应的远程文件URL");
            return;
        }
        // 2. 根据附件ID查询附件信息,获取文件名
        Attach attach = attachService.getOne(Wrappers.lambdaQuery(Attach.class).eq(Attach::getId, attachId));
        String fileName = (attach != null && attach.getName() != null) ? attach.getName() : "document.docx";
        // 3. 从远程URL下载文件
        HttpURLConnection connection = null;
        InputStream inputStream = null;
        OutputStream outputStream = null;
        try {
            URL url = new URL(remoteUrl);
            connection = (HttpURLConnection) url.openConnection();
            connection.setRequestMethod("GET");
            connection.setConnectTimeout(CONNECT_TIMEOUT);
            connection.setReadTimeout(READ_TIMEOUT);
            // 4. 检查响应状态码
            int responseCode = connection.getResponseCode();
            if (responseCode != HttpURLConnection.HTTP_OK) {
                response.sendError(HttpServletResponse.SC_BAD_GATEWAY, "远程文件下载失败,状态码: " + responseCode);
                return;
            }
            // 5. 获取远程文件的Content-Type
            String contentType = connection.getContentType();
            if (contentType == null || contentType.isEmpty()) {
                contentType = "application/octet-stream";
            }
            // 6. 设置响应头
            response.setContentType(contentType);
            response.setHeader("Content-Disposition", "attachment; filename=\""
                    + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) + "\"");
            // 7. 流式传输文件内容
            inputStream = connection.getInputStream();
            outputStream = response.getOutputStream();
            byte[] buffer = new byte[8192];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
            }
            outputStream.flush();
        } catch (Exception e) {
            log.error("代理下载远程文件失败,attachId: {}, remoteUrl: {}, 错误: {}", attachId, remoteUrl, e.getMessage(), e);
            if (!response.isCommitted()) {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "代理下载远程文件失败: " + e.getMessage());
            }
        } finally {
            // 8. 关闭资源
            if (inputStream != null) {
                try { inputStream.close(); } catch (IOException ignored) {}
            }
            if (connection != null) {
                connection.disconnect();
            }
        }
    }
    /**
     * 构建当前服务器的代理下载URL
     *
     * @param request  HTTP请求对象,用于获取服务器地址
     * @param attachId 附件ID
     * @return 代理下载URL地址
     */
    private String buildProxyDownloadUrl(HttpServletRequest request, Long attachId) {
        // 1. 获取请求的协议、主机名和端口
        String scheme = request.getScheme();
        String serverName = request.getServerName();
        int serverPort = request.getServerPort();
        // 2. 构建服务器基础URL(省略默认端口)
        StringBuilder baseUrl = new StringBuilder();
        baseUrl.append(scheme).append("://").append(serverName);
        if (("http".equals(scheme) && serverPort != 80) || ("https".equals(scheme) && serverPort != 443)) {
            baseUrl.append(":").append(serverPort);
        }
        // baseUrl.append("http:192.168.1.33:80/blade-resource");
        // 3. 拼接代理下载URL
        return baseUrl.toString() + "/attach/onlyoffice-download?attachId=" + attachId;
    }
}
drone-ops/drone-resource/src/main/java/org/sxkj/resource/endpoint/OfficeCallbackEndpoint.java
@@ -1,6 +1,8 @@
package org.sxkj.resource.endpoint;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.AllArgsConstructor;
@@ -13,15 +15,14 @@
import org.springblade.core.oss.model.BladeFile;
import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.utils.FileUtil;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import org.sxkj.common.utils.file.FileNameHarsher;
import org.sxkj.resource.builder.OssBuilder;
import org.sxkj.resource.dto.OfficeCallbackDTO;
import org.sxkj.resource.entity.Attach;
import org.sxkj.resource.service.IAttachService;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
@@ -52,58 +53,97 @@
    /**
     * ONLYOFFICE 保存回调接口
     * 对应前端 config.editorConfig.callbackUrl = "http://localhost:8080/office/callback"
     * <p>
     * 处理ONLYOFFICE文档编辑保存回调,支持以下状态:
     * status=2: 文档编辑完成,关闭编辑器时触发
     * status=6: 文档强制保存(forcesave)时触发
     * </p>
     * <p>
     * 对应前端 config.editorConfig.callbackUrl = "http://192.168.1.33:8310/office/callback"
     * </p>
     *
     * @param callbackDTO 回调数据对象
     * @return 回调响应结果
     * @param payload  回调数据对象,包含status、url、key、filetype等字段
     * @param response HTTP响应对象
     * @return 回调响应结果,{"error":0}表示成功
     */
    @SneakyThrows
    @PostMapping("/callback")
    @ApiOperation(value = "ONLYOFFICE保存回调", notes = "ONLYOFFICE文档编辑保存回调接口")
    public Map<String, Object> officeCallback(@RequestBody OfficeCallbackDTO callbackDTO) {
        log.info("ONLYOFFICE回调开始,回调数据: {}", JSON.toJSONString(callbackDTO));
        Map<String, Object> result = new HashMap<>(4);
    public Map<String, Object> officeCallback(@RequestBody JSONObject payload, HttpServletResponse response) {
        log.info("ONLYOFFICE回调开始,回调数据: {}", JSON.toJSONString(payload));
        // 步骤1:获取回调状态和文件信息
        String status = callbackDTO.getStatus();
        String fileKey = callbackDTO.getKey();
        String fileDownloadUrl = callbackDTO.getUrl();
        // 1. 获取回调状态码
        Integer status = payload.getInteger("status");
        log.info("回调状态status={}", status);
        try {
            // 步骤2:判断是否是保存操作(1=手动保存,2=强制保存)
            if ("1".equals(status) || "2".equals(status)) {
                // 步骤3:下载ONLYOFFICE返回的新文件
                byte[] fileContent = downloadFile(fileDownloadUrl);
                // 步骤4:获取文件名(可根据业务需求从数据库获取原始文件名)
                String fileName = generateFileName(fileKey);
                // 步骤5:上传文件到OSS
                BladeFile bladeFile = uploadToOss(fileName, fileContent);
                log.info("ONLYOFFICE文件下载成功,文件名: {}, 文件大小: {}", fileName, fileContent.length);
                // 步骤6:保存附件信息到数据库
                Long attachId = saveAttachInfo(fileName, fileContent.length, bladeFile);
                // 步骤7:记录操作日志(可根据业务需求扩展)
                log.info("ONLYOFFICE文件保存成功,文件key: {}, 文件名: {}, 附件ID: {}, 下载URL: {}",
                    fileKey, fileName, attachId, bladeFile.getLink());
                // 步骤8:业务扩展(可根据需求添加)
                // - 更新数据库文件地址、版本号、更新时间
                // - 记录操作日志、操作用户
                // - 发送通知等
            }
            // 步骤9:成功必须返回 error:0
            result.put("error", 0);
        } catch (Exception e) {
            // 步骤10:异常处理,非0代表失败,编辑器会提示保存失败
            log.error("ONLYOFFICE文件保存异常,文件key: {}, 异常信息: {}", fileKey, e.getMessage(), e);
            result.put("error", 1);
            result.put("message", "文件保存异常:" + e.getMessage());
        // 2. 获取编辑后文件下载地址
        String downloadUrl = payload.getString("url");
        if (downloadUrl != null) {
            log.info("编辑后新版文件下载地址: {}", downloadUrl);
        }
        return result;
        // 3. 获取文档唯一标识key
        String key = payload.getString("key");
        log.info("回调key={}", key);
        try {
            // 4. 处理不同状态码
            // status=2: 文档被关闭,所有用户都退出编辑,需要保存
            // status=6: 文档强制保存(forcesave),编辑过程中定时或手动触发
            if ((status == 2 || status == 6) && downloadUrl != null) {
                handleSaveCallback(payload, key, downloadUrl);
            }
            // 5. 成功必须返回 {"error":0}
            return Map.of("error", 0);
        } catch (Exception e) {
            log.error("ONLYOFFICE回调处理失败,status={}, key={}, 错误: {}", status, key, e.getMessage(), e);
            // 异常返回非0,文档服务判定保存失败
            return Map.of("error", 1);
        }
    }
    /**
     * 处理保存回调(status=2或status=6)
     * <p>
     * 从ONLYOFFICE服务器下载编辑后的文件,上传到OSS,
     * 并更新附件表中的link和md5字段。
     * </p>
     *
     * @param payload     回调数据对象
     * @param key         文档唯一标识,对应附件表的md5字段
     * @param downloadUrl 编辑后文件的下载地址
     * @throws Exception 下载、上传或更新数据库时的异常
     */
    private void handleSaveCallback(JSONObject payload, String key, String downloadUrl) throws Exception {
        // 1. 根据key(md5)查询附件记录
        Attach attach = attachService.getOne(
            Wrappers.lambdaQuery(Attach.class).eq(Attach::getMd5, key)
        );
        if (attach == null) {
            log.warn("未找到key={}对应的附件记录,跳过保存", key);
            return;
        }
        log.info("找到附件记录,attachId={}, name={}", attach.getId(), attach.getName());
        // 2. 从ONLYOFFICE服务器下载编辑后的文件
        log.info("开始从ONLYOFFICE下载编辑后文件: {}", downloadUrl);
        byte[] fileContent = downloadFile(downloadUrl);
        log.info("文件下载完成,大小: {} bytes", fileContent.length);
        // 3. 上传文件到OSS
        String fileName = attach.getName() != null ? attach.getName() : key + ".docx";
        BladeFile bladeFile = uploadToOss(fileName, fileContent);
        log.info("文件上传OSS完成,新link: {}", bladeFile.getLink());
        // 4. 更新附件记录的link和md5
        String newLink = bladeFile.getLink();
        String newMd5 = FileNameHarsher.generateFileHash(newLink);
        attach.setLink(newLink);
        attach.setMd5(newMd5);
        attach.setAttachSize((long) fileContent.length);
        attachService.updateById(attach);
        log.info("附件记录更新完成,attachId={}, 新md5={}", attach.getId(), newMd5);
    }
    /**
drone-ops/drone-resource/src/main/java/org/sxkj/resource/util/OnlyofficeJwt.java
New file
@@ -0,0 +1,42 @@
package org.sxkj.resource.util;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import java.util.Map;
public class OnlyofficeJwt {
    /**
     * 把jsonObject返回给前端使用
     * @return
     */
    public static Map<String, Object> getToken(Map<String, Object> config){
         String tokenSecret = "ztzf-aisky-kx5xo-dx2w2-xpx5q";
         String token = "";
        try {
            // Signer signer = HMACSigner.newSHA256Signer(tokenSecret);
            // JWT jwt = new JWT();
            // for (String key : payloadClaims.keySet()) {
            //     jwt.addClaim(key, payloadClaims.get(key));
            // }
            // token = JWT.getEncoder().encode(jwt, signer);
            Algorithm algorithm = Algorithm.HMAC256(tokenSecret);
            // Calendar calendar = Calendar.getInstance();
            // Instant issuedAt = calendar.toInstant();
            // calendar.add(12, 5);
            // Instant expiresAt = calendar.toInstant();
            // token = JWT.create().withIssuedAt(issuedAt).withExpiresAt(expiresAt).withPayload(payloadClaims).sign(algorithm);
             token = JWT.create()
                .withPayload(config)
                .sign(algorithm);
        } catch (Exception e) {
            token = "";
        }
        config.put("token", token);
        return config;
    }
}
pom.xml
@@ -100,6 +100,18 @@
                <version>${bcpkix-jdk15on-version}</version>
            </dependency>
            <dependency>
                <groupId>com.inversoft</groupId>
                <artifactId>prime-jwt</artifactId>
                <version>1.3.1</version>
            </dependency>
            <dependency>
                <groupId>com.onlyoffice</groupId>
                <artifactId>docs-integration-sdk</artifactId>
                <version>1.6.0</version>
            </dependency>
        </dependencies>
    </dependencyManagement>