diff --git a/server/src/main/java/cn/keking/service/impl/OfficeFilePreviewImpl.java b/server/src/main/java/cn/keking/service/impl/OfficeFilePreviewImpl.java index 2a19aa19..306ad484 100644 --- a/server/src/main/java/cn/keking/service/impl/OfficeFilePreviewImpl.java +++ b/server/src/main/java/cn/keking/service/impl/OfficeFilePreviewImpl.java @@ -67,7 +67,10 @@ public class OfficeFilePreviewImpl implements FilePreview { String outFilePath = fileAttribute.getOutFilePath(); //转换后生成文件的路径 // 查询转换状态 - checkAndHandleConvertStatus(model, fileName, cacheName,fileAttribute); + String convertStatusResult = checkAndHandleConvertStatus(model, fileName, cacheName, fileAttribute); + if (convertStatusResult != null) { + return convertStatusResult; + } if (!officePreviewType.equalsIgnoreCase("html")) { if (ConfigConstants.getOfficeTypeWeb().equalsIgnoreCase("web")) { @@ -307,9 +310,11 @@ public class OfficeFilePreviewImpl implements FilePreview { /** * 异步方法 */ - public String checkAndHandleConvertStatus(Model model, String fileName, String cacheName, FileAttribute fileAttribute){ + public String checkAndHandleConvertStatus(Model model, String fileName, String cacheName, FileAttribute fileAttribute) { FileConvertStatusManager.ConvertStatus status = FileConvertStatusManager.getConvertStatus(cacheName); int refreshSchedule = ConfigConstants.getTime(); + boolean forceUpdatedCache = fileAttribute.forceUpdatedCache(); + if (status != null) { if (status.getStatus() == FileConvertStatusManager.Status.CONVERTING) { // 正在转换中,返回等待页面 @@ -318,11 +323,27 @@ public class OfficeFilePreviewImpl implements FilePreview { model.addAttribute("message", status.getRealTimeMessage()); return WAITING_FILE_PREVIEW_PAGE; } else if (status.getStatus() == FileConvertStatusManager.Status.TIMEOUT) { - // 超时状态,不允许重新转换 - return otherFilePreview.notSupportedFile(model, fileAttribute, "文件转换已超时,无法继续转换"); + // 超时状态,检查是否有强制更新命令 + if (forceUpdatedCache) { + // 强制更新命令,清除状态,允许重新转换 + FileConvertStatusManager.convertSuccess(cacheName); + logger.info("强制更新命令跳过超时状态,允许重新转换: {}", cacheName); + return null; // 返回null表示继续执行 + } else { + // 没有强制更新,不允许重新转换 + return otherFilePreview.notSupportedFile(model, fileAttribute, "文件转换已超时,无法继续转换"); + } } else if (status.getStatus() == FileConvertStatusManager.Status.FAILED) { - // 失败状态,不允许重新转换 - return otherFilePreview.notSupportedFile(model, fileAttribute, "文件转换失败,无法继续转换"); + // 失败状态,检查是否有强制更新命令 + if (forceUpdatedCache) { + // 强制更新命令,清除状态,允许重新转换 + FileConvertStatusManager.convertSuccess(cacheName); + logger.info("强制更新命令跳过失败状态,允许重新转换: {}", cacheName); + return null; // 返回null表示继续执行 + } else { + // 没有强制更新,不允许重新转换 + return otherFilePreview.notSupportedFile(model, fileAttribute, "文件转换失败,无法继续转换"); + } } } return null; diff --git a/server/src/main/java/cn/keking/utils/DownloadUtils.java b/server/src/main/java/cn/keking/utils/DownloadUtils.java index 047d22b0..020d9b19 100644 --- a/server/src/main/java/cn/keking/utils/DownloadUtils.java +++ b/server/src/main/java/cn/keking/utils/DownloadUtils.java @@ -7,8 +7,18 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.type.TypeFactory; import io.mola.galimatias.GalimatiasParseException; import org.apache.commons.io.FileUtils; +import org.apache.hc.client5.http.ConnectionKeepAliveStrategy; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpMethod; @@ -27,6 +37,7 @@ import java.nio.file.StandardCopyOption; import java.util.Arrays; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; import static cn.keking.utils.KkFileUtils.*; @@ -41,10 +52,56 @@ public class DownloadUtils { private static final String URL_PARAM_FTP_PASSWORD = "ftp.password"; private static final String URL_PARAM_FTP_CONTROL_ENCODING = "ftp.control.encoding"; private static final String URL_PARAM_FTP_PORT = "ftp.control.port"; - private static final RestTemplate restTemplate = new RestTemplate(); - private static final HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); private static final ObjectMapper mapper = new ObjectMapper(); + // 使用静态单例的HttpClient和RestTemplate,实现连接复用 + private static volatile CloseableHttpClient httpClient; + private static volatile RestTemplate restTemplate; + + // 获取单例HttpClient(线程安全) + private static CloseableHttpClient getHttpClient() throws Exception { + if (httpClient == null) { + synchronized (DownloadUtils.class) { + if (httpClient == null) { + httpClient = createConfiguredHttpClient(); + logger.info("HttpClient初始化完成,已启用连接池和超时配置"); + } + } + } + return httpClient; + } + + // 获取单例RestTemplate + private static RestTemplate getRestTemplate() throws Exception { + if (restTemplate == null) { + synchronized (DownloadUtils.class) { + if (restTemplate == null) { + HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); + factory.setHttpClient(getHttpClient()); + + // 设置连接和读取超时(毫秒) + factory.setConnectTimeout(10000); // 10秒连接超时 + factory.setReadTimeout(30000); // 30秒读取超时 + factory.setConnectionRequestTimeout(5000); // 5秒获取连接超时 + + restTemplate = new RestTemplate(factory); + } + } + } + return restTemplate; + } + + // 应用关闭时清理资源 + public static void shutdown() { + if (httpClient != null) { + try { + httpClient.close(); + logger.info("HttpClient已关闭"); + } catch (IOException e) { + logger.warn("关闭HttpClient失败", e); + } + } + } /** * @param fileAttribute fileAttribute @@ -61,7 +118,8 @@ public class DownloadUtils { } ReturnResponse response = new ReturnResponse<>(0, "下载成功!!!", ""); String realPath = getRelFilePath(fileName, fileAttribute); - + // 获取文件后缀用于校验 + final String fileSuffix = fileAttribute.getSuffix(); // 判断是否非法地址 if (KkFileUtils.isIllegalFileName(realPath)) { response.setCode(1); @@ -92,11 +150,8 @@ public class DownloadUtils { if (isHttpUrl(url)) { File realFile = new File(realPath); - // 创建配置好的HttpClient - CloseableHttpClient httpClient = createConfiguredHttpClient(); - - factory.setHttpClient(httpClient); - restTemplate.setRequestFactory(factory); + // 使用单例的RestTemplate,复用连接池 + RestTemplate template = getRestTemplate(); String finalUrlStr = urlStr; RequestCallback requestCallback = request -> { request.getHeaders().setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL)); @@ -111,10 +166,41 @@ public class DownloadUtils { } }; try { - restTemplate.execute(url.toURI(), HttpMethod.GET, requestCallback, fileResponse -> { - FileUtils.copyToFile(fileResponse.getBody(), realFile); + final boolean[] hasError = {false}; + String finalUrlStr1 = urlStr; + template.execute(url.toURI(), HttpMethod.GET, requestCallback, fileResponse -> { + try { + // 获取响应头中的Content-Type + String contentType = WebUtils.headersType(fileResponse); + + // 如果是Office/设计文件,需要校验MIME类型 + if ( WebUtils.isMimeCheckRequired(fileSuffix)) { + if (! WebUtils.isValidMimeType(contentType, fileSuffix)) { + logger.error("文件类型错误,期望二进制文件但接收到文本类型,url: {}, Content-Type: {}", + finalUrlStr1, contentType); + hasError[0] = true; + // 重要:关闭响应流,不读取后续数据 + fileResponse.close(); + return null; + } + } + + // 保存文件 + FileUtils.copyToFile(fileResponse.getBody(), realFile); + } catch (Exception e) { + logger.error("处理文件响应时出错", e); + hasError[0] = true; + } return null; }); + + // 如果下载过程中出现错误 + if (hasError[0]) { + response.setCode(1); + response.setContent(null); + response.setMsg("文件类型校验失败"); + return response; + } } catch (Exception e) { // 如果是SSL证书错误,给出建议 if (e.getMessage() != null && @@ -126,16 +212,10 @@ public class DownloadUtils { } response.setCode(1); response.setContent(null); - response.setMsg("下载失败:" + e); + response.setMsg("下载失败:" + e.getMessage()); return response; - } finally { - // 确保HttpClient被关闭 - try { - httpClient.close(); - } catch (IOException e) { - logger.warn("关闭HttpClient失败", e); - } } + // 不再需要finally块中关闭HttpClient,因为复用 } else if (isFtpUrl(url)) { String ftpUsername = WebUtils.getUrlParameterReg(fileAttribute.getUrl(), URL_PARAM_FTP_USERNAME); String ftpPassword = WebUtils.getUrlParameterReg(fileAttribute.getUrl(), URL_PARAM_FTP_PASSWORD); @@ -153,7 +233,7 @@ public class DownloadUtils { response.setMsg(fileName); return response; } catch (IOException | GalimatiasParseException e) { - logger.error("文件下载失败,url:{}", urlStr); + logger.error("文件下载失败,url:{}", urlStr, e); response.setCode(1); response.setContent(null); if (e instanceof FileNotFoundException) { @@ -163,22 +243,53 @@ public class DownloadUtils { } return response; } catch (Exception e) { - throw new RuntimeException(e); + logger.error("下载过程发生未知异常", e); + response.setCode(1); + response.setContent(null); + response.setMsg("下载失败:" + e.getMessage()); + return response; } } /** - * 创建根据配置定制的HttpClient + * 创建根据配置定制的HttpClient(连接池版本) */ private static CloseableHttpClient createConfiguredHttpClient() throws Exception { - org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom(); + // 使用新的Builder API创建连接池管理器 + PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create() + .setMaxConnTotal(100) // 最大连接数 + .setMaxConnPerRoute(20) // 每个路由最大连接数 + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofSeconds(10)) // 连接超时10秒 + .setSocketTimeout(Timeout.ofSeconds(30)) // Socket超时30秒 + .setTimeToLive(TimeValue.ofMinutes(10)) // 连接存活时间10分钟 + .build()) + .build(); + + // 创建请求配置 + RequestConfig requestConfig = RequestConfig.custom() + .setResponseTimeout(Timeout.ofSeconds(30)) // 响应超时30秒 + .setConnectionRequestTimeout(Timeout.ofSeconds(5)) // 获取连接超时5秒 + .build(); + + // 创建HttpClientBuilder + HttpClientBuilder builder = HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig); + + // 配置Keep-Alive策略 + ConnectionKeepAliveStrategy keepAliveStrategy = (response, context) -> { + // 默认保持30秒 + return TimeValue.ofSeconds(30); + }; + builder.setKeepAliveStrategy(keepAliveStrategy); // 配置SSL if (ConfigConstants.isIgnoreSSL()) { logger.debug("创建忽略SSL验证的HttpClient"); - // 如果SslUtils有创建builder的方法就更好了,这里假设我们直接使用SslUtils - // 或者我们可以创建一个新的方法来返回配置了忽略SSL的builder - return createHttpClientWithConfig(); + // 如果SslUtils支持HttpClient5,则使用它来创建client + // 否则,我们需要在这里配置忽略SSL + return createHttpClientWithConfig(builder); } else { logger.debug("创建标准HttpClient"); } @@ -195,10 +306,17 @@ public class DownloadUtils { /** * 创建配置了忽略SSL的HttpClient */ - private static CloseableHttpClient createHttpClientWithConfig() throws Exception { - return SslUtils.createHttpClientIgnoreSsl(); - } + private static CloseableHttpClient createHttpClientWithConfig(HttpClientBuilder builder) throws Exception { + // 如果SslUtils支持直接配置builder,则: + // SslUtils.configureIgnoreSsl(builder); + // return builder.build(); + // 否则,使用SslUtils创建client + CloseableHttpClient sslIgnoredClient = SslUtils.createHttpClientIgnoreSsl(); + + logger.warn("SslUtils.createHttpClientIgnoreSsl()可能没有连接池配置,建议修改SslUtils以支持builder配置"); + return sslIgnoredClient; + } // 处理file协议的文件下载 private static void handleFileProtocol(URL url, String targetPath) throws IOException { @@ -280,5 +398,4 @@ public class DownloadUtils { } return realPath; } - } \ No newline at end of file diff --git a/server/src/main/java/cn/keking/utils/WebUtils.java b/server/src/main/java/cn/keking/utils/WebUtils.java index fc1f88ec..beaa9a3e 100644 --- a/server/src/main/java/cn/keking/utils/WebUtils.java +++ b/server/src/main/java/cn/keking/utils/WebUtils.java @@ -7,6 +7,8 @@ import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.util.ObjectUtils; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.util.HtmlUtils; @@ -461,6 +463,78 @@ public class WebUtils { return parts; } + /** + * 获取Content-Type + */ + + public static String headersType(ClientHttpResponse fileResponse) { + if (fileResponse == null) { + return null; + } + HttpHeaders headers = fileResponse.getHeaders(); + if (headers == null) { + return null; + } + String contentTypeStr = null; + try { + // 直接获取Content-Type头字符串 + contentTypeStr = headers.getFirst(HttpHeaders.CONTENT_TYPE); + if (contentTypeStr == null || contentTypeStr.isEmpty()) { + return null; + } + // 解析为MediaType对象 + MediaType mediaType = MediaType.parseMediaType(contentTypeStr); + // 返回主类型和子类型,忽略参数 + return mediaType.getType() + "/" + mediaType.getSubtype(); + } catch (Exception e) { + // 如果解析失败,尝试简单的字符串处理 + if (contentTypeStr != null) { + // 移除分号及后面的参数 + int semicolonIndex = contentTypeStr.indexOf(';'); + if (semicolonIndex > 0) { + return contentTypeStr.substring(0, semicolonIndex).trim(); + } + return contentTypeStr.trim(); + } + return null; + } + } + + /** + * 判断文件是否需要校验MIME类型 + * @param suffix 文件后缀 + * @return 是否需要校验 + */ + public static boolean isMimeCheckRequired(String suffix) { + if (suffix == null) { + return false; + } + String lowerSuffix = suffix.toLowerCase(); + return Arrays.asList( + "doc", "docx", "ppt", "pptx", "pdf", "dwg", + "dxf", "dwf", "psd", "wps", "xlsx", "xls", + "rar", "zip" + ).contains(lowerSuffix); + } + + /** + * 校验文件MIME类型是否有效 + * @param contentType 响应头中的Content-Type + * @param suffix 文件后缀 + * @return 是否有效 + */ + public static boolean isValidMimeType(String contentType, String suffix) { + if (contentType == null || suffix == null) { + return true; + } + + // 如果检测到是HTML、文本或JSON格式,则认为是错误响应 + String lowerContentType = contentType.toLowerCase(); + return !lowerContentType.contains("text/html") + && !lowerContentType.contains("text/plain") + && !lowerContentType.contains("application/json"); + } + /** * 支持basic 下载方法 */ diff --git a/server/src/main/resources/web/main/record.ftl b/server/src/main/resources/web/main/record.ftl index c4379a76..4b81a5e3 100644 --- a/server/src/main/resources/web/main/record.ftl +++ b/server/src/main/resources/web/main/record.ftl @@ -60,8 +60,7 @@ 9. 新增 防重复转换
10. 新增 异步等待
11. 新增 上传限制不支持的文件禁止上传
- 12. 新增 异步等待
- 13. 新增 cadviewer转换方法
+ 12. 新增 cadviewer转换方法

修复

1. 压缩包路径问题
2. 安全问题