From fa51580985bdb2c4acd787dbb9f6ba26b7096a84 Mon Sep 17 00:00:00 2001 From: rcx <3084692334@qq.com> Date: Wed, 19 Aug 2026 18:04:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(product):=E9=99=84=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0v2=E8=BF=94=E5=9B=9E=E5=8E=9F=E5=A7=8B=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8D=EF=BC=8C=E4=B8=8B=E8=BD=BD=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=94=AF=E6=8C=81inline=E9=A2=84=E8=A7=88=E4=B8=8E=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=96=87=E4=BB=B6=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/menu/UploadFileResultDto.java | 18 ++ .../interfaces/facade/menu/MenuApi.java | 160 +++++++++++++++--- 2 files changed, 157 insertions(+), 21 deletions(-) create mode 100644 mhd_product/src/main/java/com/linke/product/interfaces/dto/menu/UploadFileResultDto.java diff --git a/mhd_product/src/main/java/com/linke/product/interfaces/dto/menu/UploadFileResultDto.java b/mhd_product/src/main/java/com/linke/product/interfaces/dto/menu/UploadFileResultDto.java new file mode 100644 index 000000000..bc01d42a7 --- /dev/null +++ b/mhd_product/src/main/java/com/linke/product/interfaces/dto/menu/UploadFileResultDto.java @@ -0,0 +1,18 @@ +package com.linke.product.interfaces.dto.menu; + +import lombok.Data; + +/** + * 文件上传 v2 返回对象:文件标识 + 原始文件名 + * + * @author rcx + */ +@Data +public class UploadFileResultDto { + + /** 文件标识(存储键:内网 LOCAL:D:/upload/{uuid}.{ext},外网 OBS 对象键 mhd/{uuid}.{ext}) */ + private String fileKey; + + /** 原始文件名(前端展示用,需随业务数据一并保存,如合同附件名称字段) */ + private String originalName; +} \ No newline at end of file diff --git a/mhd_product/src/main/java/com/linke/product/interfaces/facade/menu/MenuApi.java b/mhd_product/src/main/java/com/linke/product/interfaces/facade/menu/MenuApi.java index 122211337..162371d39 100644 --- a/mhd_product/src/main/java/com/linke/product/interfaces/facade/menu/MenuApi.java +++ b/mhd_product/src/main/java/com/linke/product/interfaces/facade/menu/MenuApi.java @@ -9,6 +9,7 @@ import com.linke.product.infrastructure.obs.FileStorageService; import com.linke.product.infrastructure.obs.ObsProperties; import com.linke.product.interfaces.assemble.menu.MenuAssembler; import com.linke.product.interfaces.dto.menu.MenuDto; +import com.linke.product.interfaces.dto.menu.UploadFileResultDto; import com.mhd.common.core.domain.po.MenuVo; import com.mhd.common.core.domain.dto.RoleMenuDTO; import com.mhd.common.core.oss.configure.OSSClientUtil; @@ -35,6 +36,7 @@ import javax.servlet.http.HttpServletRequest; import java.io.*; import java.net.HttpURLConnection; import java.net.URL; +import java.net.URLEncoder; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; @@ -66,7 +68,8 @@ public class MenuApi extends BaseController { private String bucketName; private String folder = "mhd"; - + /** 本地上传根目录(LOCAL: 通道统一存放目录,download-streaming 路径校验也以它为准) */ + private static final String LOCAL_UPLOAD_DIR = "D:\\upload"; @PostConstruct public void init() { this.bucketName = obsProperties.getBucketName(); @@ -236,7 +239,7 @@ public class MenuApi extends BaseController { * 本地上传文件到 D:/upload 目录 */ private String uploadToLocal(MultipartFile file) throws IOException { - String localUploadDir = "D:\\upload"; + String localUploadDir = LOCAL_UPLOAD_DIR; File dir = new File(localUploadDir); if (!dir.exists()) { dir.mkdirs(); @@ -253,6 +256,71 @@ public class MenuApi extends BaseController { return "LOCAL:" + destFile.getAbsolutePath(); } + + /** + * 按扩展名解析 MediaType(预览时浏览器据此决定渲染方式,不再统一 octet-stream) + */ + private MediaType resolveMediaType(String fileName) { + String name = fileName == null ? "" : fileName.toLowerCase(); + int dot = name.lastIndexOf('.'); + String ext = dot > -1 ? name.substring(dot + 1) : ""; + switch (ext) { + case "pdf": + return MediaType.APPLICATION_PDF; + case "jpg": + case "jpeg": + return MediaType.IMAGE_JPEG; + case "png": + return MediaType.IMAGE_PNG; + case "gif": + return MediaType.IMAGE_GIF; + case "bmp": + return MediaType.parseMediaType("image/bmp"); + case "txt": + return MediaType.TEXT_PLAIN; + case "html": + case "htm": + return MediaType.TEXT_HTML; + case "doc": + return MediaType.parseMediaType("application/msword"); + case "docx": + return MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + case "xls": + return MediaType.parseMediaType("application/vnd.ms-excel"); + case "xlsx": + return MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + case "zip": + return MediaType.parseMediaType("application/zip"); + default: + return MediaType.APPLICATION_OCTET_STREAM; + } + } + + /** + * 构造 Content-Disposition 响应头(RFC 5987 编码,中文文件名不乱码) + * dispositionType 只允许 inline(预览)/ attachment(下载) + */ + private String buildContentDisposition(String dispositionType, String fileName) { + String encoded; + try { + encoded = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20"); + } catch (UnsupportedEncodingException e) { + encoded = "file"; + } + return dispositionType + "; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded; + } + + /** + * 取路径/文件键的末段文件名(兼容 / 与 \ 两种分隔符) + */ + private String lastSegment(String path) { + if (path == null || path.isEmpty()) { + return ""; + } + int idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return idx > -1 ? path.substring(idx + 1) : path; + } + @PostMapping("/upload") public AjaxResult uploadFile(@RequestParam("file") MultipartFile file, HttpServletRequest request) { try { @@ -267,6 +335,28 @@ public class MenuApi extends BaseController { return AjaxResult.success(fileKey); } + /** + * 文件上传 v2:返回 fileKey + 原始文件名 + * 前端需将 fileKey 存业务表附件地址字段、originalName 存附件名称字段(用于回显显示) + */ + @PostMapping("/upload/v2") + public AjaxResult uploadFileV2(@RequestParam("file") MultipartFile file, HttpServletRequest request) { + String fileKey; + try { + if (isLocalNetwork(request)) { + fileKey = uploadToLocal(file); + } else { + fileKey = fileStorageService.uploadFile(file); + } + } catch (Exception e) { + return AjaxResult.error("上传文件失败: " + e.getMessage()); + } + UploadFileResultDto result = new UploadFileResultDto(); + result.setFileKey(fileKey); + result.setOriginalName(file.getOriginalFilename()); + return AjaxResult.success(result); + } + @GetMapping("/url") public AjaxResult getFileUrl(@RequestParam String fileKey) { String fileUrl = fileStorageService.getFileUrl(fileKey); @@ -419,23 +509,52 @@ public class MenuApi extends BaseController { } /** - * 方法2:使用StreamingResponseBody(更灵活) + * 文件流式下载/预览(StreamingResponseBody) + * + * @param bucketName OBS 桶名(LOCAL: 文件忽略此参数) + * @param objectKey 文件标识:LOCAL: 开头 → 服务器本地文件;否则 → OBS 对象键 + * @param filename 展示/下载文件名(前端传原始文件名;缺省时取 objectKey 末段) + * @param disposition attachment=下载(默认);inline=浏览器内联预览(PDF/图片直接打开) */ @GetMapping("/download-streaming") public ResponseEntity downloadStreaming( @RequestParam String bucketName, @RequestParam String objectKey, - @RequestParam String filename, + @RequestParam(value = "filename", required = false) String filename, + @RequestParam(value = "disposition", required = false, defaultValue = "attachment") String disposition, HttpServletRequest request) { - // 判断是否为内网环境,从本地文件读取 - if (isLocalNetwork(request) && objectKey != null && objectKey.startsWith("LOCAL:")) { - String localFilePath = objectKey.substring("LOCAL:".length()); - File localFile = new File(localFilePath); + // 响应头取值只允许两种,防注入 + String dispositionType = "inline".equalsIgnoreCase(disposition) ? "inline" : "attachment"; + + // 展示文件名:优先前端传入的原始文件名,兜底取 objectKey 末段 + String displayFileName = (filename != null && !filename.trim().isEmpty()) + ? lastSegment(filename) : lastSegment(objectKey); + + // 按存储位置路由(与下载者所在网络无关):LOCAL: 开头读本地受控目录 + if (objectKey != null && objectKey.startsWith("LOCAL:")) { + File localFile; + try { + File rootDir = new File(LOCAL_UPLOAD_DIR).getCanonicalFile(); + // 只取末段文件名并在受控根目录内解析,杜绝 ../ 路径穿越 + String fileNameOnly = lastSegment(objectKey.substring("LOCAL:".length())); + if (fileNameOnly.isEmpty() || fileNameOnly.contains("..")) { + return ResponseEntity.badRequest().build(); + } + localFile = new File(rootDir, fileNameOnly).getCanonicalFile(); + if (!localFile.getPath().startsWith(rootDir.getPath() + File.separator)) { + return ResponseEntity.badRequest().build(); + } + } catch (IOException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + if (!localFile.exists() || !localFile.isFile()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + filename + "\"") - .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, buildContentDisposition(dispositionType, displayFileName)) + .header("X-Content-Type-Options", "nosniff") + .contentType(resolveMediaType(displayFileName)) .contentLength(localFile.length()) .body(outputStream -> { try (InputStream inputStream = new FileInputStream(localFile)) { @@ -449,18 +568,17 @@ public class MenuApi extends BaseController { }); } - // OBS方式下载 + // OBS 方式下载(对象键,如 mhd/{uuid}.pdf) return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + filename + "\"") - .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, buildContentDisposition(dispositionType, displayFileName)) + .header("X-Content-Type-Options", "nosniff") + .contentType(resolveMediaType(displayFileName)) .body(outputStream -> { - ObsClient obsClient1 = null; + ObsClient obsClient = null; try { - obsClient1 = new ObsClient(obsProperties.getAccessKey(), obsProperties.getSecretKey(), obsProperties.getEndpoint()); + obsClient = new ObsClient(obsProperties.getAccessKey(), obsProperties.getSecretKey(), obsProperties.getEndpoint()); GetObjectRequest req = new GetObjectRequest(bucketName, objectKey); - ObsObject obsObject = obsClient1.getObject(req); - + ObsObject obsObject = obsClient.getObject(req); try (InputStream inputStream = obsObject.getObjectContent()) { byte[] buffer = new byte[8192]; int bytesRead; @@ -470,8 +588,8 @@ public class MenuApi extends BaseController { outputStream.flush(); } } finally { - if (obsClient1 != null) { - obsClient1.close(); + if (obsClient != null) { + obsClient.close(); } } });