feat(product):附件上传v2返回原始文件名,下载接口支持inline预览与中文文件名
This commit is contained in:
+18
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import com.linke.product.infrastructure.obs.FileStorageService;
|
|||||||
import com.linke.product.infrastructure.obs.ObsProperties;
|
import com.linke.product.infrastructure.obs.ObsProperties;
|
||||||
import com.linke.product.interfaces.assemble.menu.MenuAssembler;
|
import com.linke.product.interfaces.assemble.menu.MenuAssembler;
|
||||||
import com.linke.product.interfaces.dto.menu.MenuDto;
|
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.po.MenuVo;
|
||||||
import com.mhd.common.core.domain.dto.RoleMenuDTO;
|
import com.mhd.common.core.domain.dto.RoleMenuDTO;
|
||||||
import com.mhd.common.core.oss.configure.OSSClientUtil;
|
import com.mhd.common.core.oss.configure.OSSClientUtil;
|
||||||
@@ -35,6 +36,7 @@ import javax.servlet.http.HttpServletRequest;
|
|||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -66,7 +68,8 @@ public class MenuApi extends BaseController {
|
|||||||
private String bucketName;
|
private String bucketName;
|
||||||
|
|
||||||
private String folder = "mhd";
|
private String folder = "mhd";
|
||||||
|
/** 本地上传根目录(LOCAL: 通道统一存放目录,download-streaming 路径校验也以它为准) */
|
||||||
|
private static final String LOCAL_UPLOAD_DIR = "D:\\upload";
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
this.bucketName = obsProperties.getBucketName();
|
this.bucketName = obsProperties.getBucketName();
|
||||||
@@ -236,7 +239,7 @@ public class MenuApi extends BaseController {
|
|||||||
* 本地上传文件到 D:/upload 目录
|
* 本地上传文件到 D:/upload 目录
|
||||||
*/
|
*/
|
||||||
private String uploadToLocal(MultipartFile file) throws IOException {
|
private String uploadToLocal(MultipartFile file) throws IOException {
|
||||||
String localUploadDir = "D:\\upload";
|
String localUploadDir = LOCAL_UPLOAD_DIR;
|
||||||
File dir = new File(localUploadDir);
|
File dir = new File(localUploadDir);
|
||||||
if (!dir.exists()) {
|
if (!dir.exists()) {
|
||||||
dir.mkdirs();
|
dir.mkdirs();
|
||||||
@@ -253,6 +256,71 @@ public class MenuApi extends BaseController {
|
|||||||
return "LOCAL:" + destFile.getAbsolutePath();
|
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")
|
@PostMapping("/upload")
|
||||||
public AjaxResult<String> uploadFile(@RequestParam("file") MultipartFile file, HttpServletRequest request) {
|
public AjaxResult<String> uploadFile(@RequestParam("file") MultipartFile file, HttpServletRequest request) {
|
||||||
try {
|
try {
|
||||||
@@ -267,6 +335,28 @@ public class MenuApi extends BaseController {
|
|||||||
return AjaxResult.success(fileKey);
|
return AjaxResult.success(fileKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传 v2:返回 fileKey + 原始文件名
|
||||||
|
* 前端需将 fileKey 存业务表附件地址字段、originalName 存附件名称字段(用于回显显示)
|
||||||
|
*/
|
||||||
|
@PostMapping("/upload/v2")
|
||||||
|
public AjaxResult<UploadFileResultDto> 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")
|
@GetMapping("/url")
|
||||||
public AjaxResult<String> getFileUrl(@RequestParam String fileKey) {
|
public AjaxResult<String> getFileUrl(@RequestParam String fileKey) {
|
||||||
String fileUrl = fileStorageService.getFileUrl(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")
|
@GetMapping("/download-streaming")
|
||||||
public ResponseEntity<StreamingResponseBody> downloadStreaming(
|
public ResponseEntity<StreamingResponseBody> downloadStreaming(
|
||||||
@RequestParam String bucketName,
|
@RequestParam String bucketName,
|
||||||
@RequestParam String objectKey,
|
@RequestParam String objectKey,
|
||||||
@RequestParam String filename,
|
@RequestParam(value = "filename", required = false) String filename,
|
||||||
|
@RequestParam(value = "disposition", required = false, defaultValue = "attachment") String disposition,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
|
|
||||||
// 判断是否为内网环境,从本地文件读取
|
// 响应头取值只允许两种,防注入
|
||||||
if (isLocalNetwork(request) && objectKey != null && objectKey.startsWith("LOCAL:")) {
|
String dispositionType = "inline".equalsIgnoreCase(disposition) ? "inline" : "attachment";
|
||||||
String localFilePath = objectKey.substring("LOCAL:".length());
|
|
||||||
File localFile = new File(localFilePath);
|
// 展示文件名:优先前端传入的原始文件名,兜底取 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()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
.header(HttpHeaders.CONTENT_DISPOSITION, buildContentDisposition(dispositionType, displayFileName))
|
||||||
"attachment; filename=\"" + filename + "\"")
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
.contentType(resolveMediaType(displayFileName))
|
||||||
.contentLength(localFile.length())
|
.contentLength(localFile.length())
|
||||||
.body(outputStream -> {
|
.body(outputStream -> {
|
||||||
try (InputStream inputStream = new FileInputStream(localFile)) {
|
try (InputStream inputStream = new FileInputStream(localFile)) {
|
||||||
@@ -449,18 +568,17 @@ public class MenuApi extends BaseController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// OBS方式下载
|
// OBS 方式下载(对象键,如 mhd/{uuid}.pdf)
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
.header(HttpHeaders.CONTENT_DISPOSITION, buildContentDisposition(dispositionType, displayFileName))
|
||||||
"attachment; filename=\"" + filename + "\"")
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
.contentType(resolveMediaType(displayFileName))
|
||||||
.body(outputStream -> {
|
.body(outputStream -> {
|
||||||
ObsClient obsClient1 = null;
|
ObsClient obsClient = null;
|
||||||
try {
|
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);
|
GetObjectRequest req = new GetObjectRequest(bucketName, objectKey);
|
||||||
ObsObject obsObject = obsClient1.getObject(req);
|
ObsObject obsObject = obsClient.getObject(req);
|
||||||
|
|
||||||
try (InputStream inputStream = obsObject.getObjectContent()) {
|
try (InputStream inputStream = obsObject.getObjectContent()) {
|
||||||
byte[] buffer = new byte[8192];
|
byte[] buffer = new byte[8192];
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
@@ -470,8 +588,8 @@ public class MenuApi extends BaseController {
|
|||||||
outputStream.flush();
|
outputStream.flush();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (obsClient1 != null) {
|
if (obsClient != null) {
|
||||||
obsClient1.close();
|
obsClient.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user