Spring Boot文件上传
# Spring Boot文件上传
# 一、文件上传基础概念与原理
# 1.1 文件上传技术概述
文件上传是Web应用中常见的功能需求,在Spring Boot应用中,文件上传主要通过MultipartFile
接口处理。根据不同的业务需求,我们可以实现单文件上传、多文件批量上传以及大文件分片上传等功能。本文将详细介绍这些实现方式及其最佳实践。
# 1.2 Spring Boot中的文件上传机制
Spring Boot内置了对文件上传的支持,主要通过以下组件实现:
- MultipartResolver:解析HTTP请求中的
multipart/form-data
内容 - MultipartFile:表示上传文件的接口,提供获取文件名、大小、内容类型等方法
- Spring MVC注解:使用
@RequestParam
、@RequestPart
等注解接收上传的文件
# 1.3 文件上传配置参数
在Spring Boot的application.yml
或application.properties
中可以配置文件上传相关参数:
spring:
servlet:
multipart:
enabled: true # 启用文件上传功能
max-file-size: 50MB # 单个文件最大大小
max-request-size: 100MB # 单次请求最大大小(包含所有文件)
file-size-threshold: 2KB # 超过此大小时写入磁盘而不是内存
location: /tmp # 临时文件存储位置
2
3
4
5
6
7
8
# 二、单文件上传实现
# 2.1 基础单文件上传实现
单文件上传是最常见的文件上传场景,通过MultipartFile
接收上传的文件,并将其保存到服务器指定位置。
/**
* 文件上传控制器
* 处理单文件和多文件上传请求
*/
@RestController
@RequestMapping("/api/file")
public class FileUploadController {
/**
* 单文件上传处理端点
*
* @param file 前端上传的文件对象,由Spring自动解析multipart请求并绑定
* @return 包含上传结果的HTTP响应
*/
@PostMapping("/upload")
public ResponseEntity<Map<String, Object>> uploadFile(@RequestParam("file") MultipartFile file) {
// 返回结果Map
Map<String, Object> response = new HashMap<>();
// 检查文件是否为空
if (file.isEmpty()) {
response.put("success", false);
response.put("message", "文件为空,请选择有效文件进行上传");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
try {
// 获取原始文件名
String originalFileName = file.getOriginalFilename();
// 防御性编程,确保文件名不为空
if (originalFileName == null) {
response.put("success", false);
response.put("message", "无法获取文件名");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
// 生成唯一文件名,避免文件名冲突
String uniqueFileName = UUID.randomUUID().toString() + "_" + originalFileName;
// 定义文件保存路径
String filePath = "C:/uploads/";
File uploadDir = new File(filePath);
// 检查目录是否存在,不存在则创建
if (!uploadDir.exists()) {
boolean dirCreated = uploadDir.mkdirs();
if (!dirCreated) {
response.put("success", false);
response.put("message", "无法创建目标目录,请检查权限");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
// 构建完整的文件保存路径
File destFile = new File(uploadDir, uniqueFileName);
// 将上传的文件内容写入目标文件
file.transferTo(destFile);
// 构建成功响应数据
response.put("success", true);
response.put("message", "文件上传成功");
response.put("fileName", uniqueFileName);
response.put("originalFileName", originalFileName);
response.put("fileSize", file.getSize());
response.put("contentType", file.getContentType());
response.put("filePath", destFile.getAbsolutePath());
return ResponseEntity.ok(response);
} catch (IOException e) {
// 记录详细错误信息
e.printStackTrace();
// 构建错误响应
response.put("success", false);
response.put("message", "文件上传失败:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# 2.2 使用相对路径实现单文件上传
在实际应用中,通常需要将文件保存到应用程序所在目录或相对路径,而不是硬编码的绝对路径。以下示例展示了如何使用系统属性获取程序运行目录并保存文件:
/**
* 使用系统路径实现的单文件上传处理端点
* 将文件保存到应用程序运行目录下的指定文件夹
*
* @param file 前端上传的文件对象
* @return 包含上传结果的HTTP响应
*/
@PostMapping("/upload-to-app-dir")
public ResponseEntity<Map<String, Object>> uploadToAppDirectory(
@RequestParam("file") MultipartFile file) {
Map<String, Object> response = new HashMap<>();
// 文件有效性检查
if (file.isEmpty()) {
response.put("success", false);
response.put("message", "文件为空,请选择有效文件");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
try {
// 获取原始文件名
String originalFileName = file.getOriginalFilename();
if (originalFileName == null) {
response.put("success", false);
response.put("message", "无法获取文件名");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
// 生成唯一文件名
String uniqueFileName = UUID.randomUUID().toString() + "_" + originalFileName;
// 获取应用程序运行目录
String currentDir = System.getProperty("user.dir");
// 拼接上传目录路径,使用File.separator确保跨平台兼容性
String uploadPath = currentDir + File.separator + "uploads" + File.separator;
// 日志记录保存路径
System.out.println("文件将保存到: " + uploadPath);
// 创建目录对象
File uploadDir = new File(uploadPath);
if (!uploadDir.exists()) {
boolean dirCreated = uploadDir.mkdirs();
if (!dirCreated) {
response.put("success", false);
response.put("message", "无法创建目标目录,请检查应用权限");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
// 创建目标文件对象
File destFile = new File(uploadDir, uniqueFileName);
// 保存文件
file.transferTo(destFile);
// 构建成功响应
response.put("success", true);
response.put("message", "文件上传成功");
response.put("fileName", uniqueFileName);
response.put("originalFileName", originalFileName);
response.put("fileSize", file.getSize());
response.put("filePath", destFile.getAbsolutePath());
return ResponseEntity.ok(response);
} catch (IOException e) {
e.printStackTrace();
response.put("success", false);
response.put("message", "文件上传失败: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# 2.3 文件路径选择策略
在不同的运行环境中,文件存储路径的选择策略如下表所示:
运行环境 | 推荐存储路径策略 | 优劣分析 | 最佳实践 |
---|---|---|---|
Spring Boot JAR独立运行 | 配置文件指定的固定外部路径 | ✅ 路径稳定 ✅ 升级应用不影响文件 | 使用配置文件定义的绝对路径 |
Spring Boot WAR部署到Tomcat | Tomcat外部路径或应用目录相对路径 | ✅ 相对稳定 ⚠️ 重启可能影响临时路径 | 使用外部存储目录或应用相对路径 |
本地开发环境 | 项目相对路径或开发机器固定目录 | ✅ 方便开发调试 ⚠️ 需防止提交临时文件 | 使用相对路径并添加.gitignore规则 |
容器化环境(Docker) | 挂载外部卷(Volume) | ✅ 数据持久化 ✅ 容器重建不丢失数据 | 使用Docker volumes挂载外部存储 |
# 三、多文件上传实现
# 3.1 批量文件上传实现
多文件上传允许用户一次性上传多个文件,提高操作效率。Spring Boot通过List<MultipartFile>
接收多文件上传请求:
/**
* 多文件批量上传处理端点
* 同时接收和处理多个文件,并返回每个文件的处理结果
*
* @param files 前端上传的文件列表,必须与前端field名称一致
* @return 包含上传结果的HTTP响应
*/
@PostMapping("/upload-multiple")
public ResponseEntity<Map<String, Object>> uploadMultipleFiles(
@RequestParam("files") List<MultipartFile> files) {
// 初始化返回结果对象
Map<String, Object> response = new HashMap<>();
List<Map<String, Object>> fileResults = new ArrayList<>();
// 检查文件列表是否为空
if (files.isEmpty()) {
response.put("success", false);
response.put("message", "未选择任何文件,请选择要上传的文件");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
// 定义文件保存根目录
String uploadDir = "C:/uploads/batch/";
File dirObj = new File(uploadDir);
// 确保目录存在
if (!dirObj.exists()) {
boolean dirCreated = dirObj.mkdirs();
if (!dirCreated) {
response.put("success", false);
response.put("message", "无法创建目标目录,请检查权限设置");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
// 初始化统计变量
int successCount = 0;
int failureCount = 0;
long totalSize = 0;
// 遍历处理每个文件
for (MultipartFile file : files) {
Map<String, Object> fileResult = new HashMap<>();
// 当前处理的文件信息
String originalFileName = file.getOriginalFilename();
fileResult.put("originalFileName", originalFileName);
// 跳过空文件
if (file.isEmpty()) {
fileResult.put("success", false);
fileResult.put("message", "文件为空,已跳过");
failureCount++;
fileResults.add(fileResult);
continue;
}
try {
// 生成唯一文件名避免冲突
String uniqueFileName = UUID.randomUUID().toString() + "_" +
(originalFileName != null ? originalFileName : "unknown");
// 构建完整的文件存储路径
File destFile = new File(dirObj, uniqueFileName);
// 保存文件
file.transferTo(destFile);
// 记录成功信息
fileResult.put("success", true);
fileResult.put("message", "上传成功");
fileResult.put("savedFileName", uniqueFileName);
fileResult.put("fileSize", file.getSize());
fileResult.put("contentType", file.getContentType());
fileResult.put("filePath", destFile.getAbsolutePath());
// 更新统计数据
successCount++;
totalSize += file.getSize();
} catch (IOException e) {
// 记录失败信息
fileResult.put("success", false);
fileResult.put("message", "上传失败: " + e.getMessage());
failureCount++;
}
// 添加当前文件结果到结果列表
fileResults.add(fileResult);
}
// 构建最终响应
response.put("success", failureCount == 0);
response.put("message", String.format("共处理 %d 个文件, 成功: %d, 失败: %d",
files.size(), successCount, failureCount));
response.put("totalFiles", files.size());
response.put("successCount", successCount);
response.put("failureCount", failureCount);
response.put("totalSize", totalSize);
response.put("files", fileResults);
// 根据处理结果返回适当的HTTP状态码
if (failureCount > 0) {
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).body(response);
} else {
return ResponseEntity.ok(response);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# 3.2 多文件上传性能优化
当处理大量文件上传时,可能会遇到性能问题。以下是一些优化技巧:
- 使用异步处理:对大量文件使用异步任务处理,避免阻塞主线程
- 批量插入数据库:如需记录文件信息,使用批量插入而非逐条插入
- 文件分类存储:根据文件类型或上传日期分目录存储,提高文件系统效率
/**
* 异步多文件上传处理端点
* 通过异步方式处理多文件上传,提高并发性能
*
* @param files 前端上传的文件列表
* @return 任务ID,用于后续查询处理结果
*/
@PostMapping("/upload-multiple-async")
public ResponseEntity<Map<String, Object>> uploadMultipleFilesAsync(
@RequestParam("files") List<MultipartFile> files) {
Map<String, Object> response = new HashMap<>();
// 检查参数有效性
if (files.isEmpty()) {
response.put("success", false);
response.put("message", "未选择任何文件");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
// 生成唯一任务ID
String taskId = UUID.randomUUID().toString();
// 启动异步任务处理文件
CompletableFuture.runAsync(() -> {
processFilesAsync(files, taskId);
});
// 立即返回任务ID,不等待处理完成
response.put("success", true);
response.put("message", "文件上传任务已提交");
response.put("taskId", taskId);
response.put("totalFiles", files.size());
return ResponseEntity.accepted().body(response);
}
/**
* 异步处理文件上传的内部方法
*
* @param files 文件列表
* @param taskId 任务ID
*/
private void processFilesAsync(List<MultipartFile> files, String taskId) {
// 创建任务结果对象
Map<String, Object> taskResult = new HashMap<>();
List<Map<String, Object>> fileResults = new ArrayList<>();
// 根据日期创建子目录,分类存储文件
String dateFolder = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
String uploadDir = "C:/uploads/" + dateFolder + "/";
File dirObj = new File(uploadDir);
if (!dirObj.exists()) {
dirObj.mkdirs();
}
// 处理统计
int successCount = 0;
int failureCount = 0;
// 并行处理所有文件
List<Map<String, Object>> results = files.parallelStream().map(file -> {
Map<String, Object> result = new HashMap<>();
String originalName = file.getOriginalFilename();
result.put("originalFileName", originalName);
if (file.isEmpty()) {
result.put("success", false);
result.put("message", "文件为空");
return result;
}
try {
// 根据文件类型创建子目录
String contentType = file.getContentType();
String typeFolder = "other";
if (contentType != null) {
if (contentType.startsWith("image/")) {
typeFolder = "images";
} else if (contentType.startsWith("video/")) {
typeFolder = "videos";
} else if (contentType.startsWith("audio/")) {
typeFolder = "audios";
} else if (contentType.contains("pdf")) {
typeFolder = "documents";
}
}
// 创建类型子目录
String typePath = uploadDir + typeFolder + "/";
new File(typePath).mkdirs();
// 生成唯一文件名
String uniqueFileName = UUID.randomUUID().toString() + "_" +
(originalName != null ? originalName : "unknown");
// 保存文件
File destFile = new File(typePath, uniqueFileName);
file.transferTo(destFile);
// 记录成功信息
result.put("success", true);
result.put("message", "上传成功");
result.put("fileType", typeFolder);
result.put("savedPath", destFile.getAbsolutePath());
return result;
} catch (IOException e) {
result.put("success", false);
result.put("message", "上传失败: " + e.getMessage());
return result;
}
}).collect(Collectors.toList());
// 统计处理结果
for (Map<String, Object> result : results) {
if ((Boolean) result.get("success")) {
successCount++;
} else {
failureCount++;
}
}
// 保存任务结果
taskResult.put("taskId", taskId);
taskResult.put("totalFiles", files.size());
taskResult.put("successCount", successCount);
taskResult.put("failureCount", failureCount);
taskResult.put("files", results);
taskResult.put("completedAt", new Date());
// 在实际应用中,这里可以将结果保存到数据库或缓存
System.out.println("异步任务 " + taskId + " 完成,成功: " +
successCount + ", 失败: " + failureCount);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# 四、大文件分片上传实现
# 4.1 分片上传原理与实现
对于大文件上传,单次请求可能会因为网络问题、服务器超时等原因而失败。分片上传将大文件分割成多个小片段分别上传,然后在服务器端合并,可大幅提高上传成功率,同时支持断点续传。
/**
* 大文件分片上传控制器
* 实现大文件的分片上传、分片验证和文件合并功能
*/
@RestController
@RequestMapping("/api/chunked")
public class ChunkedUploadController {
/**
* 接收并保存文件分片
*
* @param chunk 文件分片数据
* @param chunkIndex 分片索引,从0开始
* @param totalChunks 总分片数
* @param fileName 原始文件名
* @param identifier 文件唯一标识符,通常是文件内容的哈希值
* @return 分片上传结果
*/
@PostMapping("/upload-chunk")
public ResponseEntity<Map<String, Object>> uploadChunk(
@RequestParam("chunk") MultipartFile chunk,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("fileName") String fileName,
@RequestParam("identifier") String identifier) {
Map<String, Object> response = new HashMap<>();
// 验证参数
if (chunk.isEmpty() || chunkIndex < 0 || totalChunks <= 0) {
response.put("success", false);
response.put("message", "无效的请求参数");
return ResponseEntity.badRequest().body(response);
}
try {
// 创建分片存储目录
String chunkDir = "C:/uploads/chunks/" + identifier + "/";
File dir = new File(chunkDir);
if (!dir.exists()) {
boolean created = dir.mkdirs();
if (!created) {
throw new IOException("无法创建分片存储目录: " + chunkDir);
}
}
// 分片文件命名格式: [原始文件名].[分片索引].[总分片数].part
String chunkFileName = String.format("%s.%d.%d.part",
fileName, chunkIndex, totalChunks);
File chunkFile = new File(dir, chunkFileName);
// 保存分片文件
chunk.transferTo(chunkFile);
// 返回成功响应
response.put("success", true);
response.put("message", "分片上传成功");
response.put("chunkIndex", chunkIndex);
response.put("totalChunks", totalChunks);
response.put("identifier", identifier);
return ResponseEntity.ok(response);
} catch (IOException e) {
e.printStackTrace();
response.put("success", false);
response.put("message", "分片上传失败: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
/**
* 检查分片上传状态
* 用于断点续传,客户端可以查询已上传的分片,避免重复上传
*
* @param identifier 文件唯一标识符
* @param fileName 原始文件名
* @param totalChunks 总分片数
* @return 包含已上传分片索引的列表
*/
@GetMapping("/check-chunks")
public ResponseEntity<Map<String, Object>> checkChunks(
@RequestParam("identifier") String identifier,
@RequestParam("fileName") String fileName,
@RequestParam("totalChunks") int totalChunks) {
Map<String, Object> response = new HashMap<>();
List<Integer> uploadedChunks = new ArrayList<>();
// 构建分片目录路径
String chunkDir = "C:/uploads/chunks/" + identifier + "/";
File dir = new File(chunkDir);
// 检查目录是否存在
if (!dir.exists()) {
// 目录不存在,表示没有任何分片上传
response.put("success", true);
response.put("uploadedChunks", uploadedChunks);
return ResponseEntity.ok(response);
}
// 获取目录下所有文件
File[] files = dir.listFiles();
if (files != null) {
// 遍历文件,找出已上传的分片
for (File file : files) {
String name = file.getName();
// 解析文件名,提取分片索引
if (name.startsWith(fileName) && name.endsWith(".part")) {
try {
// 文件名格式: [原始文件名].[分片索引].[总分片数].part
String[] parts = name.split("\\.");
if (parts.length >= 4) {
int chunkIndex = Integer.parseInt(parts[parts.length - 3]);
uploadedChunks.add(chunkIndex);
}
} catch (NumberFormatException e) {
// 忽略无效的文件名
}
}
}
}
// 构建响应
response.put("success", true);
response.put("identifier", identifier);
response.put("fileName", fileName);
response.put("totalChunks", totalChunks);
response.put("uploadedChunks", uploadedChunks);
return ResponseEntity.ok(response);
}
/**
* 合并所有分片,生成完整文件
*
* @param identifier 文件唯一标识符
* @param fileName 原始文件名
* @param totalChunks 总分片数
* @return 文件合并结果
*/
@PostMapping("/merge-chunks")
public ResponseEntity<Map<String, Object>> mergeChunks(
@RequestParam("identifier") String identifier,
@RequestParam("fileName") String fileName,
@RequestParam("totalChunks") int totalChunks) {
Map<String, Object> response = new HashMap<>();
try {
// 构建分片目录路径
String chunkDir = "C:/uploads/chunks/" + identifier + "/";
File dir = new File(chunkDir);
// 检查目录是否存在
if (!dir.exists() || !dir.isDirectory()) {
response.put("success", false);
response.put("message", "分片目录不存在,无法合并文件");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
// 检查所有分片是否都已上传
boolean allChunksExist = true;
for (int i = 0; i < totalChunks; i++) {
String chunkFileName = String.format("%s.%d.%d.part", fileName, i, totalChunks);
File chunkFile = new File(dir, chunkFileName);
if (!chunkFile.exists()) {
allChunksExist = false;
response.put("success", false);
response.put("message", "缺少分片 " + i + ",无法合并文件");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
}
// 创建目标文件目录
String dateFolder = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
String mergedDir = "C:/uploads/merged/" + dateFolder + "/";
File mergedDirObj = new File(mergedDir);
if (!mergedDirObj.exists()) {
boolean created = mergedDirObj.mkdirs();
if (!created) {
throw new IOException("无法创建合并文件目录: " + mergedDir);
}
}
// 生成唯一文件名,避免冲突
String uniqueFileName = UUID.randomUUID().toString() + "_" + fileName;
File mergedFile = new File(mergedDir, uniqueFileName);
// 创建输出流,合并所有分片
try (FileOutputStream fos = new FileOutputStream(mergedFile)) {
for (int i = 0; i < totalChunks; i++) {
String chunkFileName = String.format("%s.%d.%d.part", fileName, i, totalChunks);
File chunkFile = new File(dir, chunkFileName);
// 将分片内容写入合并文件
try (FileInputStream fis = new FileInputStream(chunkFile)) {
byte[] buffer = new byte[1024 * 1024]; // 1MB 缓冲区
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
}
}
}
// 合并完成后,可以选择删除分片文件
if (mergedFile.exists() && mergedFile.length() > 0) {
// 删除分片文件(可选)
for (int i = 0; i < totalChunks; i++) {
String chunkFileName = String.format("%s.%d.%d.part", fileName, i, totalChunks);
File chunkFile = new File(dir, chunkFileName);
chunkFile.delete();
}
// 删除分片目录(可选)
dir.delete();
}
// 构建成功响应
response.put("success", true);
response.put("message", "文件合并成功");
response.put("originalFileName", fileName);
response.put("mergedFileName", uniqueFileName);
response.put("filePath", mergedFile.getAbsolutePath());
response.put("fileSize", mergedFile.length());
return ResponseEntity.ok(response);
} catch (IOException e) {
e.printStackTrace();
response.put("success", false);
response.put("message", "文件合并失败: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# 4.2 分片上传优化与断点续传
分片上传的性能和可靠性可以通过以下方式优化:
- 断点续传的实现:记录已上传的分片,允许中断后从断点处继续
- 并行上传:前端同时上传多个分片,提高上传速度
- 动态分片大小:根据网络状况自动调整分片大小
- 秒传功能:通过文件指纹判断文件是否已存在,避免重复上传
# 五、文件上传本地访问配置
# 5.1 静态资源映射配置
为了让上传的文件可以通过HTTP访问,需要在Spring Boot中配置静态资源映射:
/**
* Web MVC配置类
* 配置静态资源映射,使上传的文件可通过URL访问
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
/**
* 文件上传目录,从配置文件中读取
*/
@Value("${file.upload-dir}")
private String uploadDir;
/**
* 配置静态资源处理器,将URL路径映射到文件系统目录
*
* @param registry 资源处理器注册表
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 主要资源映射配置
registry.addResourceHandler("/file/**") // URL路径格式:/file/xxx.jpg
.addResourceLocations("file:" + uploadDir + "/") // 实际文件系统路径
.setCachePeriod(3600) // 客户端缓存时间(秒)
.setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)) // HTTP缓存控制
.resourceChain(true) // 启用资源链,支持版本资源和转换器
.addResolver(new PathResourceResolver() {
/**
* 自定义资源解析规则
* 可以在这里添加安全限制,如只允许特定类型的文件访问
*/
@Override
protected Resource resolveResourceInternal(
HttpServletRequest request, String requestPath,
List<? extends Resource> locations, ResourceResolverChain chain) {
// 安全检查:只允许访问特定类型的文件
// 通过后缀名过滤,避免敏感文件泄露
if (requestPath.matches(".*(\\.(html|js|css|jsp))$")) {
return null; // 不允许直接访问这些类型的文件
}
// 调用父类方法继续解析资源
return super.resolveResourceInternal(request, requestPath, locations, chain);
}
});
// 额外的资源映射配置,可以根据需要添加
// 例如,为头像文件配置特定的URL路径和资源位置
registry.addResourceHandler("/avatars/**")
.addResourceLocations("file:" + uploadDir + "/avatars/")
.setCacheControl(CacheControl.maxAge(7, TimeUnit.DAYS));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# 5.2 环境特定配置文件
根据不同的运行环境配置不同的文件存储路径:
application.yml(通用配置):
# 文件上传基本配置
spring:
servlet:
multipart:
enabled: true # 启用文件上传
max-file-size: 50MB # 单个文件最大大小
max-request-size: 100MB # 单次请求最大大小(含所有文件)
file-size-threshold: 2MB # 超过此大小的文件将写入磁盘而非内存
# 服务器基本配置
server:
port: 8080
servlet:
context-path: /api
# 文件上传访问URL配置
file:
# 使用占位符,在特定环境配置中覆盖
upload-dir: ${FILE_UPLOAD_DIR:./uploads}
# 服务器基础URL,用于构建完整文件访问路径
server-url: ${SERVER_URL:http://localhost:8080}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
application-dev.yml(开发环境配置):
# 开发环境特定的文件配置
file:
upload-dir: D:/develop/uploads
server-url: http://localhost:8080
2
3
4
application-prod.yml(生产环境配置):
# 生产环境特定的文件配置
file:
upload-dir: /data/appfiles/uploads
server-url: https://api.yourapp.com
2
3
4
# 5.3 文件上传工具类
集成上述功能的完整文件上传工具类:
/**
* 文件上传工具类
* 提供各种文件上传功能,支持单文件上传、多文件上传和Base64编码文件上传
*/
@Component
public class FileUploadUtil {
private static final Logger logger = LoggerFactory.getLogger(FileUploadUtil.class);
/**
* 上传目录的物理路径,通过配置文件注入
*/
@Value("${file.upload-dir}")
private String uploadDir;
/**
* 服务器基础URL,用于构建文件访问路径
*/
@Value("${file.server-url}")
private String serverUrl;
/**
* 单文件上传方法
*
* @param file 待上传的文件对象
* @param subDir 子目录名称(可选),例如"images"、"documents"等
* @return 上传结果信息,包含文件存储路径和访问URL
*/
public Map<String, Object> uploadFile(MultipartFile file, String subDir) {
Map<String, Object> result = new HashMap<>();
// 文件有效性检查
if (file == null || file.isEmpty()) {
result.put("success", false);
result.put("message", "文件为空,无法上传");
return result;
}
try {
// 获取原始文件名
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
originalFilename = "unknown_file";
}
// 提取文件扩展名
String fileExtension = "";
int lastDotIndex = originalFilename.lastIndexOf(".");
if (lastDotIndex > 0) {
fileExtension = originalFilename.substring(lastDotIndex);
}
// 生成唯一文件名,避免文件名冲突
String uniqueFileName = UUID.randomUUID().toString() + fileExtension;
// 构建日期目录结构,按年月日归类文件
String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date());
// 构建完整的文件存储路径
String fullDir = uploadDir;
if (subDir != null && !subDir.isEmpty()) {
fullDir = Paths.get(uploadDir, subDir, datePath).toString();
} else {
fullDir = Paths.get(uploadDir, datePath).toString();
}
// 确保目录存在
File directory = new File(fullDir);
if (!directory.exists()) {
if (!directory.mkdirs()) {
result.put("success", false);
result.put("message", "无法创建目标目录: " + fullDir);
return result;
}
}
// 构建完整的文件路径
String filePath = Paths.get(fullDir, uniqueFileName).toString();
File destFile = new File(filePath);
// 保存文件
file.transferTo(destFile);
// 构建公开访问URL
String publicPath = subDir != null
? String.format("/file/%s/%s/%s", subDir, datePath, uniqueFileName)
: String.format("/file/%s/%s", datePath, uniqueFileName);
String publicUrl = serverUrl + publicPath.replace("\\", "/");
// 构建成功响应
result.put("success", true);
result.put("message", "文件上传成功");
result.put("originalFilename", originalFilename);
result.put("fileName", uniqueFileName);
result.put("filePath", filePath);
result.put("fileSize", file.getSize());
result.put("contentType", file.getContentType());
result.put("publicUrl", publicUrl);
return result;
} catch (IOException e) {
logger.error("文件上传失败", e);
result.put("success", false);
result.put("message", "文件上传失败: " + e.getMessage());
return result;
}
}
/**
* 多文件上传方法
*
* @param files 待上传的文件列表
* @param subDir 子目录名称(可选)
* @return 每个文件的上传结果列表
*/
public List<Map<String, Object>> uploadMultipleFiles(List<MultipartFile> files, String subDir) {
return files.stream()
.map(file -> uploadFile(file, subDir))
.collect(Collectors.toList());
}
/**
* Base64编码文件上传
* 适用于前端直接传输Base64编码的图片数据
*
* @param base64Data Base64编码的文件数据
* @param fileName 文件名(可选)
* @param fileType 文件类型,例如"png","jpg"等
* @param subDir 子目录名称(可选)
* @return 上传结果信息
*/
public Map<String, Object> uploadBase64File(String base64Data, String fileName,
String fileType, String subDir) {
Map<String, Object> result = new HashMap<>();
// 参数检查
if (base64Data == null || base64Data.isEmpty()) {
result.put("success", false);
result.put("message", "Base64数据为空");
return result;
}
try {
// 处理Base64数据,移除可能存在的前缀
String base64Content = base64Data;
if (base64Data.contains(",")) {
base64Content = base64Data.split(",")[1];
}
// 解码Base64数据为字节数组
byte[] fileBytes = Base64.getDecoder().decode(base64Content);
// 如果未指定文件类型,尝试从Base64前缀推断
if (fileType == null || fileType.isEmpty()) {
if (base64Data.startsWith("data:image/png;")) {
fileType = "png";
} else if (base64Data.startsWith("data:image/jpeg;")) {
fileType = "jpg";
} else if (base64Data.startsWith("data:image/gif;")) {
fileType = "gif";
} else {
fileType = "bin"; // 默认二进制文件
}
}
// 构建文件名
String finalFileName;
if (fileName != null && !fileName.isEmpty()) {
finalFileName = fileName;
// 确保文件名有正确的扩展名
if (!finalFileName.endsWith("." + fileType)) {
finalFileName += "." + fileType;
}
} else {
finalFileName = UUID.randomUUID().toString() + "." + fileType;
}
// 构建日期目录结构
String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date());
// 构建完整目录路径
String fullDir = uploadDir;
if (subDir != null && !subDir.isEmpty()) {
fullDir = Paths.get(uploadDir, subDir, datePath).toString();
} else {
fullDir = Paths.get(uploadDir, datePath).toString();
}
// 确保目录存在
File directory = new File(fullDir);
if (!directory.exists()) {
if (!directory.mkdirs()) {
result.put("success", false);
result.put("message", "无法创建目标目录: " + fullDir);
return result;
}
}
// 构建完整文件路径
String filePath = Paths.get(fullDir, finalFileName).toString();
File destFile = new File(filePath);
// 将数据写入文件
try (FileOutputStream fos = new FileOutputStream(destFile)) {
fos.write(fileBytes);
}
// 构建公开访问URL
String publicPath = subDir != null
? String.format("/file/%s/%s/%s", subDir, datePath, finalFileName)
: String.format("/file/%s/%s", datePath, finalFileName);
String publicUrl = serverUrl + publicPath.replace("\\", "/");
// 构建成功响应
result.put("success", true);
result.put("message", "Base64文件上传成功");
result.put("fileName", finalFileName);
result.put("filePath", filePath);
result.put("fileSize", fileBytes.length);
result.put("publicUrl", publicUrl);
return result;
} catch (IllegalArgumentException e) {
logger.error("Base64解码失败", e);
result.put("success", false);
result.put("message", "Base64解码失败: " + e.getMessage());
return result;
} catch (IOException e) {
logger.error("文件写入失败", e);
result.put("success", false);
result.put("message", "文件写入失败: " + e.getMessage());
return result;
}
}
/**
* 删除已上传的文件
*
* @param filePath 文件的完整路径
* @return 删除结果
*/
public boolean deleteFile(String filePath) {
try {
File file = new File(filePath);
if (file.exists()) {
return file.delete();
}
return false;
} catch (Exception e) {
logger.error("文件删除失败", e);
return false;
}
}
/**
* 根据URL路径删除文件
*
* @param fileUrl 文件的访问URL
* @return 删除结果
*/
public boolean deleteFileByUrl(String fileUrl) {
// 从URL中提取相对路径
String relativePath = fileUrl.replace(serverUrl + "/file/", "");
String fullPath = Paths.get(uploadDir, relativePath).toString();
return deleteFile(fullPath);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# 六、文件上传安全性处理
# 6.1 文件类型验证与大小限制
为了防止上传恶意文件,需要实现文件类型验证和大小限制:
/**
* 文件类型验证器
* 检查上传文件的类型和大小是否符合安全要求
*/
@Component
public class FileValidator {
// 允许的文件类型集合
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
"image/jpeg", "image/png", "image/gif", "image/webp"
));
private static final Set<String> ALLOWED_DOCUMENT_TYPES = new HashSet<>(Arrays.asList(
"application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/plain"
));
/**
* 验证图片文件
*
* @param file 待验证的文件
* @param maxSizeBytes 最大允许大小(字节)
* @return 验证结果,包含成功/失败状态和消息
*/
public Map<String, Object> validateImageFile(MultipartFile file, long maxSizeBytes) {
Map<String, Object> result = new HashMap<>();
// 检查文件是否为空
if (file.isEmpty()) {
result.put("valid", false);
result.put("message", "文件为空");
return result;
}
// 检查文件大小
if (file.getSize() > maxSizeBytes) {
result.put("valid", false);
result.put("message", "文件大小超出限制: "
+ formatFileSize(file.getSize()) + " > "
+ formatFileSize(maxSizeBytes));
return result;
}
// 检查文件类型
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType.toLowerCase())) {
result.put("valid", false);
result.put("message", "不支持的图片文件类型: " + contentType);
return result;
}
// 进一步验证文件内容(魔术数字检查)
try (InputStream is = file.getInputStream()) {
byte[] magicBytes = new byte[8]; // 读取文件头部8个字节
int read = is.read(magicBytes);
if (read < 2) {
result.put("valid", false);
result.put("message", "无效的图片文件");
return result;
}
// 检查文件头部特征
boolean validHeader = false;
// JPEG: FF D8 FF
if (magicBytes[0] == (byte) 0xFF && magicBytes[1] == (byte) 0xD8 && magicBytes[2] == (byte) 0xFF) {
validHeader = true;
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
else if (magicBytes[0] == (byte) 0x89 && magicBytes[1] == (byte) 0x50
&& magicBytes[2] == (byte) 0x4E && magicBytes[3] == (byte) 0x47) {
validHeader = true;
}
// GIF: 47 49 46 38
else if (magicBytes[0] == (byte) 0x47 && magicBytes[1] == (byte) 0x49
&& magicBytes[2] == (byte) 0x46 && magicBytes[3] == (byte) 0x38) {
validHeader = true;
}
if (!validHeader) {
result.put("valid", false);
result.put("message", "文件内容与声明的类型不匹配");
return result;
}
} catch (IOException e) {
result.put("valid", false);
result.put("message", "文件验证失败: " + e.getMessage());
return result;
}
// 所有检查通过
result.put("valid", true);
result.put("message", "文件验证通过");
return result;
}
/**
* 验证文档文件
*
* @param file 待验证的文件
* @param maxSizeBytes 最大允许大小(字节)
* @return 验证结果
*/
public Map<String, Object> validateDocumentFile(MultipartFile file, long maxSizeBytes) {
Map<String, Object> result = new HashMap<>();
// 检查文件是否为空
if (file.isEmpty()) {
result.put("valid", false);
result.put("message", "文件为空");
return result;
}
// 检查文件大小
if (file.getSize() > maxSizeBytes) {
result.put("valid", false);
result.put("message", "文件大小超出限制: "
+ formatFileSize(file.getSize()) + " > "
+ formatFileSize(maxSizeBytes));
return result;
}
// 检查文件类型
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_DOCUMENT_TYPES.contains(contentType.toLowerCase())) {
result.put("valid", false);
result.put("message", "不支持的文档文件类型: " + contentType);
return result;
}
// 所有检查通过
result.put("valid", true);
result.put("message", "文件验证通过");
return result;
}
/**
* 格式化文件大小
*
* @param sizeInBytes 文件大小(字节)
* @return 格式化后的文件大小字符串
*/
private String formatFileSize(long sizeInBytes) {
if (sizeInBytes < 1024) {
return sizeInBytes + " B";
} else if (sizeInBytes < 1024 * 1024) {
return String.format("%.2f KB", sizeInBytes / 1024.0);
} else if (sizeInBytes < 1024 * 1024 * 1024) {
return String.format("%.2f MB", sizeInBytes / (1024.0 * 1024));
} else {
return String.format("%.2f GB", sizeInBytes / (1024.0 * 1024 * 1024));
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# 6.2 防止恶意文件上传的安全措施
以下是防止恶意文件上传的关键安全措施:
- 文件内容验证:检查文件内容与声明的MIME类型是否匹配
- 禁止执行权限:确保上传目录中的文件没有执行权限
- 文件存储与访问分离:将文件存储在Web根目录之外,通过特定控制器提供访问
- 使用防病毒扫描:对上传的文件进行病毒扫描
/**
* 文件上传安全配置类
* 配置文件上传相关的安全策略
*/
@Configuration
public class FileUploadSecurityConfig {
@Value("${file.upload-dir}")
private String uploadDir;
/**
* 配置上传目录安全选项
* 在应用启动时执行
*/
@PostConstruct
public void configureUploadDirectory() {
File directory = new File(uploadDir);
// 确保目录存在
if (!directory.exists()) {
boolean created = directory.mkdirs();
if (!created) {
throw new RuntimeException("无法创建文件上传目录: " + uploadDir);
}
}
// 确保目录权限设置正确
// 注意: 这部分在Windows系统上可能没有效果
try {
// 设置目录权限为755 (所有者可读写执行,组用户和其他用户可读和执行)
Set<PosixFilePermission> permissions = new HashSet<>();
permissions.add(PosixFilePermission.OWNER_READ);
permissions.add(PosixFilePermission.OWNER_WRITE);
permissions.add(PosixFilePermission.OWNER_EXECUTE);
permissions.add(PosixFilePermission.GROUP_READ);
permissions.add(PosixFilePermission.GROUP_EXECUTE);
permissions.add(PosixFilePermission.OTHERS_READ);
permissions.add(PosixFilePermission.OTHERS_EXECUTE);
try {
Files.setPosixFilePermissions(directory.toPath(), permissions);
} catch (UnsupportedOperationException e) {
// 在不支持POSIX文件权限的系统上(如Windows)忽略此操作
System.out.println("当前系统不支持POSIX文件权限: " + e.getMessage());
}
} catch (IOException e) {
throw new RuntimeException("无法设置文件上传目录权限: " + e.getMessage());
}
}
/**
* 创建文件上传拦截器
* 用于增强基于Spring Security的文件上传安全性
*/
@Bean
public FilterRegistrationBean<Filter> fileUploadSecurityFilter() {
FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 只拦截文件上传请求
if (request.getContentType() != null &&
request.getContentType().startsWith("multipart/form-data")) {
// 检查上传频率限制(防止DoS攻击)
String clientIp = request.getRemoteAddr();
// 这里可以实现IP-based的上传频率限制
// 添加安全响应头
response.setHeader("X-Content-Type-Options", "nosniff");
}
// 继续过滤链
filterChain.doFilter(request, response);
}
});
// 应用于所有上传路径
registrationBean.addUrlPatterns("/api/file/upload/*", "/api/chunked/*");
registrationBean.setOrder(1);
return registrationBean;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# 七、文件上传集成与应用场景
# 7.1 用户头像上传实现
/**
* 用户头像上传控制器
* 提供用户头像上传、查看和更新功能
*/
@RestController
@RequestMapping("/api/user")
public class UserAvatarController {
@Autowired
private FileUploadUtil fileUploadUtil;
@Autowired
private FileValidator fileValidator;
/**
* 上传用户头像
* 支持图片文件和Base64编码图片数据
*
* @param userId 用户ID
* @param file 头像图片文件(可选)
* @param base64Data Base64编码的图片数据(可选)
* @return 头像上传结果
*/
@PostMapping("/{userId}/avatar")
public ResponseEntity<Map<String, Object>> uploadAvatar(
@PathVariable String userId,
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam(value = "base64", required = false) String base64Data) {
Map<String, Object> response = new HashMap<>();
try {
// 检查是文件上传还是Base64上传
if (file != null && !file.isEmpty()) {
// 验证文件类型和大小(最大限制5MB)
Map<String, Object> validationResult =
fileValidator.validateImageFile(file, 5 * 1024 * 1024);
if (!(boolean) validationResult.get("valid")) {
response.put("success", false);
response.put("message", validationResult.get("message"));
return ResponseEntity.badRequest().body(response);
}
// 上传文件到指定目录
Map<String, Object> uploadResult =
fileUploadUtil.uploadFile(file, "avatars/" + userId);
// 更新用户头像URL(在实际应用中应该保存到用户记录)
// userService.updateAvatarUrl(userId, (String) uploadResult.get("publicUrl"));
return ResponseEntity.ok(uploadResult);
} else if (base64Data != null && !base64Data.isEmpty()) {
// 使用Base64数据上传
Map<String, Object> uploadResult = fileUploadUtil.uploadBase64File(
base64Data, "avatar", "png", "avatars/" + userId);
// 更新用户头像URL(在实际应用中应该保存到用户记录)
// userService.updateAvatarUrl(userId, (String) uploadResult.get("publicUrl"));
return ResponseEntity.ok(uploadResult);
} else {
response.put("success", false);
response.put("message", "未提供有效的头像数据,请上传文件或提供Base64编码数据");
return ResponseEntity.badRequest().body(response);
}
} catch (Exception e) {
e.printStackTrace();
response.put("success", false);
response.put("message", "头像上传失败: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# 7.2 产品图片批量上传
/**
* 产品图片上传控制器
* 提供产品图片的单张和批量上传功能
*/
@RestController
@RequestMapping("/api/products")
public class ProductImageController {
@Autowired
private FileUploadUtil fileUploadUtil;
@Autowired
private FileValidator fileValidator;
/**
* 产品图片批量上传
*
* @param productId 产品ID
* @param files 图片文件列表
* @return 批量上传结果
*/
@PostMapping("/{productId}/images")
public ResponseEntity<Map<String, Object>> uploadProductImages(
@PathVariable String productId,
@RequestParam("files") List<MultipartFile> files) {
Map<String, Object> response = new HashMap<>();
// 检查文件列表
if (files.isEmpty()) {
response.put("success", false);
response.put("message", "没有选择图片文件");
return ResponseEntity.badRequest().body(response);
}
// 限制上传数量
if (files.size() > 10) {
response.put("success", false);
response.put("message", "一次最多只能上传10张产品图片");
return ResponseEntity.badRequest().body(response);
}
List<Map<String, Object>> uploadResults = new ArrayList<>();
boolean hasErrors = false;
// 处理每个图片文件
for (MultipartFile file : files) {
// 验证图片文件(最大8MB)
Map<String, Object> validationResult =
fileValidator.validateImageFile(file, 8 * 1024 * 1024);
if (!(boolean) validationResult.get("valid")) {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("success", false);
errorResult.put("originalFilename", file.getOriginalFilename());
errorResult.put("message", validationResult.get("message"));
uploadResults.add(errorResult);
hasErrors = true;
continue;
}
// 上传图片
String uploadPath = "products/" + productId + "/images";
Map<String, Object> result = fileUploadUtil.uploadFile(file, uploadPath);
uploadResults.add(result);
if (!(boolean) result.get("success")) {
hasErrors = true;
}
}
// 构建响应
response.put("success", !hasErrors);
response.put("message", hasErrors ? "部分图片上传失败" : "所有图片上传成功");
response.put("totalFiles", files.size());
response.put("results", uploadResults);
return ResponseEntity.ok(response);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# 7.3 大文件视频上传
/**
* 视频文件上传控制器
* 提供视频文件上传和分片上传功能
*/
@RestController
@RequestMapping("/api/videos")
public class VideoUploadController {
@Autowired
private ChunkedUploadController chunkedUploadController;
/**
* 初始化视频上传
* 返回上传参数和分片大小建议
*
* @param fileName 视频文件名
* @param fileSize 视频文件大小(字节)
* @return 上传参数
*/
@PostMapping("/init-upload")
public ResponseEntity<Map<String, Object>> initVideoUpload(
@RequestParam("fileName") String fileName,
@RequestParam("fileSize") long fileSize) {
Map<String, Object> response = new HashMap<>();
// 生成文件唯一标识符
String identifier = UUID.randomUUID().toString();
// 根据文件大小计算推荐的分片大小
int chunkSize = calculateOptimalChunkSize(fileSize);
// 计算总分片数
int totalChunks = (int) Math.ceil((double) fileSize / chunkSize);
// 构建响应
response.put("success", true);
response.put("identifier", identifier);
response.put("fileName", fileName);
response.put("fileSize", fileSize);
response.put("chunkSize", chunkSize);
response.put("totalChunks", totalChunks);
return ResponseEntity.ok(response);
}
/**
* 检查视频处理状态
*
* @param identifier 视频文件标识符
* @return 处理状态
*/
@GetMapping("/status/{identifier}")
public ResponseEntity<Map<String, Object>> checkVideoStatus(
@PathVariable String identifier) {
Map<String, Object> response = new HashMap<>();
// 检查视频处理状态
// 这里应该查询实际的处理状态,例如转码状态等
response.put("success", true);
response.put("identifier", identifier);
response.put("status", "processing"); // 可能的状态:pending, processing, completed, failed
response.put("progress", 45); // 进度百分比
return ResponseEntity.ok(response);
}
/**
* 计算最优分片大小
* 根据文件大小返回合适的分片大小
*
* @param fileSize 文件总大小(字节)
* @return 最优分片大小(字节)
*/
private int calculateOptimalChunkSize(long fileSize) {
// 默认分片大小2MB
int defaultChunkSize = 2 * 1024 * 1024;
if (fileSize <= 20 * 1024 * 1024) {
// 小于20MB的文件,使用2MB分片
return defaultChunkSize;
} else if (fileSize <= 100 * 1024 * 1024) {
// 20MB-100MB的文件,使用5MB分片
return 5 * 1024 * 1024;
} else if (fileSize <= 1024 * 1024 * 1024) {
// 100MB-1GB的文件,使用10MB分片
return 10 * 1024 * 1024;
} else {
// 大于1GB的文件,使用20MB分片
return 20 * 1024 * 1024;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# 八、总结与最佳实践
# 8.1 文件上传方案选择指南
上传场景 | 推荐方案 | 建议配置 |
---|---|---|
小文件上传(<2MB) | 单文件上传 | 使用简单验证,不分块,直接上传保存 |
中型文件(2MB-50MB) | 单文件上传+进度条 | 配置适当超时时间,前端添加进度反馈 |
多文件批量上传 | 多文件上传+并发限制 | 限制批量上传数量,使用队列管理 |
大文件上传(>50MB) | 分片上传+断点续传 | 适当分片大小,添加上传恢复机制 |
敏感文件上传 | 加密上传+访问控制 | 添加权限验证,实现文件加密存储 |
# 8.2 性能优化建议
- 配置合理的超时时间:避免大文件上传超时
- 使用异步处理:上传完成后的处理工作放入异步队列
- 实现智能分片:根据网络条件和文件大小动态调整分片大小
- 使用并行上传:前端同时上传多个分片提高效率
- 实现文件指纹校验:通过文件哈希避免重复上传相同文件
# 8.3 安全性建议
- 严格限制文件类型:只允许上传预期的文件类型
- 验证文件内容:检查文件内容与声明的MIME类型是否匹配
- 限制文件大小:防止超大文件导致的DoS攻击
- 使用安全的存储路径:避免文件存储在可执行目录
- 实现上传频率限制:防止批量上传攻击
- 定期清理临时文件:设置定时任务清理未完成的上传临时文件