1. 项目概述:为什么一个文件上传功能值得花一整天反复调试?
SpringMVC 的文件上传功能,表面看只是“选个文件点上传”这么简单,但实际在某高校实验室做教学系统重构时,我亲眼见过三组学生卡在同一个地方超过48小时——不是逻辑写错,也不是路径配错,而是表单 enctype 属性漏写了、commons-fileupload 依赖版本和 Spring 版本不兼容、临时目录权限没放开、Nginx 代理层悄悄截断了大文件请求体。这些细节,官方文档不会标红加粗,Stack Overflow 的高赞答案往往只解决其中一环,而真实生产环境里,它们是叠在一起爆发的。
这个案例的核心,不是教你“怎么让 upload.jsp 能点按钮”,而是帮你建立一套可复用、可排查、可上线的文件处理闭环能力。它覆盖了从浏览器端表单构造、后端控制器接收、服务层校验压缩、存储路径生成、数据库记录关联,到下载时的流式响应、中文文件名兼容、断点续传基础支持等完整链路。关键词“SpringMVC”“多文件上传”“下载案例”背后,真正要解决的是:如何让一个看似简单的功能,在不同浏览器、不同服务器配置、不同文件大小、不同并发压力下,都稳定输出预期结果。
适合谁来读?如果你正在用 Spring Boot 2.7+ 做后台管理系统的开发,手头有个 Excel 批量导入、用户头像上传、合同 PDF 存档的需求;或者你刚学完 SpringMVC 控制器映射,但对 MultipartFile 这个对象始终觉得“用得不踏实”;又或者你被测试提了个 bug:“上传 50MB 视频失败,页面白屏,日志没报错”——那这篇就是为你写的。它不讲 MVC 架构图,不画流程框图,只讲你打开 IDE 后,每一行代码为什么这么写、删掉哪一行会出什么问题、日志里看到哪串字符就该去查哪个配置。
我试过把上传逻辑封装成工具类直接扔进新项目,结果在某次压测中发现:当 20 个用户同时上传 10MB 文件时,Tomcat 线程池耗尽,后续所有 HTTP 请求全部超时。后来才明白,文件上传不是“接住就完事”,它本质是一次 I/O 密集型操作,必须和线程模型、内存缓冲、磁盘 IO 路径深度耦合。所以本文所有配置参数,都会附带计算依据——比如maxUploadSize设为 50MB,不是拍脑袋,而是根据你服务器物理内存、JVM 堆大小、预期并发数反推出来的安全阈值。
2. 整体设计思路与方案选型:为什么不用 Spring Boot Starter Web 自带的上传?为什么坚持用 Commons FileUpload?
2.1 不用 Spring Boot 默认上传机制的三个硬原因
Spring Boot 2.0+ 默认启用了基于 Servlet 3.0 的原生文件上传(StandardServletMultipartResolver),它确实省去了额外依赖,但我在某跨平台数据同步项目中踩过三次坑:
第一坑:无法细粒度控制解析过程
默认解析器在request.getInputStream()被调用前,就把整个 multipart body 缓存进内存或临时文件。当你需要在解析中途校验文件头(比如判断是不是真 PNG)、或按字段名分流处理(如“附件”存 OSS,“缩略图”存本地)时,它只提供一个MultipartFile[]数组,你根本拿不到原始字节流和边界分隔符位置。第二坑:错误码不统一,前端难处理
当上传超限时,StandardServletMultipartResolver抛MaxUploadSizeExceededException,但这个异常默认被DefaultHandlerExceptionResolver捕获并返回 400 错误,且响应体是空白 HTML。而前端 JS 期望的是 JSON 格式{code: 4001, msg: "文件不能大于50MB"}。你得重写异常处理器,但此时请求体已被丢弃,连原始文件名都取不到。第三坑:不支持分块上传预检
某客户要求支持 2GB 工程图纸上传,必须用分块(chunked upload)。Servlet 3.0 原生解析器根本不识别X-Upload-ID、X-Chunk-Index这类自定义 Header,你得自己写 Filter 拦截原始 request 流,等于绕过整个 Spring MVC 解析链路。
提示:这不是贬低 Spring Boot,默认方案对 90% 的管理后台足够用。但一旦你的业务涉及大文件、多源异构、强校验或合规审计,就必须切换到更底层可控的方案。
2.2 为什么选 Apache Commons FileUpload 而非其他?
当前主流有三类方案:
- Servlet 原生 API:需手动解析 boundary,处理 base64 编码、字符集转换,代码量大且易出错;
- OkHttp/Feign 客户端上传:适用于微服务间调用,不解决浏览器直传问题;
- Apache Commons FileUpload:成熟度最高(2002 年发布),Spring MVC 底层其实也封装了它,我们只是“掀开盖子”直接用。
我对比了 Commons FileUpload 1.5 和 2.0 版本:
- 1.5 支持
DiskFileItemFactory设置内存阈值(sizeThreshold)和临时目录(repository),这是控制内存溢出的关键; - 2.0 引入
FileItemIterator流式迭代,避免一次性加载所有文件到内存,但要求 Servlet 容器支持HttpServletRequest#getParts(),而某些国产中间件(如东方通 TONGWEB)兼容性差;
最终选定commons-fileupload 1.5 + commons-io 2.11组合。理由很实在:某银行核心系统要求所有第三方组件必须通过等保三级扫描,而 1.5 版本的 CVE 漏洞数为 0,2.0 版本在 2023 年曝出过一个反序列化风险(CVE-2023-24998),虽已修复,但审批流程拖了两个月。
2.3 多文件上传 vs 单文件:设计上本质是同一套逻辑
很多人以为“多文件”要单独写 Controller 方法,其实完全没必要。关键在于HTML 表单的name属性是否一致:
<!-- 单文件 --> <input type="file" name="singleFile" /> <!-- 多文件(注意:name 相同!) --> <input type="file" name="multiFiles" multiple /> <!-- 或多个独立 input --> <input type="file" name="multiFiles" /> <input type="file" name="multiFiles" />后端只需声明@RequestParam("multiFiles") List<MultipartFile> files,Spring 会自动聚合。但要注意:如果用multiple属性,Chrome 和 Firefox 会把所有选中文件打包成一个multipart/form-data请求体,而 IE11 会拆成多个独立请求——这导致你在 Controller 里收到的files.size()在不同浏览器可能不一致。解决方案是:强制要求前端用 JS 构造 FormData,逐个 append,不依赖原生 multiple。这样你能精确控制每个文件的 key 名(如file_0,file_1),也能在上传前做尺寸校验。
2.4 下载功能为何必须区分“内联显示”和“附件下载”
浏览器对Content-Disposition响应头的处理逻辑,直接决定用户看到的是“在浏览器里打开 PDF”还是“弹出保存对话框”。这不仅是用户体验问题,更是安全红线:
- 内联显示(inline):适合图片、PDF、文本等可渲染格式。但若返回的是
.jar或.exe文件,且服务器未正确设置Content-Type,某些旧版 IE 会执行文件而非下载,造成远程代码执行风险; - 附件下载(attachment):强制触发保存对话框,但中文文件名会乱码。RFC 5987 规定应使用
filename*=UTF-8''xxx.png编码,但 IE8 及以下不支持,必须降级为filename=xxx.png并用new String(filename.getBytes("GBK"), "ISO-8859-1")做兼容;
我在某政务系统中遇到真实案例:用户上传的合同名为《2024年技术服务协议_v2.1(终稿).pdf》,下载时 IE11 显示为2024??????????.pdf。最后采用双重策略:
- 优先用
filename*=标准格式; - 若 User-Agent 包含
MSIE,则 fallback 到 GBK 编码; - 所有文件名在入库前先用正则
[^a-zA-Z0-9\u4e00-\u9fa5._-]过滤非法字符,避免路径遍历攻击。
3. 核心细节解析与实操要点:从表单到 Controller,每一行代码背后的深意
3.1 前端表单必须写的三要素(缺一不可)
很多初学者只写<input type="file">就以为万事大吉,结果后端MultipartFile始终为空。根源在于 HTML 表单的三个隐藏规则:
enctype 必须为 multipart/form-data
这是 multipart 协议的标识。如果写成application/x-www-form-urlencoded(默认值),浏览器会把文件转成 base64 字符串塞进普通表单字段,后端收到的只是字符串,不是二进制流。验证方法:用浏览器开发者工具 Network 面板看请求头,必须有Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxxx。method 必须为 POST
GET 请求有 URL 长度限制(通常 2KB),根本装不下文件内容。即使你用 JS 拼接 data URI,也会因 URL 过长被 Nginx 截断(414 Request-URI Too Large)。name 属性必须与后端 @RequestParam 的 value 严格一致
注意大小写和下划线。比如前端写name="userAvatar",后端必须写@RequestParam("userAvatar") MultipartFile file。Spring 不会做驼峰转换,也不会忽略下划线。
一个健壮的表单模板如下:
<form id="uploadForm" action="/api/file/upload" method="POST" enctype="multipart/form-data"> <input type="hidden" name="bizType" value="avatar" /> <input type="hidden" name="userId" value="123456" /> <!-- 多文件:用 JS 动态添加 input,确保 name 相同 --> <div id="fileInputs"> <input type="file" name="files" accept="image/*,.pdf,.doc,.docx" /> </div> <button type="button" onclick="addFileInput()">+ 添加文件</button> <button type="submit">上传</button> </form> <script> function addFileInput() { const div = document.getElementById('fileInputs'); const input = document.createElement('input'); input.type = 'file'; input.name = 'files'; // 关键:name 必须相同 input.accept = 'image/*,.pdf,.doc,.docx'; div.appendChild(input); } // 提交前校验 document.getElementById('uploadForm').onsubmit = function(e) { const files = document.querySelectorAll('input[name="files"]'); let totalSize = 0; for (let i = 0; i < files.length; i++) { if (files[i].files.length > 0) { totalSize += files[i].files[0].size; } } if (totalSize > 50 * 1024 * 1024) { // 50MB alert('总文件大小不能超过 50MB'); e.preventDefault(); } }; </script>注意:
accept属性只是浏览器提示,不能替代后端校验。用户用 Postman 或 curl 仍可上传任意类型文件。
3.2 后端 Controller 的五个关键约束条件
Controller 方法签名看着简单,但每个参数都有隐含契约:
@PostMapping("/upload") @ResponseBody public Result upload( @RequestParam("files") List<MultipartFile> files, @RequestParam("bizType") String bizType, @RequestParam("userId") Long userId, HttpServletRequest request) { // ... }@RequestParam("files") 必须与前端 name 一致:Spring 通过这个字符串匹配
MultipartHttpServletRequest中的文件项。如果前端传fileList,这里写files,就会抛MissingServletRequestParameterException。List 是唯一支持多文件的类型:不能用
MultipartFile[](Spring 会尝试调用setFiles(MultipartFile[]),但MultipartFile没有无参构造,报InstantiationException),也不能用Map<String, MultipartFile>(Spring 不支持这种泛型注入)。普通参数(bizType, userId)必须用 @RequestParam 显式标注:因为 multipart 请求体里,文本字段和文件字段是混合存放的,Spring 无法自动区分哪些是文件、哪些是普通参数,必须靠注解指引。
HttpServletRequest 是获取原始请求信息的最后防线:比如你想读取
X-Request-ID追踪日志,或检查request.getRemoteAddr()做 IP 限流,这时HttpServletRequest比@RequestHeader更可靠(后者在 multipart 解析失败时可能为空)。@ResponseBody 是返回 JSON 的前提:如果不加,Spring 会尝试走视图解析器(ViewResolver),去找
upload.jsp,而你根本没有这个页面,结果 404。
3.3 文件校验的四个层次(漏掉任一层都可能被攻破)
校验不是“if (file.getSize() > 50MB) return error”,而是四层漏斗式过滤:
第一层:容器层拦截(最外层)
在application.yml中配置:
spring: servlet: context-path: /app # 这是 Tomcat/Jetty 的全局限制,单位字节 http: multipart: max-file-size: 50MB max-request-size: 50MB作用:在 Spring 解析前,由 Servlet 容器直接拒绝超大请求,避免内存耗尽。但注意:此配置仅对StandardServletMultipartResolver生效,对 Commons FileUpload 无效。所以如果你用了 Commons,这层配置要删掉,否则两个限制叠加,用户会收到两次错误。
第二层:SpringMVC 层校验(Controller 入口)
for (MultipartFile file : files) { if (file.isEmpty()) { throw new BusinessException("文件不能为空"); } if (file.getSize() > 50 * 1024 * 1024) { throw new BusinessException("单个文件不能大于 50MB"); } // 检查文件头(Magic Number) byte[] head = new byte[4]; file.getInputStream().read(head); String fileType = getFileType(head); if (!SUPPORTED_TYPES.contains(fileType)) { throw new BusinessException("不支持的文件类型: " + fileType); } }getFileType()示例:
private String getFileType(byte[] head) { if (head[0] == -1 && head[1] == -40) return "jpg"; // JPEG SOI marker if (head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47) return "png"; if (head[0] == 0x25 && head[1] == 0x50 && head[2] == 0x44 && head[3] == 0x46) return "pdf"; return "unknown"; }第三层:业务层校验(防越权)
// 检查 userId 是否有权上传 bizType 类型文件 if (!userService.canUploadBizType(userId, bizType)) { throw new BusinessException("无权上传此类文件"); } // 检查当天上传次数 long todayCount = fileService.countByUserIdAndDate(userId, LocalDate.now()); if (todayCount >= 10) { throw new BusinessException("今日上传次数已达上限"); }第四层:存储层校验(防路径遍历)
// 用户传来的原始文件名 String originalName = file.getOriginalFilename(); // 清洗:去掉路径分隔符,只保留文件名 String cleanName = originalName.substring(originalName.lastIndexOf("/") + 1) .substring(originalName.lastIndexOf("\\") + 1); // 生成唯一文件名,避免覆盖 String uniqueName = UUID.randomUUID().toString().replace("-", "") + "_" + cleanName; // 拼接存储路径(绝对路径) String storePath = "/data/uploads/" + bizType + "/" + LocalDate.now() + "/" + uniqueName; // 关键:校验 storePath 是否在允许目录下 if (!storePath.startsWith("/data/uploads/")) { throw new BusinessException("非法存储路径"); }实操心得:我曾在线上环境发现一个漏洞——用户上传文件名包含
../../../etc/passwd,后端没清洗直接拼接new File(storePath),结果文件被写到系统目录。后来加了Paths.get(storePath).normalize().startsWith(allowedBasePath)校验,彻底堵死。
3.4 临时目录(Repository)的配置陷阱
Commons FileUpload 使用DiskFileItemFactory管理内存和磁盘缓存:
@Bean public MultipartResolver multipartResolver() { CommonsMultipartResolver resolver = new CommonsMultipartResolver(); resolver.setMaxUploadSize(50 * 1024 * 1024); // 50MB resolver.setMaxInMemorySize(1024 * 1024); // 1MB 内存阈值 // 关键:临时目录必须存在且可写 resolver.setUploadTempDir(new FileSystemResource("/data/tmp/upload")); return resolver; }这里有两个致命陷阱:
临时目录必须提前创建,且 Tomcat 进程有写权限
如果/data/tmp/upload不存在,Commons 会静默失败,MultipartFile为空,日志里只有WARN o.a.c.f.d.DiskFileItemFactory - The temporary directory ... does not exist,极易被忽略。建议在应用启动时校验:@PostConstruct public void checkTempDir() { File tempDir = new File("/data/tmp/upload"); if (!tempDir.exists()) { boolean created = tempDir.mkdirs(); if (!created) { throw new RuntimeException("Failed to create upload temp dir: " + tempDir); } } if (!tempDir.canWrite()) { throw new RuntimeException("Upload temp dir is not writable: " + tempDir); } }maxInMemorySize 不是越大越好
设为 1MB 意味着:小于 1MB 的文件全程在内存操作,速度快;大于 1MB 的文件会先写入临时目录,再由 Controller 移动到目标位置。但如果设为 10MB,而 JVM 堆只有 512MB,100 个并发上传 8MB 文件,瞬间吃光堆内存,触发 Full GC。经验公式:maxInMemorySize ≤ (JVM Heap Size × 0.1) ÷ expected concurrent uploads。例如堆 2GB,并发 50,则maxInMemorySize ≤ 4MB。
4. 实操过程与核心环节实现:从零开始搭建可上线的上传下载模块
4.1 项目依赖配置(Maven)
pom.xml必须引入的依赖:
<dependencies> <!-- Spring Boot Web(必须) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Commons FileUpload(核心) --> <dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.5</version> </dependency> <!-- Commons IO(辅助工具) --> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.11.0</version> </dependency> <!-- Lombok(简化代码) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>为什么不用 Spring Boot Starter Multipart?
因为spring-boot-starter-web已内置spring-boot-starter-tomcat,它自带StandardServletMultipartResolver。如果你不显式配置CommonsMultipartResolverBean,Spring 会优先使用内置的。所以必须手动定义 Bean,覆盖默认行为。
4.2 配置类编写(关键:Bean 名称必须为 multipartResolver)
SpringMVC 会自动查找名为multipartResolver的 Bean。名称写错(如fileUploadResolver)会导致配置失效,默默走回默认解析器。
@Configuration public class FileUploadConfig { @Value("${file.upload.temp-dir:/data/tmp/upload}") private String tempDir; @Value("${file.upload.max-size:52428800}") // 50MB private long maxUploadSize; @Bean(name = "multipartResolver") // 名称必须是 multipartResolver! public MultipartResolver multipartResolver() { CommonsMultipartResolver resolver = new CommonsMultipartResolver(); resolver.setMaxUploadSize(maxUploadSize); resolver.setMaxInMemorySize(1024 * 1024); // 1MB resolver.setDefaultEncoding("UTF-8"); resolver.setResolveLazily(true); // 延迟解析,提升小文件性能 // 设置临时目录 File tempDirFile = new File(tempDir); if (!tempDirFile.exists()) { tempDirFile.mkdirs(); } resolver.setUploadTempDir(new FileSystemResource(tempDirFile)); return resolver; } }注意:
setResolveLazily(true)是性能优化点。默认false时,Spring 在 DispatcherServlet 的doDispatch()阶段就解析所有 multipart,即使 Controller 根本没用到文件参数。设为true后,只在 Controller 方法真正访问MultipartFile时才解析,减少不必要的 I/O。
4.3 Controller 实现(含完整异常处理)
@RestController @RequestMapping("/api/file") @Slf4j public class FileUploadController { @Autowired private FileStorageService fileStorageService; @PostMapping("/upload") public Result<List<UploadResult>> upload( @RequestParam("files") List<MultipartFile> files, @RequestParam("bizType") String bizType, @RequestParam("userId") Long userId, HttpServletRequest request) { // 1. 基础校验 if (CollectionUtils.isEmpty(files)) { return Result.fail("请选择文件"); } List<UploadResult> results = new ArrayList<>(); for (MultipartFile file : files) { try { // 2. 单文件校验 validateSingleFile(file, bizType, userId); // 3. 业务存储(返回存储后的元数据) UploadResult result = fileStorageService.store(file, bizType, userId); results.add(result); } catch (BusinessException e) { log.warn("文件上传失败 bizType={}, userId={}, fileName={}, error={}", bizType, userId, file.getOriginalFilename(), e.getMessage()); results.add(UploadResult.fail(file.getOriginalFilename(), e.getMessage())); } catch (IOException e) { log.error("文件IO异常 bizType={}, userId={}, fileName={}", bizType, userId, file.getOriginalFilename(), e); results.add(UploadResult.fail(file.getOriginalFilename(), "服务器内部错误")); } } return Result.success(results); } private void validateSingleFile(MultipartFile file, String bizType, Long userId) throws BusinessException { if (file.isEmpty()) { throw new BusinessException("文件不能为空"); } if (file.getSize() > 50 * 1024 * 1024) { throw new BusinessException("单个文件不能大于 50MB"); } // 文件头校验(略) // 业务权限校验(略) } }UploadResult数据结构:
@Data public class UploadResult { private String originalName; // 原始文件名 private String storedName; // 存储后文件名(含路径) private String url; // 可访问 URL(如 /api/file/download?id=xxx) private Long size; // 文件大小(字节) private String md5; // 文件 MD5(用于秒传去重) private String status; // success / failed private String errorMsg; // 失败时的错误信息 public static UploadResult success(String originalName, String storedName, String url, Long size, String md5) { UploadResult r = new UploadResult(); r.originalName = originalName; r.storedName = storedName; r.url = url; r.size = size; r.md5 = md5; r.status = "success"; return r; } public static UploadResult fail(String originalName, String errorMsg) { UploadResult r = new UploadResult(); r.originalName = originalName; r.errorMsg = errorMsg; r.status = "failed"; return r; } }4.4 文件存储服务(FileStorageService)实现
核心逻辑:生成唯一路径 → 创建目录 → 写入文件 → 计算 MD5 → 保存元数据到 DB。
@Service @Slf4j public class FileStorageService { @Value("${file.storage.base-dir:/data/uploads}") private String baseDir; @Autowired private FileMetadataMapper fileMetadataMapper; public UploadResult store(MultipartFile file, String bizType, Long userId) throws IOException { // 1. 生成存储路径 String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String uniqueFileName = generateUniqueFileName(file.getOriginalFilename()); String relativePath = String.format("%s/%s/%s", bizType, datePath, uniqueFileName); String fullPath = Paths.get(baseDir, relativePath).toString(); // 2. 创建目录 File targetDir = new File(fullPath).getParentFile(); if (!targetDir.exists()) { boolean created = targetDir.mkdirs(); if (!created) { throw new BusinessException("创建存储目录失败: " + targetDir); } } // 3. 写入文件(使用 try-with-resources 确保流关闭) Path targetPath = Paths.get(fullPath); file.transferTo(targetPath); // transferTo 是原子操作,比 FileOutputStream 更安全 // 4. 计算 MD5(用于秒传) String md5 = calculateMD5(file.getInputStream()); // 5. 保存元数据到数据库 FileMetadata metadata = new FileMetadata(); metadata.setOriginalName(file.getOriginalFilename()); metadata.setStoredName(relativePath); metadata.setBizType(bizType); metadata.setUserId(userId); metadata.setSize(file.getSize()); metadata.setMd5(md5); metadata.setCreateTime(LocalDateTime.now()); fileMetadataMapper.insert(metadata); // 6. 返回可访问 URL(注意:这里是相对路径,Nginx 需配置静态资源映射) String url = "/static/" + relativePath; return UploadResult.success(file.getOriginalFilename(), relativePath, url, file.getSize(), md5); } private String generateUniqueFileName(String originalName) { String ext = ""; int dotIndex = originalName.lastIndexOf("."); if (dotIndex > 0) { ext = originalName.substring(dotIndex); } return UUID.randomUUID().toString().replace("-", "") + ext; } private String calculateMD5(InputStream inputStream) throws IOException { MessageDigest digest = MessageDigest.getInstance("MD5"); byte[] buffer = new byte[8192]; int len; while ((len = inputStream.read(buffer)) != -1) { digest.update(buffer, 0, len); } BigInteger bigInt = new BigInteger(1, digest.digest()); return bigInt.toString(16); } }注意:
file.transferTo(targetPath)是关键。它底层调用Files.move(),在同文件系统内是原子重命名操作,不会出现“写一半崩溃导致文件损坏”。而file.getInputStream().transferTo(Files.newOutputStream(targetPath))在大文件时可能因内存不足 OOM。
4.5 下载功能实现(支持断点续传和中文名)
下载接口必须处理三件事:文件存在性校验、Range 请求支持(断点续传)、中文文件名兼容。
@GetMapping("/download") public void download(@RequestParam("id") Long fileId, HttpServletRequest request, HttpServletResponse response) throws IOException { // 1. 查询文件元数据 FileMetadata metadata = fileMetadataMapper.selectById(fileId); if (metadata == null || !new File(baseDir, metadata.getStoredName()).exists()) { response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在"); return; } File file = new File(baseDir, metadata.getStoredName()); long fileSize = file.length(); // 2. 设置响应头 response.setContentType(getContentType(metadata.getOriginalName())); response.setHeader("Content-Length", String.valueOf(fileSize)); response.setHeader("Content-Disposition", getContentDisposition(metadata.getOriginalName())); // 3. 处理 Range 请求(断点续传) String range = request.getHeader("Range"); if (range != null && range.startsWith("bytes=")) { response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); long start = Long.parseLong(range.substring(6).split("-")[0]); long end = Math.min(start + 1024 * 1024 - 1, fileSize - 1); // 每次最多传 1MB response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize); response.setHeader("Content-Length", String.valueOf(end - start + 1)); // 4. 分段写入 try (RandomAccessFile raf = new RandomAccessFile(file, "r"); OutputStream out = response.getOutputStream()) { raf.seek(start); byte[] buffer = new byte[8192]; int len; long toRead = end - start + 1; while ((len = raf.read(buffer, 0, (int) Math.min(buffer.length, toRead))) != -1 && toRead > 0) { out.write(buffer, 0, len); toRead -= len; } } } else { // 5. 全量下载 try (InputStream in = new FileInputStream(file); OutputStream out = response.getOutputStream()) { in.transferTo(out); } } } private String getContentType(String fileName) { String ext = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); return switch (ext) { case "jpg", "jpeg", "png", "gif" -> "image/" + ext; case "pdf" -> "application/pdf"; case "txt" -> "text/plain"; case "xlsx", "xls" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; default -> "application/octet-stream"; }; } private String getContentDisposition(String fileName) throws UnsupportedEncodingException { String userAgent = request.getHeader("User-Agent"); String encodedName; if (userAgent != null && userAgent.contains("MSIE")) { // IE 兼容 encodedName = URLEncoder.encode(fileName, "GBK"); } else { // 标准 RFC 5987 encodedName = MimeUtility.encodeText(fileName, "UTF-8", "B"); } return "attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + URLEncoder.encode(fileName, "UTF-8"); }实操心得:Nginx 默认不透传
Range请求头,需在 proxy_pass 配置中加proxy_set_header Range $http_range;,否则断点续传永远失效。另外,Content-Disposition的双filename写法是浏览器兼容性最佳实践,现代浏览器用filename*=,老 IE 退化到filename=。
5. 常见问题与排查技巧实录:那些让你加班到凌晨的 Bug
5.1 典型问题速查表
| 现象 | 可能原因 | 排查命令/步骤 | 解决方案 |
|---|---|---|---|
MultipartFile为空,isEmpty()返回 true | 1. 表单enctype缺失2. name属性不匹配3. CommonsMultipartResolver Bean 名称错误 | 1. Chrome DevTools → Network → Headers → 查看Content-Type2. curl -F "files=@test.txt" http://localhost:8080/api/file/upload测试 | 补全enctype,核对name,确认 Bean 名为multipartResolver |
| 上传大文件时页面卡死,无响应 | 1.maxUploadSize配置过小2. 临时目录磁盘满 3. Tomcat maxPostSize限制 | 1.df -h /data/tmp查磁盘2. tail -f logs/catalina.out看是否有FileUploadBase$SizeLimitExceededException | 调大maxUploadSize,清理磁盘,检查 Tomcatserver.xml中Connector的maxPostSize |
| 下载中文文件名乱码(IE 显示问号) | 1.Content-Disposition未做 IE 兼容2. 服务器默认编码非 UTF-8 | 1. `locale -a | grep zh_CN<br>2.java -XshowSettings:properties -version 2>&1 |
| 上传成功但文件内容损坏(图片打不开) | 1.file.getInputStream()被多次调用2. 使用 FileOutputStream而非transferTo | 1. 检查代码中是否对同一MultipartFile调用多次getInputStream()2. `md5 |