Spring Boot 文件上传:从 MultipartFile 到可访问的 URL
📝 笔记摘要:Spring Boot 文件上传的四段链路
文件上传看起来就是一行 transferTo,实际上是四段各管一件事的流程:容器接住请求、落进临时盘、业务代码把文件搬走、最后把磁盘目录映射成 URL。每一段都有自己独立的配置项和失败点,混在一起排查就会一直找不到根因。
代码按 Spring Boot 4.1.1(Spring Framework 7.0.9 / Servlet 6.1 / Tomcat 11)写,踩的坑也按这个版本说:transferTo 的相对路径陷阱、原始文件名的路径穿越、中文文件名下载乱码、临时目录配错键名不生效。
这篇的结构
1. 一次上传请求要穿过四段
2. 搭一个能跑起来的环境
3. 接收:MultipartFile 与两种参数注解
4. 落盘:transferTo 的相对路径陷阱
5. 文件名、目录与路径穿越
6. 临时目录与上传大小限制
7. 把磁盘目录变成可访问的 URL
8. 下载:中文文件名与流式响应
9. 这一档的边界在哪
1. 一次上传请求要穿过四段
浏览器把文件塞进 multipart/form-data 请求体发过来,文件并没有直接落到你的业务目录。中间隔着容器和业务代码两跳。
第 1 段 浏览器提交
文件以 part 的形式随请求体上来,此刻还没落盘
第 2 段 容器收进临时盘
超过阈值就落spring.servlet.multipart.location,请求结束自动清理
第 3 段 业务代码搬运
transferTo()落到业务目录,路径和文件名在这一段定
第 4 段 对外提供访问
addResourceHandlers把磁盘目录映射成 URL 前缀,文件才真正能被下载

四段各有一组配置项,任何一段没配对,报错都长得不一样:
第 1 段 没配
max-file-size,默认单文件上限只有 1MB,超了直接 413
第 2 段 临时目录不存在或没权限,启动就报The temporary upload location [...] is not valid
第 3 段 目录用mkdir()建,多级路径静默失败,文件不落盘
第 4 段 映射没配,文件存进去了但访问就是 404
2. 搭一个能跑起来的环境
新建一个 Spring Boot 项目,勾上 spring-boot-starter-web 和 spring-boot-starter-thymeleaf(要渲染上传页面才需要后者),模板引擎选 Freemarker 或 Thymeleaf 都行。

提示:截图里的版本号是当年选 2.7.4 时的样子。2.x 已经停止维护了,现在新项目直接选 3.5.x 或 4.x,Java 基线是 17。
依赖就一个:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
前端页面用 enctype="multipart/form-data",这个属性漏了,浏览器会把文件名当普通文本发过去,后端拿到空文件:
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<button type="submit">上传</button>
</form>
3. 接收:MultipartFile 与两种参数注解
Spring 把上传的文件包成 MultipartFile。真正用得上的方法是这几个:
getOriginalFilename() 客户端传过来的原始文件名,不可信,只用来显示
getSize() 字节数,配合max-file-size判断
getContentType() MIME 类型,同样由客户端声明,不等于文件真实类型
getInputStream() 拿内容流,大文件走它
transferTo() 把内容搬到目标位置,搬完临时文件就没了
两个参数注解都能接文件,选哪个有区别:
@RequestParam("file") 按表单字段名绑定,文件名对不上就报 400。最常用
@RequestPart("file") 按 part 绑定,可以带consumes指定媒体类型,multipart 表单里还能用它直接绑定 POJO
区别在于 @RequestPart 多了一层媒体类型筛选的能力,@RequestParam 没有。普通文件上传用 @RequestParam 就够,需要按 image/* 之类分流的时候再换 @RequestPart。
@RestController
@RequestMapping("/upload")
public class UploadController {
@PostMapping(value = "/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String upload(@RequestParam("file") MultipartFile file) {
return "收到 " + file.getOriginalFilename();
}
}
4. 落盘:transferTo 的相对路径陷阱
这一步是最容易出玄学故障的地方。文件传完了,接口返回成功,目标目录里却什么都没有。
根源在 MultipartFile.transferTo(File) 的实现。它内部先调 part.write(dest.getPath()),而 Servlet 规范规定,Part.write() 遇到相对路径时,是以容器的工作目录为基准解析的,不是以你的进程工作目录为基准。只有在 dest 是绝对路径、且文件还不存在时,Spring 才会额外做一次兜底拷贝把它搬到正确位置。
所以这段代码会“成功”,但文件跑到别处去了:
// 有坑:dest 是相对路径
File dest = new File("uploads", file.getOriginalFilename());
file.transferTo(dest);
// 文件实际落在
// /tmp/tomcat.xxxxxx/work/Tomcat/localhost/ROOT/uploads/xxx
// 这类容器临时目录里,请求结束就被清掉
改成绝对路径,并且用 Path 重载:
Path root = Paths.get("/data/uploads").toAbsolutePath().normalize();
Path target = root.resolve("2026/10/20").resolve(safeName);
Files.createDirectories(target.getParent());
file.transferTo(target); // Path 重载:直接写到 target 指定的位置
提示:
transferTo(Path)从 Spring Framework 5.1 起就有了,Spring Boot 2.x 后期和 3.x、4.x 都能用。它内部直接拷贝到给定路径,没有File重载那层判断,用它最省心。
排查这类问题的特征很好认:报错路径里出现 /tmp/、/private/var/folders/、Tomcat/localhost,基本就是相对路径被容器解析走了。
5. 文件名、目录与路径穿越
getOriginalFilename() 里的内容是客户端给的,它可能带着目录分隔符,也可能压根是 ../../etc/passwd 这种东西。直接拼进落盘路径,等于把服务器的目录结构交给客户端决定。
处理原则有三条:
只取文件名 用
Paths.get(original).getFileName()截掉目录部分
目录由服务端决定 按日期或业务 ID 分目录,不接受客户端传的路径
存储名可以改 用 UUID 或雪花 ID 重命名,原始名字存进数据库字段
String original = file.getOriginalFilename();
if (original == null || original.isBlank()) {
throw new IllegalArgumentException("文件名为空");
}
// 截掉可能存在的目录部分,只留最后一段
String base = Paths.get(original).getFileName().toString();
// 去掉控制字符和路径分隔符
String clean = base.replaceAll("[\\p{Cntrl}]", "_");
// 存储名用服务端生成的,原始名只作展示
String stored = UUID.randomUUID().toString().replace("-", "");
String ext = "";
int dot = clean.lastIndexOf('.');
if (dot > 0) {
ext = clean.substring(dot);
}
目录用 Files.createDirectories 建,不要用 File.mkdir():后者只建一级,父目录不存在时直接返回 false 而且不抛异常,文件自然也落不下去。createDirectories 会把整条路径逐级建出来,已经存在也不报错。
6. 临时目录与上传大小限制
这组配置的键名很容易记错,尤其是临时目录那一项:
spring:
servlet:
multipart:
enabled: true
max-file-size: 20MB
max-request-size: 25MB
file-size-threshold: 2MB
location: /data/tmp/spring-multipart
提示:临时目录的配置项是
spring.servlet.multipart.location,没有local这个键。写错了 Spring Boot 不报错,这一段配置被静默忽略,临时目录照旧用系统临时目录,行为上完全看不出来。
各项的含义和默认值:
max-file-size 单文件上限,默认 1MB。不配的话传个 5MB 就 413
max-request-size 整个 multipart 请求上限,默认 10MB。多文件上传时它才是先撞墙的那个
file-size-threshold 超过这个大小才落临时盘,默认 0B。调小一点能让小文件走内存,省一次磁盘 IO
location 临时目录。不配就用系统临时目录,Linux 下是/tmp,Windows 下是用户目录下的Temp
临时目录有两个硬要求:目录得存在,运行应用的用户得对它有读写权限。权限不够时会在启动阶段抛 The temporary upload location [...] is not valid,或者上传时抛 FileNotFoundException (Permission denied)。
正常流程下 Spring 会在请求结束后自动清掉临时文件。异常中断的处理链路时(比如没 catch 住异常),临时文件会残留,磁盘慢慢被撑满。所以临时目录要挂在能监控容量的盘上,别丢进系统盘就不管了。
提示:上传大小限制不止 Spring 这一层。前面挂了 Nginx 的话,它默认
client_max_body_size只有 1m,请求在 Nginx 就被挡了,压根到不了应用。改 Spring 的配置没反应时,先看代理层。
7. 把磁盘目录变成可访问的 URL
Spring Boot 打成的是 jar 包,static 目录里的东西没法像传统 war 包那样直接访问。要让磁盘上的文件能通过 HTTP 取到,得把目录映射成 URL 前缀。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${file.upload-dir}")
private String uploadDir;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// toUri() 生成 file:///D:/data/uploads/ 这种带三斜杠的绝对 URI
String location = Paths.get(uploadDir).toAbsolutePath().normalize().toUri().toString();
registry.addResourceHandler("/uploadfile/**")
.addResourceLocations(location);
}
}
手拼 "file:" + path 是个常见的写法错误。Windows 上路径是 D:/data/uploads,拼出来是 file:D:/data/uploads,只有两个斜杠,浏览器按相对 URI 解析,访问不到。交给 toUri() 生成才稳。
file:
upload-dir: D:/data/uploads
映射好之后,文件就能通过 /uploadfile/ 前缀访问:

注意:资源映射是静态资源,不走任何鉴权。配了之后这个前缀下所有文件都是公开的,谁拿到 URL 谁能下。带用户隔离的附件目录不要用静态映射,用 Controller 自己控权限。
8. 下载:中文文件名与流式响应
下载接口别用 getBytes(),那会把整个文件读进内存,一个几百 MB 的文件就能把堆打满。走流:
@GetMapping("/download")
public ResponseEntity<StreamingResponseBody> download(@RequestParam String name) throws IOException {
Path root = Paths.get(uploadDir).toAbsolutePath().normalize();
Path file = root.resolve(name).normalize();
// 防目录穿越:目标必须还在 root 里面
if (!file.startsWith(root) || !Files.exists(file)) {
return ResponseEntity.notFound().build();
}
String filename = file.getFileName().toString();
ContentDisposition disposition = ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build();
StreamingResponseBody body = out -> {
try (InputStream in = Files.newInputStream(file)) {
in.transferTo(out);
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(Files.size(file))
.body(body);
}
中文文件名这里有个坑:HTTP 头只允许 ASCII,中文要按 RFC 5987 编码成 filename*=UTF-8''%E4%B8%AD%E6%96%87.jpg。用 URLEncoder.encode() 编到 filename= 后面是旧做法,浏览器解出来的文件名会是乱码。用 Spring 的 ContentDisposition 工具类,两种形式它都会给。
9. 这一档的边界在哪
本机磁盘存文件是能用的最小形态,它的问题不在代码,在部署形态:
多副本不成立 文件跟着实例走,A 节点传的文件 B 节点读不到
容量跟着单机走 磁盘满了只能换盘,还得停机
没有冗余 这块盘坏了文件就没了,没有副本可退
大文件穿应用 上传下载的字节全部过一遍应用进程,带宽和连接数都是它的

要往上一档,代码层的替换点很清楚——把第 3 段的落盘换成一个对象存储客户端,第 4 段的静态映射换成预签名 URL。应用自己那一套 Controller 和前端表单基本不用动。
自建 MinIO 自建对象存储 数据不能出内网、成本敏感的时候用 S3 兼容的自建方案
托管 阿里云 OSS 接入 流量波动大、要可用性 SLA、没运维人力的时候直接上云
顺带一提 AList 容器部署 支持挂 S3 后端,能把对象存储再包装成一个可预览的网盘
💡 补充:文件校验别信 getContentType()。它是客户端在请求头里声明的 MIME 类型,改个后缀名就跟着变。真要拦,得做内容嗅探或者干脆只允许白名单后缀,文件名和大小都别放开。生产环境再加一层病毒扫描,扫描放在临时目录做,扫完再往业务目录搬。
🔥 一句话总结:文件上传是四段流程,容器接、临时盘放、业务代码搬、映射成 URL;四段各配各的,其中 transferTo 一定要给绝对路径,文件名一定要自己生成。
