From 62091bb640608f24812e069947928c49b4ebdedf Mon Sep 17 00:00:00 2001
From: linwei <872216696@qq.com>
Date: Sat, 11 Jul 2026 10:39:44 +0800
Subject: [PATCH] feat(resource): 集成OnlyOffice文档编辑功能

---
 drone-ops/drone-resource/src/main/java/org/sxkj/resource/controller/AttachController.java |  192 ++++++++++++++++++++++++++++++++++++-----------
 1 files changed, 147 insertions(+), 45 deletions(-)

diff --git a/drone-ops/drone-resource/src/main/java/org/sxkj/resource/controller/AttachController.java b/drone-ops/drone-resource/src/main/java/org/sxkj/resource/controller/AttachController.java
index 7fefbaa..80ee579 100644
--- a/drone-ops/drone-resource/src/main/java/org/sxkj/resource/controller/AttachController.java
+++ b/drone-ops/drone-resource/src/main/java/org/sxkj/resource/controller/AttachController.java
@@ -21,6 +21,7 @@
 import org.springblade.core.tool.utils.BeanUtil;
 import org.springblade.core.tool.utils.DateTimeUtil;
 import org.springblade.core.tool.utils.Func;
+import org.springblade.core.tool.utils.StringUtil;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 import org.springframework.util.StringUtils;
@@ -421,8 +422,9 @@
 	@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) {
+		HttpServletRequest request,
+		@ApiParam(value = "附件ID", required = true) @RequestParam Long attachId,
+		@RequestParam(value = "mode", required = false) String mode) {
 		// 1. 获取当前登录用户信息
 		BladeUser user = AuthUtil.getUser();
 
@@ -445,7 +447,7 @@
 		// 5. 构建编辑器配置
 		Map<String, Object> editorConfig = new HashMap<>();
 		editorConfig.put("customization", customization);
-		editorConfig.put("mode", "edit");
+		editorConfig.put("mode", StringUtil.isBlank(mode) ? "view" : mode);
 		editorConfig.put("callbackUrl", onlyofficeCallbackUrl);
 		editorConfig.put("lang", "zh-CN");
 		editorConfig.put("user", userConfig);
@@ -477,14 +479,15 @@
 
 		// 10. 构建代理下载URL,替代直接使用远程URL
 		// Document Server通过代理下载接口获取文件,避免无法直接访问远程URL的问题
-		String documentUrl = buildProxyDownloadUrl(request, attachId);
-		// if (onlyofficeBaseUrl != null && !onlyofficeBaseUrl.isEmpty()) {
-		// 	// 使用代理下载URL,Document Server通过本服务的代理接口获取文件
-		// 	documentUrl = onlyofficeBaseUrl + "/attach/onlyoffice-download?attachId=" + attachId;
-		// } else {
-		// 	// 未配置baseUrl时回退到直接使用远程URL
-		// 	documentUrl = fileUrl != null ? fileUrl : "";
-		// }
+		String documentUrl;
+		// String documentUrl = buildProxyDownloadUrl(request, attachId);
+		if (onlyofficeBaseUrl != null && !onlyofficeBaseUrl.isEmpty()) {
+			// 使用代理下载URL,Document Server通过本服务的代理接口获取文件
+			documentUrl = onlyofficeBaseUrl + "/attach/onlyoffice-download?attachId=" + attachId;
+		} else {
+			// 未配置baseUrl时回退到直接使用远程URL
+			documentUrl = fileUrl != null ? fileUrl : "";
+		}
 
 		// 11. 生成文档唯一标识
 		String key = generateRevisionId(fileName);
@@ -507,6 +510,8 @@
 		log.info("ONLYOFFICE配置生成完成,attachId: {}, documentUrl: {}", attachId, documentUrl);
 		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;
@@ -546,40 +551,63 @@
 		return baseUrl.toString() + "/attach/onlyoffice-download?attachId=" + attachId;
 	}
 
-	/**
-	 * 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 {
+		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");
+		// 处理 OPTIONS 预检请求
+		if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
+			response.setHeader("Access-Control-Allow-Origin", "*");
+			response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+			response.setHeader("Access-Control-Allow-Headers", "*");
+			response.setStatus(HttpServletResponse.SC_OK);
 			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";
+		log.info("ONLYOFFICE 下载请求,attachId: {}", attachId);
 
-		// 3. 从远程URL下载文件
+		// 1. 根据附件ID查询附件信息
+		Attach attach = attachService.getOne(Wrappers.lambdaQuery(Attach.class).eq(Attach::getId, attachId));
+		if (attach == null) {
+			response.sendError(HttpServletResponse.SC_NOT_FOUND, "附件不存在,attachId: " + attachId);
+			return;
+		}
+
+		// 2. 优先从缓存中获取远程文件URL
+		String remoteUrl = REMOTE_URL_CACHE.get(attachId);
+		if (remoteUrl == null || remoteUrl.isEmpty()) {
+			remoteUrl = attach.getLink();
+			log.info("从数据库获取远程URL,attachId: {}, link: {}", attachId, remoteUrl);
+		}
+
+		// 3. 验证远程URL
+		if (remoteUrl == null || remoteUrl.isEmpty()) {
+			response.sendError(HttpServletResponse.SC_NOT_FOUND, "附件缺少远程文件URL,attachId: " + attachId);
+			return;
+		}
+
+		// 4. 获取纯文件名(不含路径)
+		String fileName = attach.getName();
+		if (fileName == null || fileName.isEmpty()) {
+			fileName = "document.docx";
+		}
+		// 移除路径部分
+		if (fileName.contains("/")) {
+			fileName = fileName.substring(fileName.lastIndexOf("/") + 1);
+		}
+		if (fileName.contains("\\")) {
+			fileName = fileName.substring(fileName.lastIndexOf("\\") + 1);
+		}
+		log.info("下载文件名: {}", fileName);
+
+		// 5. 下载远程文件
 		HttpURLConnection connection = null;
 		InputStream inputStream = null;
 		OutputStream outputStream = null;
+
 		try {
 			URL url = new URL(remoteUrl);
 			connection = (HttpURLConnection) url.openConnection();
@@ -587,42 +615,78 @@
 			connection.setConnectTimeout(CONNECT_TIMEOUT);
 			connection.setReadTimeout(READ_TIMEOUT);
 
-			// 4. 检查响应状态码
+			// 复制请求头(如果需要)
+			String userAgent = request.getHeader("User-Agent");
+			if (userAgent != null) {
+				connection.setRequestProperty("User-Agent", userAgent);
+			}
+
 			int responseCode = connection.getResponseCode();
 			if (responseCode != HttpURLConnection.HTTP_OK) {
+				log.error("远程文件下载失败,状态码: {}, url: {}", responseCode, remoteUrl);
 				response.sendError(HttpServletResponse.SC_BAD_GATEWAY, "远程文件下载失败,状态码: " + responseCode);
 				return;
 			}
 
-			// 5. 获取远程文件的Content-Type
+			// 6. 设置响应头
 			String contentType = connection.getContentType();
 			if (contentType == null || contentType.isEmpty()) {
-				contentType = "application/octet-stream";
+				contentType = getContentTypeByFileName(fileName);
+			}
+			response.setContentType(contentType);
+
+			// 设置 Content-Length
+			int contentLength = connection.getContentLength();
+			if (contentLength > 0) {
+				response.setContentLength(contentLength);
 			}
 
-			// 6. 设置响应头
-			response.setContentType(contentType);
-			response.setHeader("Content-Disposition", "attachment; filename=\""
-					+ URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) + "\"");
+			// 7. 正确设置 Content-Disposition
+			String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name())
+				.replaceAll("\\+", "%20");
 
-			// 7. 流式传输文件内容
+			// ONLYOFFICE 需要 inline 方式访问
+			response.setHeader("Content-Disposition",
+				"inline; filename=\"" + encodedFileName + "\"; " +
+					"filename*=UTF-8''" + encodedFileName);
+
+			// CORS 头
+			response.setHeader("Access-Control-Allow-Origin", "*");
+			response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+			response.setHeader("Access-Control-Expose-Headers", "Content-Disposition");
+
+			// 缓存控制
+			response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+			response.setHeader("Pragma", "no-cache");
+			response.setHeader("Expires", "0");
+
+			// 8. 流式传输
 			inputStream = connection.getInputStream();
 			outputStream = response.getOutputStream();
 			byte[] buffer = new byte[8192];
 			int bytesRead;
+			long totalBytes = 0;
 			while ((bytesRead = inputStream.read(buffer)) != -1) {
 				outputStream.write(buffer, 0, bytesRead);
+				totalBytes += bytesRead;
 			}
 			outputStream.flush();
+
+			log.info("下载完成,文件大小: {} bytes", totalBytes);
+
 		} catch (Exception e) {
-			log.error("代理下载远程文件失败,attachId: {}, remoteUrl: {}, 错误: {}", attachId, remoteUrl, e.getMessage(), e);
+			log.error("代理下载远程文件失败,attachId: {}, remoteUrl: {}, 错误: {}",
+				attachId, remoteUrl, e.getMessage(), e);
 			if (!response.isCommitted()) {
-				response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "代理下载远程文件失败: " + e.getMessage());
+				response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+					"代理下载远程文件失败: " + e.getMessage());
 			}
 		} finally {
-			// 8. 关闭资源
 			if (inputStream != null) {
 				try { inputStream.close(); } catch (IOException ignored) {}
+			}
+			if (outputStream != null) {
+				try { outputStream.close(); } catch (IOException ignored) {}
 			}
 			if (connection != null) {
 				connection.disconnect();
@@ -630,4 +694,42 @@
 		}
 	}
 
+	/**
+	 * 根据文件名获取 Content-Type
+	 */
+	private String getContentTypeByFileName(String fileName) {
+		if (fileName == null) return "application/octet-stream";
+
+		String extension = "";
+		int lastDot = fileName.lastIndexOf(".");
+		if (lastDot > 0) {
+			extension = fileName.substring(lastDot + 1).toLowerCase();
+		}
+
+		switch (extension) {
+			case "docx":
+				return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+			case "doc":
+				return "application/msword";
+			case "xlsx":
+				return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+			case "xls":
+				return "application/vnd.ms-excel";
+			case "pptx":
+				return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
+			case "ppt":
+				return "application/vnd.ms-powerpoint";
+			case "pdf":
+				return "application/pdf";
+			case "txt":
+				return "text/plain";
+			case "csv":
+				return "text/csv";
+			case "rtf":
+				return "application/rtf";
+			default:
+				return "application/octet-stream";
+		}
+	}
+
 }

--
Gitblit v1.9.3