| | |
| | | import io.swagger.annotations.Api; |
| | | import io.swagger.annotations.ApiOperation; |
| | | import io.swagger.annotations.ApiParam; |
| | | import lombok.AllArgsConstructor; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | 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; |
| | | 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; |
| | | import org.springframework.web.bind.annotation.*; |
| | | import org.sxkj.common.model.ResponseResult; |
| | |
| | | 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; |
| | | |
| | | /** |
| | | * 附件表 控制器 |
| | |
| | | @Slf4j |
| | | @NonDS |
| | | @RestController |
| | | @AllArgsConstructor |
| | | @RequiredArgsConstructor |
| | | @RequestMapping("/attach") |
| | | @Api(value = "附件表接口", tags = "附件") |
| | | public class AttachController extends BladeController { |
| | | |
| | | private final IAttachService attachService; |
| | | private final OnlyofficeJwt onlyofficeJwt; |
| | | |
| | | /** OnlyOffice应用服务基础URL,供Document Server访问代理下载接口 */ |
| | | @Value("${onlyoffice.base-url:}") |
| | | private String onlyofficeBaseUrl; |
| | | |
| | | /** OnlyOffice回调地址 */ |
| | | @Value("${onlyoffice.callback-url:}") |
| | | private String onlyofficeCallbackUrl; |
| | | |
| | | /** 远程文件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; |
| | | |
| | | /** |
| | | * 详情 |
| | |
| | | @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); |
| | | } |
| | |
| | | // 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, |
| | | @RequestParam(value = "mode", required = false) String mode) { |
| | | // 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", StringUtil.isBlank(mode) ? "view" : mode); |
| | | editorConfig.put("callbackUrl", onlyofficeCallbackUrl); |
| | | 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 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); |
| | | |
| | | // 12. 构建文档配置,使用代理下载URL替代远程URL |
| | | Map<String, Object> document = new HashMap<>(); |
| | | document.put("title", fileName != null ? fileName : "未命名文档"); |
| | | document.put("url", documentUrl); |
| | | document.put("fileType", fileType); |
| | | 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: {}, 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; |
| | | 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 |
| | | } |
| | | |
| | | /** |
| | | * 构建当前服务器的代理下载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; |
| | | } |
| | | |
| | | @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 { |
| | | |
| | | // 处理 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; |
| | | } |
| | | |
| | | log.info("ONLYOFFICE 下载请求,attachId: {}", attachId); |
| | | |
| | | // 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(); |
| | | connection.setRequestMethod("GET"); |
| | | connection.setConnectTimeout(CONNECT_TIMEOUT); |
| | | connection.setReadTimeout(READ_TIMEOUT); |
| | | |
| | | // 复制请求头(如果需要) |
| | | 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; |
| | | } |
| | | |
| | | // 6. 设置响应头 |
| | | String contentType = connection.getContentType(); |
| | | if (contentType == null || contentType.isEmpty()) { |
| | | contentType = getContentTypeByFileName(fileName); |
| | | } |
| | | response.setContentType(contentType); |
| | | |
| | | // 设置 Content-Length |
| | | int contentLength = connection.getContentLength(); |
| | | if (contentLength > 0) { |
| | | response.setContentLength(contentLength); |
| | | } |
| | | |
| | | // 7. 正确设置 Content-Disposition |
| | | String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) |
| | | .replaceAll("\\+", "%20"); |
| | | |
| | | // 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); |
| | | if (!response.isCommitted()) { |
| | | response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, |
| | | "代理下载远程文件失败: " + e.getMessage()); |
| | | } |
| | | } finally { |
| | | if (inputStream != null) { |
| | | try { inputStream.close(); } catch (IOException ignored) {} |
| | | } |
| | | if (outputStream != null) { |
| | | try { outputStream.close(); } catch (IOException ignored) {} |
| | | } |
| | | if (connection != null) { |
| | | connection.disconnect(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 根据文件名获取 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"; |
| | | } |
| | | } |
| | | |
| | | } |