SpringBoot文件上传全攻略:从原理到生产级实战
-
- [一、基础原理------multipart/form-data 与 SpringBoot 的初步处理](#一、基础原理——multipart/form-data 与 SpringBoot 的初步处理)
-
- [1.1 为什么JSON不适合传输文件?](#1.1 为什么JSON不适合传输文件?)
- [1.2 multipart/form-data:专为二进制设计的"集装箱"](#1.2 multipart/form-data:专为二进制设计的“集装箱”)
- [1.3 SpringBoot的"智能拆箱"](#1.3 SpringBoot的“智能拆箱”)
- 二、前端实战------从原生到框架的文件打包
-
- [2.1 核心主角:FormData对象](#2.1 核心主角:FormData对象)
- [2.2 三种打包方案对比](#2.2 三种打包方案对比)
-
- [2.2.1 传统HTML表单(石器时代)](#2.2.1 传统HTML表单(石器时代))
- [2.2.2 原生JavaScript + Fetch(工业时代)](#2.2.2 原生JavaScript + Fetch(工业时代))
- [2.2.3 Vue 3 + TypeScript(现代时代)](#2.2.3 Vue 3 + TypeScript(现代时代))
- [2.3 避坑指南](#2.3 避坑指南)
- 三、后端接收------MultipartFile源码解析与参数辨析
-
- [3.1 什么是MultipartFile接口?为什么需要它?如何使用?](#3.1 什么是MultipartFile接口?为什么需要它?如何使用?)
-
- [3.1.1 什么是MultipartFile?](#3.1.1 什么是MultipartFile?)
- [3.1.2 为什么需要MultipartFile?](#3.1.2 为什么需要MultipartFile?)
- [3.1.3 如何使用MultipartFile?](#3.1.3 如何使用MultipartFile?)
- [3.2 Controller接收基础详解](#3.2 Controller接收基础详解)
- [3.3 MultipartFile接口源码深度剖析](#3.3 MultipartFile接口源码深度剖析)
- [3.4 安全落盘与重命名策略](#3.4 安全落盘与重命名策略)
- [3.5 配置文件扩容](#3.5 配置文件扩容)
- [3.6 @RequestParam vs @RequestPart](#3.6 @RequestParam vs @RequestPart)
- [3.7 集成MinIO的注意事项](#3.7 集成MinIO的注意事项)
- 四、生产级防护------异常处理、安全校验与用户体验
-
- [4.1 优雅的全局异常拦截](#4.1 优雅的全局异常拦截)
-
- [4.1.1 捕获MaxUploadSizeExceededException](#4.1.1 捕获MaxUploadSizeExceededException)
- [4.1.2 动态获取配置信息](#4.1.2 动态获取配置信息)
- [4.2 深度安全校验:文件魔数(Magic Number)](#4.2 深度安全校验:文件魔数(Magic Number))
- [4.3 用户体验:带进度条的上传](#4.3 用户体验:带进度条的上传)
- [4.4 异常处理架构原则](#4.4 异常处理架构原则)
- [4.5 生产环境额外防护](#4.5 生产环境额外防护)
- [4.6 大文件上传的性能优化](#4.6 大文件上传的性能优化)
- 五、结语与最佳实践清单
)
一、基础原理------multipart/form-data 与 SpringBoot 的初步处理
1.1 为什么JSON不适合传输文件?
在日常接口交互中,我们习惯使用JSON格式传递数据。JSON本质是文本协议,对于二进制文件存在三个致命缺陷:
- 字符编码冲突:二进制数据中的不可见字符会破坏JSON的解析结构。
- 体积膨胀:若将文件转为Base64字符串,体积会增加约33%,浪费带宽。
- 内存压力:JSON解析必须将完整内容载入内存,大文件直接导致服务器OOM。
1.2 multipart/form-data:专为二进制设计的"集装箱"
multipart/form-data是一种多部分媒体类型,它允许在一个HTTP请求体中混合包含文本字段和二进制文件。其核心设计是Boundary(边界分隔符)。
浏览器会自动生成一个随机字符串作为Boundary,并在Content-Type头中声明:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryEBDscQO08JaFZOy6
请求体被Boundary分割为多个Part(部分),每个Part包含自己的Header和Body。以下是一个典型示例:
------WebKitFormBoundaryEBDscQO08JaFZOy6
Content-Disposition: form-data; name="username"
John Doe
------WebKitFormBoundaryEBDscQO08JaFZOy6
Content-Disposition: form-data; name="avatar"; filename="avatar.jpg"
Content-Type: image/jpeg
[二进制图片数据]
------WebKitFormBoundaryEBDscQO08JaFZOy6--
每个Part的Header包含:
Content-Disposition:提供字段名(name)和文件名(filename)。Content-Type:声明该Part的MIME类型,如image/jpeg。
Header与Body之间必须有一个空行,这是协议规定的分隔符。
1.3 SpringBoot的"智能拆箱"
当请求到达服务端,SpringBoot通过MultipartResolver组件(默认实现为StandardServletMultipartResolver)解析请求:
- 流式处理 :对于文本字段直接存入内存;对于文件字段,若文件超过阈值则写入临时目录(如
/tmp),避免内存溢出。 - 封装为MultipartFile :每个文件Part被封装成
MultipartFile对象,供开发者通过@RequestParam获取。
这一过程对开发者完全透明,我们只需关注业务逻辑即可。
二、前端实战------从原生到框架的文件打包
2.1 核心主角:FormData对象
FormData是浏览器内置的API,专门用于构建multipart/form-data格式的请求体。它自动处理Boundary生成和Header设置,开发者只需调用append方法添加键值对。
常用方法:
append(name, value):添加字段,若name重复则追加。delete(name):删除指定字段。get(name):获取字段值。
2.2 三种打包方案对比
2.2.1 传统HTML表单(石器时代)
利用表单的enctype="multipart/form-data"属性,浏览器原生打包提交,但会导致页面刷新。
html
<form action="/api/upload" method="post" enctype="multipart/form-data">
<input type="text" name="username" />
<input type="file" name="avatar" />
<button type="submit">提交</button>
</form>
2.2.2 原生JavaScript + Fetch(工业时代)
手动获取文件输入框的File对象,构造FormData并通过fetch发送,实现异步上传。
javascript
const fileInput = document.getElementById('avatar');
const fd = new FormData();
fd.append('username', '老王');
fd.append('avatar', fileInput.files[0]);
fetch('/api/upload', {
method: 'POST',
body: fd
});
2.2.3 Vue 3 + TypeScript(现代时代)
在Vue 3组合式API中,结合ref和类型系统,使代码更健壮。
vue
<template>
<input type="file" ref="fileInput" @change="handleSelect" />
<button @click="upload">上传</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const handleSelect = () => {
if (fileInput.value?.files) {
selectedFile.value = fileInput.value.files[0];
}
};
const upload = async () => {
if (!selectedFile.value) return;
const fd = new FormData();
fd.append('avatar', selectedFile.value);
fd.append('username', '老王');
await axios.post('/api/upload', fd);
alert('上传成功');
};
</script>
在TypeScript中使用ref<HTMLInputElement | null>(null)有两个好处:
- 类型约束 :明确
fileInput.value只能是HTMLInputElement或null。 - 空安全 :使用时必须检查
null,避免运行时错误。
2.3 避坑指南
- 禁止手动设置Content-Type :发送
FormData时,绝对不能手动在请求头中写入Content-Type: multipart/form-data。浏览器会自动生成包含Boundary的正确Content-Type,手动设置会导致Boundary丢失,后端无法解析。 - 字段名必须一致 :前端
append('avatar', ...),后端@RequestParam("avatar"),两者必须严格匹配。
三、后端接收------MultipartFile源码解析与参数辨析
3.1 什么是MultipartFile接口?为什么需要它?如何使用?
3.1.1 什么是MultipartFile?
MultipartFile是Spring框架提供的一个接口,用于封装HTTP multipart请求中接收到的文件数据。它位于org.springframework.web.multipart包中,继承自InputStreamSource接口,代表一个上传的文件。通过该接口,开发者可以方便地获取文件的元数据(如文件名、大小、内容类型)以及文件内容本身(以流或字节数组形式)。
3.1.2 为什么需要MultipartFile?
在没有MultipartFile之前,处理文件上传需要开发者手动解析HTTP请求的原始输入流,识别Boundary并提取文件部分。这不仅繁琐而且容易出错。MultipartFile的出现解决了以下问题:
- 屏蔽底层解析细节:Spring已经完成了复杂的multipart解析工作,开发者只需通过简单的API调用即可操作上传的文件。
- 统一处理接口 :无论文件存储在内存、临时文件还是其他位置,
MultipartFile都提供相同的方法,无需关心具体存储策略。 - 简化流式操作 :继承自
InputStreamSource,可以方便地获取输入流,用于后续处理(如转发到云存储、解析文件内容等)。 - 异常处理集成:与Spring的异常体系无缝结合,可在全局异常处理器中统一捕获上传相关异常。
3.1.3 如何使用MultipartFile?
使用MultipartFile通常遵循以下步骤:
- 在Controller方法参数中声明 :使用
@RequestParam注解将上传的文件绑定到MultipartFile参数,参数名应与前端表单字段名一致。 - 校验文件是否为空 :调用
isEmpty()方法判断用户是否选择了文件。 - 获取元数据 :根据需要调用
getOriginalFilename()、getSize()、getContentType()获取文件信息。 - 获取文件内容 :可以选择
getInputStream()进行流式处理,或者getBytes()获取字节数组(慎用于大文件)。 - 保存文件 :调用
transferTo(Path dest)或transferTo(File dest)将文件保存到目标路径。
一个典型的Controller示例:
java
@PostMapping("/upload")
public String handleUpload(@RequestParam("avatar") MultipartFile file) throws IOException {
if (file.isEmpty()) {
return "文件不能为空";
}
// 打印元数据
System.out.println("原始文件名:" + file.getOriginalFilename());
System.out.println("文件类型:" + file.getContentType());
System.out.println("文件大小:" + file.getSize() + " bytes");
// 安全校验:只允许图片
if (file.getContentType() != null && !file.getContentType().startsWith("image/")) {
return "只支持图片上传";
}
// 保存文件
String path = "/data/uploads/" + file.getOriginalFilename();
file.transferTo(new File(path));
return "上传成功,路径:" + path;
}
3.2 Controller接收基础详解
在实际开发中,我们通常需要更完善的接收逻辑,包括异常处理、文件名消毒、目录创建等。下面是一个更完整的Controller示例:
java
@RestController
@Slf4j
public class FileUploadController {
@PostMapping("/upload")
public Result uploadFile(@RequestParam("file") MultipartFile file) {
// 1. 基础检查
if (file == null || file.isEmpty()) {
return Result.error("请选择要上传的文件");
}
// 2. 获取元数据
String originalFilename = file.getOriginalFilename();
String contentType = file.getContentType();
long size = file.getSize();
log.info("接收到文件:originalFilename={}, contentType={}, size={} bytes",
originalFilename, contentType, size);
// 3. 类型校验(白名单)
if (!isAllowedContentType(contentType)) {
return Result.error("不支持的文件类型");
}
// 4. 大小校验(已在配置中限制,此处可做二次校验)
if (size > 10 * 1024 * 1024) { // 10MB
return Result.error("文件大小不能超过10MB");
}
// 5. 安全处理:生成唯一文件名
String safeFileName = generateSafeFileName(originalFilename);
// 6. 保存文件
try {
String savePath = saveToDisk(file, safeFileName);
return Result.success("上传成功", savePath);
} catch (IOException e) {
log.error("文件保存失败", e);
return Result.error("文件上传失败,请稍后重试");
}
}
private boolean isAllowedContentType(String contentType) {
return contentType != null && (contentType.startsWith("image/")
|| contentType.equals("application/pdf"));
}
private String generateSafeFileName(String originalFilename) {
if (originalFilename == null || originalFilename.isEmpty()) {
return UUID.randomUUID().toString();
}
// 只保留文件名中的字母、数字、点和下划线,移除路径符号
String cleanName = originalFilename.replaceAll("[^a-zA-Z0-9.\\-_]", "_");
String suffix = "";
int dotIndex = cleanName.lastIndexOf('.');
if (dotIndex > 0) {
suffix = cleanName.substring(dotIndex);
cleanName = cleanName.substring(0, dotIndex);
}
return UUID.randomUUID().toString() + suffix;
}
private String saveToDisk(MultipartFile file, String fileName) throws IOException {
String uploadDir = "/data/uploads/";
File dir = new File(uploadDir);
if (!dir.exists()) {
dir.mkdirs();
}
Path targetPath = Paths.get(uploadDir, fileName).normalize();
// 防止路径遍历攻击
if (!targetPath.startsWith(Paths.get(uploadDir).normalize())) {
throw new SecurityException("非法文件名");
}
file.transferTo(targetPath);
return targetPath.toString();
}
}
3.3 MultipartFile接口源码深度剖析
MultipartFile接口的完整定义及核心方法如下:
java
public interface MultipartFile extends InputStreamSource {
// 获取表单字段名(如 "avatar")
String getName();
// 获取原始文件名(客户端文件名,可能为null)
@Nullable
String getOriginalFilename();
// 获取Content-Type(由浏览器提供,仅作参考)
@Nullable
String getContentType();
// 判断文件是否为空(0字节或未选择)
boolean isEmpty();
// 获取文件大小(字节)
long getSize();
// 获取输入流(用于流式处理,如转发到OSS)
InputStream getInputStream() throws IOException;
// 将文件内容转为字节数组(慎用,大文件会导致OOM)
byte[] getBytes() throws IOException;
// 将文件保存到目标File
void transferTo(File dest) throws IOException, IllegalStateException;
// 将文件保存到目标Path(推荐,基于NIO)
default void transferTo(Path dest) throws IOException, IllegalStateException {
FileCopyUtils.copy(getInputStream(), Files.newOutputStream(dest));
}
}
设计要点:
getOriginalFilename()和getContentType()带有@Nullable注解,使用时必须判空。transferTo(Path)是Java 7+的推荐方式,底层使用FileCopyUtils高效复制流。- 继承
InputStreamSource的设计思想:文件上传对象不一定是磁盘文件,也可以是内存流或网络流,为云存储等场景提供灵活性。
3.4 安全落盘与重命名策略
直接使用原始文件名保存存在两个风险:
- 同名覆盖:多个用户上传同名文件会互相覆盖。
- 路径注入 :文件名可能包含
../等恶意字符,导致目录遍历攻击。
正确做法:生成唯一文件名,并限制存储路径。
java
public String saveFile(MultipartFile file) throws IOException {
// 1. 创建存储目录
String uploadDir = "/data/uploads/";
File dir = new File(uploadDir);
if (!dir.exists()) {
dir.mkdirs();
}
// 2. 生成唯一文件名
String originalName = file.getOriginalFilename();
String suffix = "";
if (originalName != null && originalName.contains(".")) {
suffix = originalName.substring(originalName.lastIndexOf("."));
}
String newName = UUID.randomUUID().toString() + suffix;
// 3. 防止路径遍历攻击:使用resolve并标准化路径,确保最终路径仍在uploadDir内
Path targetPath = Paths.get(uploadDir).resolve(newName).normalize();
if (!targetPath.startsWith(Paths.get(uploadDir).normalize())) {
throw new SecurityException("非法文件名");
}
file.transferTo(targetPath);
return newName;
}
3.5 配置文件扩容
Spring Boot默认限制单个文件最大1MB,总请求最大10MB。超过限制会在进入Controller前抛出MaxUploadSizeExceededException。在application.yml中调整:
yaml
spring:
servlet:
multipart:
max-file-size: 10MB # 单个文件大小
max-request-size: 100MB # 总请求大小(多文件总和)
location: /tmp/uploads # 临时文件存放目录(可选)
3.6 @RequestParam vs @RequestPart
| 维度 | @RequestParam | @RequestPart |
|---|---|---|
| 主要用途 | 接收简单类型和MultipartFile | 接收复杂对象(如JSON)和MultipartFile |
| 转换机制 | 基于PropertyEditor/Converter | 基于HttpMessageConverter(类似@RequestBody) |
| 适用场景 | 传统表单提交 | 混合请求(文件+JSON数据) |
示例 :若前端同时上传头像文件和用户信息的JSON字符串,使用@RequestPart可以自动将JSON反序列化为Java对象。
java
@PostMapping("/save")
public String save(
@RequestPart("avatar") MultipartFile file,
@RequestPart("user") UserDTO user // 自动解析JSON部分
) {
System.out.println(user.getName());
System.out.println(user.getAge());
// 处理文件...
return "success";
}
对应的前端Vue代码:
typescript
const fd = new FormData();
fd.append('avatar', file);
fd.append('user', new Blob([JSON.stringify(userObj)], { type: 'application/json' }));
await axios.post('/api/save', fd);
3.7 集成MinIO的注意事项
当使用MinIO或其他云存储时,通常需要将文件流直接上传,而不是先保存到本地。此时应使用getInputStream()获取流并传递给MinIO客户端。
关键原则:底层组件(如工具类)不应处理异常,而是抛出让调用方处理,遵循"谁调用谁处理"的原则。
java
// 工具类方法(抛出异常)
public class MinioUtils {
private final MinioClient minioClient;
public void uploadToMinIO(InputStream is, String bucket, String key) throws IOException {
try {
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(key)
.stream(is, -1, 10485760)
.build());
} catch (Exception e) {
throw new IOException("MinIO上传失败", e);
}
}
}
// Service层调用
@Service
public class FileService {
@Autowired
private MinioUtils minioUtils;
public void uploadFile(MultipartFile file) {
try (InputStream is = file.getInputStream()) {
String key = UUID.randomUUID().toString();
minioUtils.uploadToMinIO(is, "my-bucket", key);
} catch (IOException e) {
throw new StorageException("文件上传至MinIO失败", e);
}
}
}
// 自定义异常
public class StorageException extends RuntimeException {
public StorageException(String message, Throwable cause) {
super(message, cause);
}
}
四、生产级防护------异常处理、安全校验与用户体验
4.1 优雅的全局异常拦截
文件上传过程中的异常可能发生在进入Controller之前(如大小超限),此时无法用try-catch捕获,必须通过全局异常处理器处理。
4.1.1 捕获MaxUploadSizeExceededException
java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
public Result handleMaxUploadSizeExceeded(MaxUploadSizeExceededException e) {
long maxSize = e.getMaxUploadSize(); // 字节数
String readableSize = (maxSize / 1024 / 1024) + "MB";
log.warn("上传文件过大,系统限制为:{}", readableSize);
return Result.error("文件大小不能超过 " + readableSize);
}
@ExceptionHandler(StorageException.class)
public Result handleStorageException(StorageException e) {
log.error("存储服务异常", e);
return Result.error("文件存储失败,请稍后重试");
}
@ExceptionHandler(MultipartException.class)
public Result handleMultipartException(MultipartException e) {
log.error("Multipart解析异常", e);
return Result.error("请求格式错误,请检查文件格式");
}
@ExceptionHandler(Exception.class)
public Result handleGenericException(Exception e) {
log.error("系统异常", e);
return Result.error("系统繁忙,请稍后重试");
}
}
4.1.2 动态获取配置信息
在错误提示中硬编码"不能超过10MB"会导致配置变更时提示不准确。最佳实践是从异常对象中动态提取限额,或通过@Value注入配置值。
通过@Value注入:
java
@Service
public class FileService {
@Value("${spring.servlet.multipart.max-file-size:10MB}")
private String maxFileSize;
public String getMaxFileSize() {
return maxFileSize;
}
}
通过MultipartConfigElement获取详细配置:
java
@Autowired
private MultipartConfigElement multipartConfigElement;
public void printConfig() {
long maxFileSize = multipartConfigElement.getMaxFileSize();
String location = multipartConfigElement.getLocation();
log.info("maxFileSize: {}, location: {}", maxFileSize, location);
}
4.2 深度安全校验:文件魔数(Magic Number)
仅靠后缀名或getContentType()无法防止恶意文件伪装(如将PHP脚本重命名为.jpg)。真正的校验方式是读取文件二进制头部的魔数。
java
public class FileMagicValidator {
private static final Map<String, String> MAGIC_MAP = new HashMap<>();
static {
MAGIC_MAP.put("FFD8FF", "jpg"); // JPEG
MAGIC_MAP.put("89504E47", "png"); // PNG
MAGIC_MAP.put("47494638", "gif"); // GIF
MAGIC_MAP.put("25504446", "pdf"); // PDF
MAGIC_MAP.put("504B0304", "zip"); // ZIP (docx, xlsx 等)
}
public static void validateImage(InputStream is) throws IOException {
byte[] header = new byte[4];
int read = is.read(header);
if (read < header.length) {
throw new SecurityException("文件内容不足");
}
String hex = bytesToHex(header);
boolean valid = MAGIC_MAP.keySet().stream()
.anyMatch(hex::startsWith);
if (!valid) {
throw new SecurityException("文件格式非法,检测到魔数不匹配");
}
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02X", b & 0xFF)); // 处理负值
}
return sb.toString();
}
}
在Controller中,先调用魔数校验,再执行业务逻辑。注意文件流被读取后位置已改变,若还需使用原流,可考虑使用mark/reset或重新获取流(对于MultipartFile,可多次调用getInputStream()获取新流)。
java
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
// 魔数校验
try (InputStream is = file.getInputStream()) {
FileMagicValidator.validateImage(is);
} catch (SecurityException e) {
return "文件类型不合法";
}
// 重新获取流进行保存
try (InputStream is = file.getInputStream()) {
// 保存逻辑...
}
return "success";
}
4.3 用户体验:带进度条的上传
对于大文件,使用Axios的onUploadProgress钩子可以实时获取上传进度。
typescript
// Vue 3 + TypeScript
import { ref } from 'vue';
import axios from 'axios';
const uploadProgress = ref(0);
const uploading = ref(false);
const uploadWithProgress = async (file: File) => {
const formData = new FormData();
formData.append('file', file);
uploading.value = true;
uploadProgress.value = 0;
try {
await axios.post('/api/upload', formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
uploadProgress.value = percent;
}
}
});
alert('上传成功');
} catch (error) {
alert('上传失败');
} finally {
uploading.value = false;
}
};
模板中使用进度条:
vue
<template>
<div>
<input type="file" @change="handleFileChange" />
<button @click="upload" :disabled="!selectedFile || uploading">
{{ uploading ? '上传中...' : '上传' }}
</button>
<div v-if="uploading">
<progress :value="uploadProgress" max="100"></progress>
<span>{{ uploadProgress }}%</span>
</div>
</div>
</template>
4.4 异常处理架构原则
- 底层组件(如MinIO工具类):不处理异常,直接向上抛出(抛出检查型异常或转换为运行时异常)。
- 业务层(Service) :进行业务校验,捕获底层异常并包装为自定义业务异常(如
StorageException)再抛出。 - 全局处理器(ControllerAdvice):统一处理异常,返回用户友好的提示信息,并记录错误日志。
4.5 生产环境额外防护
- 文件类型白名单 :结合魔数与后缀,只允许特定类型(如
image/jpeg、image/png)。 - 病毒扫描:上传后立即调用ClamAV等扫描服务。
- 文件存储隔离:不要将上传文件保存在Web容器的可访问目录内,应放在专门的文件服务器或对象存储,并通过鉴权访问。
- 文件名消毒:移除文件名中的特殊字符,防止XSS(当文件名被用于前端展示时)。
4.6 大文件上传的性能优化
- 分片上传与断点续传:前端将文件切片,后端合并,并提供断点续传接口。
- 直接流式上传到云存储 :利用
getInputStream()直接上传到OSS,避免本地落盘。 - 异步处理:上传成功后立即返回"接受"状态,然后后台处理文件(如转码、压缩)。
五、结语与最佳实践清单
从原理到生产,一个健壮的文件上传系统应当包含以下要素:
- 理解
multipart/form-data协议,正确使用FormData。 - 前端避免手动设置Content-Type,字段名与后端一致。
- 后端使用
MultipartFile接口,实现安全重命名与路径防护。 - 通过配置文件控制大小限制,并在异常提示中动态读取。
- 全局异常处理器统一处理大小超限、存储失败等异常。
- 增加魔数校验,防止恶意文件上传。
- 提供上传进度条,优化用户交互。
- 定期清理临时文件。
- 生产环境做好文件存储隔离与访问控制。