写这篇的时候,我正被一个文件上传的报错折腾到怀疑人生:前端明明弹了"上传成功",服务端却连文件都没收到。排查到最后发现是拦截器提前拦截了 multipart 请求,把请求流消费掉了,Spring MVC 再去解析 MultipartFile 时只能拿到空对象。这种问题不踩一次坑,光看文档根本想不到。所以这一篇"Springboot学习三"我决定把文件上传和拦截器这两块放一起聊,因为它们在真实项目里经常互相纠缠,单独学任何一个都觉得会了,一组合就处处是雷。
这篇内容适合正在学 Spring Boot 的后端开发,也适合写过一些接口但没系统梳理过上传和拦截逻辑的朋友。我会从文件上传的完整链路讲起,再到后端校验、拦截器实现、常见坑的排查顺序,最后给出一个能直接抄的整合方案。所有代码都基于 Spring Boot 2.7.x,JDK 8 以上都能跑。
1. 文件上传:从表单到 MultipartFile 的完整链路
1.1 配置文件里的上传参数,你真的会设吗
很多人一上来就写 MultipartFile 参数,结果一传大文件就报错,根本原因是对 Spring Boot 的 multipart 自动配置不熟。Spring Boot 通过MultipartAutoConfiguration和MultipartProperties来绑定配置,核心参数就那几个:
spring.servlet.multipart.enabled=true spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=100MB spring.servlet.multipart.file-size-threshold=0B spring.servlet.multipart.location=/tmp/uploads这里最容易踩坑的是max-file-size和max-request-size的区别。前者限制单个文件大小,后者限制整个请求体的大小。如果你一次传多个文件,max-file-size=10MB没问题,但max-request-size=10MB的话,两个 6MB 的文件一起传就会失败,因为总大小超过了请求限制。我见过有人把两个参数配反,调了半天都没发现是配置语义理解错了。
还有file-size-threshold,它表示文件大小达到多少时开始写入磁盘临时目录,低于这个阈值直接保存在内存里。默认是 0,意味着所有上传文件都会先写磁盘。如果你的服务器内存够大、上传的文件又比较小,可以把这个阈值设成 1MB 或 2MB,减少磁盘 IO,但要注意内存占用。location参数指定临时目录,如果不设置,会用 Servlet 容器的默认临时目录。在 Linux 上如果/tmp权限有问题,上传也会失败,这时显式指定一个可写目录是很有用的。
配置完成后,Spring MVC 会把 multipart/resquest 解析成MultipartFile对象。如果你用的是 Spring Boot 内置的 Tomcat,还需要知道 Tomcat 本身也有一个maxSwallowSize之类的参数,不过通常默认值足够了。
1.2 Controller 层接收文件的正确姿势
基础的单文件接收长这样:
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { String originalFilename = file.getOriginalFilename(); long size = file.getSize(); // 业务处理 return Result.success(originalFilename + " 上传成功,大小:" + size); }@RequestParam("file")里的名字必须和前端表单里的字段名一致。很多人忽略这一点,前端传的是uploadFile,后端写的file,结果一直拿不到文件报 400。
多文件上传有两种方式:一个字段多个文件,或多个字段各一个文件。前者的接收参数是List<MultipartFile>或MultipartFile[]:
@PostMapping("/upload/multi") public Result<String> uploadMulti(@RequestParam("files") MultipartFile[] files) { // 遍历处理 }如果前端字段名不固定,可以用@RequestParam接收全部文件,或者直接用HttpServletRequest强转成MultipartHttpServletRequest来获取所有文件映射。不过我不推荐后一种,因为代码可读性差,而且一旦请求不是 multipart 类型就会强转失败。
还有一种是 UploadFile 和普通表单字段混在一起,比如上传文件的同时传一个备注字段:
@PostMapping("/upload/with-form") public Result<String> uploadWithForm(@RequestParam("file") MultipartFile file, @RequestParam("remark") String remark) { // 处理 }这种写法在 Spring MVC 里是支持的,因为它会先解析 multipart 请求,然后把非文件字段也绑定到@RequestParam上。
1.3 用存储策略把上传逻辑变干净
Controller 里直接写FileOutputStream存文件是最简单的做法,但后续改存储位置、加云存储、加文件服务都要动 Controller,代码会很丑。我习惯定义一个存储接口,然后根据需要实现本地存储、MinIO 存储、OSS 存储等。
public interface FileStorage { String store(MultipartFile file, String pathPrefix); }本地实现大致如下:
@Component public class LocalFileStorage implements FileStorage { @Value("${custom.upload-dir:./uploads}") private String uploadDir; @Override public String store(MultipartFile file, String pathPrefix) { String path = generateStorePath(file.getOriginalFilename(), pathPrefix); File dest = new File(uploadDir + File.separator + path); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } try { file.transferTo(dest); } catch (IOException e) { throw new RuntimeException("文件存储失败", e); } return path; } }这里有个关键点:file.transferTo()会直接把 multipart 临时文件移动到目标位置,效率比file.getInputStream()再手动复制高。但如果目标文件已存在,transferTo 在不同平台的表现不一样,有些会直接覆盖,有些会报错。稳妥的做法是先删除旧文件或生成唯一文件名。
生成存储路径时,我习惯按日期分目录,文件名用 UUID 加后缀,避免重名和路径混乱:
private String generateStorePath(String originalFilename, String pathPrefix) { String suffix = ""; int dotIndex = originalFilename.lastIndexOf('.'); if (dotIndex >= 0) { suffix = originalFilename.substring(dotIndex); } String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String uuidName = UUID.randomUUID().toString().replace("-", "") + suffix; return (pathPrefix == null ? "" : pathPrefix + "/") + datePath + "/" + uuidName; }这样一来,Controller 里的上传逻辑就简化为"校验、调存储、返回路径",后续就算要接对象存储,只需要新增一个OssFileStorage实现类,替换 Bean 即可。很多入门教程只教怎么把文件写进磁盘,但实际项目要面对的是"今天用本地,明天上云"的常态,提前做一层抽象非常划算。
2. 上传校验不能只信前端:后端防御才是真防线
2.1 文件类型白名单:只看后缀等于没防
前端用<input type="file" accept=".jpg,.png">只是让用户在选择文件时少看到无关类型,完全不能作为安全依据。攻击者可以随便改后缀,或者直接用 curl 传一个伪装成图片的脚本文件。后端如果只校验getOriginalFilename().endsWith(".jpg"),基本等于裸奔。
更稳的做法是双重校验:先看后缀,再看文件内容头。文件内容头(Magic Number)是文件开头几个字节的固定值,比如 JPEG 通常是FF D8 FF,PNG 是89 50 4E 47,PDF 是25 50 44 46。后端读取文件头几个字节判断真实类型:
private boolean isAllowedImage(MultipartFile file) throws IOException { byte[] header = new byte[4]; try (InputStream in = file.getInputStream()) { in.read(header, 0, 4); } if ((header[0] & 0xFF) == 0x89 && header[1] == 'P' && header[2] == 'N' && header[3] == 'G') { return true; } if ((header[0] & 0xFF) == 0xFF && (header[1] & 0xFF) == 0xD8) { return true; } return false; }注意,只取前 4 个字节判断 JPEG 其实不够完整,JPEG 文件头是FF D8 FF,至少判断前 3 个字节。更严谨的做法可以用 Apache Tika 或ImageIO来识别,但 ImageIO 对很多图片格式支持有限,Tika 能识别更多格式,代价是引入额外依赖。
还有一个容易被忽略的点:getContentType()这个方法获取的是请求头里带的 Content-Type,而这个值完全由客户端控制,不能作为唯一依据。比如一个 HTML 文件,攻击者可以把 Content-Type 手动改成image/png。所以白名单校验必须基于文件内容,而不是请求头。
2.2 文件大小与请求大小的双重控制
前面提到的max-file-size是 Spring 层面的限制,但最好在业务代码里也校验一次。原因是 Spring 的限制会直接抛出MaxUploadSizeExceededException,这个异常如果你的全局异常处理器没接住,返回给前端的就不是友好的 JSON 而是错误页面。自定义校验的好处是可以返回明确的中文提示。
if (file.getSize() > 10 * 1024 * 1024) { throw new BizException("文件大小不能超过10MB"); }如果涉及压缩包或视频,注意max-request-size要留足 buffer,因为 form-data 格式会把每个文件字段加上分隔线和 Content-Disposition 头,这些字节也算在请求大小里。比如传一个 9.9MB 的文件,max-request-size设为 10MB 可能刚好卡在边界,导致偶尔成功偶尔失败。我习惯把max-request-size设置成max-file-size的 2 倍以上。
2.3 文件名与路径安全:绕过路径穿越
文件名校验不只是为了防脚本执行,还涉及到路径穿越问题。攻击者可以把文件名构造为../../etc/passwd,如果你的存储路径是uploadDir + filename,就可能把文件写到 uploadDir 之外的目录,甚至覆盖系统文件。
所以无论前端传什么文件名过来,都不要直接拼路径。正确做法是:存储时使用我自己生成的 UUID 文件名,原始文件名最多只作为元数据存数据库,根本不参与路径拼接。如果需要保留原名,也要过滤掉/、\、..等特殊字符:
private String sanitizeFilename(String filename) { // 只保留最后一个文件名的合法部分,去掉路径 filename = filename.replaceAll("[\\\\/]", "_"); filename = filename.replaceAll("\\.\\.", "_"); return filename; }另外,如果业务上确实需要按照用户上传的文件名展示给用户下载,存数据库时单独存一个字段:original_name,存储路径用 UUID 文件名。下载时再把 original_name 放进Content-Disposition响应头,并做 URL 编码,避免文件名里有中文、空格导致下载异常。
2.4 上传漏洞的通用防守思路
网上关于文件上传漏洞的讨论很多,比如脚本文件上传、Apache 解析漏洞、.htaccess 攻击等。这些漏洞的核心原因大多是:服务端对文件类型校验不严格、文件存储位置放在了 Web 可执行目录下、服务器配置存在解析顺序问题。要防守,我的经验是遵循这几个原则:
- 永远不要信任用户的文件名与 Content-Type。
- 使用白名单判断文件类型和扩展名,优先校验文件内容。
- 存储文件时重新生成文件名,采用随机文件名并保留小写后缀。
- 将上传目录设置为不解释脚本语言。如果你用 Nginx,可以在 upload 目录的 location 中配置
php、jsp等脚本后缀不执行;如果你用的是 Spring Boot 内置 Tomcat,静态资源默认不会执行脚本,但假如你把文件存到 classpath 下就有风险。最保险的方式是:文件不要放在 Web 应用可访问的目录里,而是放在应用外的某个目录,单独写一个受控的文件读取接口来输出文件流。 - 如果系统必须支持用户上传 HTML/SVG 文件,展示时需要防止 XSS,比如下载时强制
Content-Disposition: attachment,或者对所有内容做转义。
有一类针对上传组件本身的攻击叫"上传 XSS",尤其在用户上传 SVG 文件时,SVG 内部可以包含<script>标签,如果服务端直接把 SVG 作为图片展示,就可能执行恶意脚本。对这类文件要么不允许上传,要么上传后主动清除脚本标签,或者强制作为附件下载而不是在线预览。
3. 拦截器:在请求到达 Controller 之前把好门
3.1 拦截器和 Filter 到底选谁
很多新手分不清拦截器(HandlerInterceptor)和过滤器(Filter)。简单说:
- Filter 是 Servlet 规范的一部分,在请求进入 Servlet 容器后、进入 Spring MVC 的 DispatcherServlet 之前执行,可以拿到原始的 ServletRequest/ServletResponse。
- HandlerInterceptor 是 Spring MVC 的机制,在请求已经被映射到具体 Handler 之后、进入 Controller 方法之前执行,可以拿到 HandlerMethod。
对于"登录校验、Token 验证、接口权限"这类需求,用 HandlerInterceptor 更合适,因为你能拿到具体的 HandlerMethod,自由度更高,也能通过返回值决定是否放行。而对于"字符编码、跨域、XSS 过滤"这类通用处理,用 Filter 更合适,它作用域更广,甚至可以拦截静态资源。
在 Spring Boot 项目中,这两种方式都可以注册。拦截器注册为WebMvcConfigurer的addInterceptors方法,过滤器注册为FilterRegistrationBean。如果你的需求是"所有 /api/ 下的请求必须带 token",拦截器就够了,代码也更简洁。
3.2 实现一个 Token 验证拦截器
我写一个最常见的登录 Token 拦截器。假设前端请求头里带Authorization: Bearer <token>,后端从 Redis 里查 token 是否存在且未过期:
@Component public class TokenInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求(OPTIONS),否则跨域场景下前端会报错 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } // 判断 handler 是否是方法处理器 if (!(handler instanceof HandlerMethod)) { // 非控制器方法(如静态资源)直接放行,由注册规则决定 return true; } String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { writeUnauthorized(response, "未登录或token缺失"); return false; } String token = authHeader.substring(7); String userId = redisTemplate.opsForValue().get("login:token:" + token); if (userId == null) { writeUnauthorized(response, "token无效或已过期"); return false; } // 把用户信息放进 request 属性,后续 Controller 可以直接取 request.setAttribute("userId", userId); return true; } private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"" + msg + "\"}"); } }这里有几个细节值得展开:
OPTIONS预检请求必须放行,否则跨域配置虽然写了,但实际带自定义 Header 的请求会被拦截器拦截,导致"Access-Control-Allow-Headers"报错。网上很多跨域问题最后查出来都是拦截器把 OPTIONS 拦了。- 往
request.setAttribute()里放当前用户信息,是跨层传递用户态的一种简单方式。Controller 方法里通过@RequestAttribute("userId") Long userId获取。如果你不想频繁写这个注解,也可以定义一个UserContext线程变量工具类,但一定要在请求结束后清理,否则线程池复用时会有数据串。用 interceptor 的afterCompletion里清空比较稳妥。 - 用 Redis 存 token 时,登录成功后要给 token 设置过期时间,并且每次请求刷新过期时间,实现"操作自动续期"。如果你用 RedisTemplate,可以用
expire方法。
3.3 拦截器注册与放行规则
注册拦截器很简单,关键是理清放行规则。我见过太多项目,在addInterceptors里把/**全拦截了,然后忘了放行登录接口,结果用户压根没法登录。
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private TokenInterceptor tokenInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(tokenInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/user/login", "/api/user/register", "/api/upload/**", // 考虑上传接口是否需要token "/doc.html", "/webjars/**", "/swagger-resources/**" ); } }放行规则的设计要结合实际业务:
- 登录、注册、验证码、短信发送、公开配置等接口必须放行。
- Swagger/Knife4j 的文档路径要放行,否则你调接口文档时因为跨域或 token 问题连文档都打不开。
- 如果你的上传接口是登录后才能用的,那不要放行
/upload/**,而是让上传请求也带 token。但要记得,上传接口通常存在 CORS 预检问题,拦截器里放行了 OPTIONS 就可以。 - 静态资源是否要拦截?Spring Boot 中
spring.mvc.static-path-pattern默认是/**,如果你的拦截路径也是/**,那么静态资源也会被拦。通常建议拦截路径写/api/**,这和静态资源天然隔离。如果一定要拦截全部,需要额外排除静态资源路径,比较繁琐。
上传接口和拦截器的结合点在于:multipart 请求的 body 是流式的,如果拦截器在preHandle里调用了request.getParameter()或读取了 inputStream,那么后续 Spring MVC 再去解析 multipart 文件时,就可能因为流被消费而失败。这是本篇文章开头提到的坑的根源。所以,如果拦截器不需要读取上传请求的 body,就千万不要在拦截器里碰流和参数。如果需要记录日志或检查业务参数,建议在进入 Controller 之后再处理,或者使用 ContentCachingRequestWrapper 包装请求,避免破坏原始流。
4. 从一次上传失败到定位问题:Multipart 请求解析排查实录
4.1 场景复现:前端报错 413 / 500 / 文件为空
我整理了一个常见的排查表,帮助快速定位:
| 现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| 前端报 413 Request Entity Too Large | 服务端max-request-size超限 | 检查 Spring 配置与 Nginx client_max_body_size |
| Controller 拿到 File 为空但前端显示成功 | 请求体被某种 Filter/Interceptor 预先读取 | 检查拦截器和 Filter 是否读取了流或调用了 getParameter |
| 上传大文件偶发失败 | 超时或临时目录权限问题 | 检查代理超时时间与location目录权限 |
| 后端抛 MaxUploadSizeExceededException | 单文件超限 | 检查max-file-size |
| 上传图片后页面不显示 | 存储路径与访问路径不一致 | 检查Content-Disposition与实际文件 URL |
| 跨域下上传失败 | OPTIONS 预检没放行 | 检查拦截器对 OPTIONS 的处理 |
4.2 排查链路:从前端到 Spring MVC 一层层扒
有一次我在一个老项目里遇到上传 PDF 后,Controller 里参数有值,但 InputStream 读出来是空,排查过程是这样的:
第一步,先用浏览器 F12 看网络请求。发现请求头里Content-Type是multipart/form-data; boundary=----WebKitFormBoundary...,说明前端没问题。然后看请求体,文件内容也在。
第二步,在后端拦截器里打印request.getParameter("remark"),发现能打印出来。就是这一步把请求流给消费了。在 Servlet 规范中,getParameter()如果遇到 multipart 请求,必须解析请求体才能拿到表单字段,这会导致后续框架解析 multipart 时得到空数据。所以当请求体是 multipart 时,拦截器绝对不要调用getParameter()、getInputStream()、getReader()这类方法。如果非要在拦截器里获取表单字段,可以用 Spring 提供的StandardServletMultipartResolver先解析,但那就得注意后续框架的 double parsing 问题,更复杂。
第三步,如果排除了拦截器问题,可以检查项目里有没有自定义的 Filter 用了OncePerRequestFilter,并且在doFilterInternal里通过wrapper.getParameter()取参数。这种情况同样会污染 multipart 请求。
第四步,检查 Spring Boot 版本。在某个版本的 Spring Boot 中,MultipartAutoConfiguration的解析器存在一些对请求流的处理差异,但大多数时候不是版本问题,而是我们自己吃了流。我遇到过最离谱的是别人在拦截器里写了日志切面,把请求体输出成 string 方便看日志,导致所有上传接口集体"404 或空文件"。
4.3 Nginx 和代理层的身位问题
Spring Boot 应用前面如果挂了 Nginx,上传文件还需要注意 Nginx 的client_max_body_size。默认是 1m,也就是说超过 1MB 的文件到不了后端就直接被 Nginx 拒了。很多人只调了 Spring 的max-file-size,却没调 Nginx,于是本地能传,测试环境不能传。调法是在 Nginx 的 http、server 或 location 里加:
client_max_body_size 20m;注意,client_max_body_size不仅限制 multipart 上传,也限制整个请求体。如果前端上传走的是跨域直连后端,不经过 Nginx,那这条可以忽略。
另外还有一层隐藏的代理:Spring Cloud Gateway、Apache Httpd、CDN。每一层都可能故意或无意地限制请求体大小。我之前还碰到过 Apache2 配置了请求体大小限制导致上传大文件失败的情况。排查这类问题有个笨办法:先绕过所有代理直连应用,如果直连没问题,问题就在代理层;如果直连也有问题,再在应用内部排查。
4.4 临时目录与磁盘空间
Spring Boot 上传文件默认写入系统临时目录。在 Linux 上如果/tmp是 noexec 或空间不足,或者服务以某个低权限用户运行,就可能导致上传后转移文件失败。解决办法是给spring.servlet.multipart.location设置一个应用自己的临时目录,并且定时清理残留文件。
还要注意磁盘空间。如果上传目录所在磁盘满了,transferTo会抛 IOException,而且这种问题很难从日志中一眼看出,因为异常信息可能是 "No space left on device"。我习惯在上传接口里加一个简单的磁盘占用检查:如果磁盘使用率超过 90%,直接拒绝上传并提示。具体代码可以封装到存储实现里:
File storeDir = new File(uploadDir); long usable = storeDir.getUsableSpace(); long total = storeDir.getTotalSpace(); if (usable * 100 / total < 10) { throw new BizException("存储空间不足,请联系管理员"); }5. 整合实践:文件上传 + 拦截器 + 静态资源访问的安全边界
5.1 拦截器里读不到上传参数?换一种设计
之前说拦截器不要碰 multipart 请求的参数,那如果业务上有需求,比如根据表单里的某个字段决定要不要执行某个校验,怎么办?我的做法是:把"要不要校验"放到路径规则里,而不是靠参数判断。实在需要参数的,可以在进入 Controller 之后再做二次校验。
举个例子,如果需求是"上传身份证时必须带 gender 字段",你可以在 Controller 方法里直接校验gender参数,用全局异常处理器返回统一错误码。不要在拦截器里读参数,这违反了边界。
还有更复杂的场景:上传接口有些放行、有些需要 token。我的建议是拆多个接口路径:
/api/upload/public/**:公开上传,用于游客上传头像(如果需要)/api/upload/secure/**:需要 token
这样在拦截器注册时直接按路径区分,规则清晰,代码也好维护。
5.2 上传文件的安全访问:避免 Web 容器直接暴露脚本
文件存储到磁盘后,怎么让用户看到?很多人直接把文件放在static/upload下面,然后通过http://localhost:8080/upload/xxx.jpg访问。这种方式很简单,但隐患在于:如果上传目录里出现了.jsp、.html、.svg之类的文件,就可能被 Web 容器直接执行或预览。更好的方案是做一个"文件读取接口",把文件目录放在 classpath 之外:
@GetMapping("/files/{datePath}/{filename}") public ResponseEntity<Resource> getFile(@PathVariable String datePath, @PathVariable String filename, @RequestParam(required = false) String download) throws IOException { Path filePath = Paths.get(uploadDir).resolve(datePath).resolve(filename).normalize(); // 校验路径是否越界 if (!filePath.startsWith(uploadDir)) { return ResponseEntity.badRequest().build(); } Resource resource = new FileSystemResource(filePath); if (!resource.exists()) { return ResponseEntity.notFound().build(); } String contentType = Files.probeContentType(filePath); if (contentType == null) { contentType = "application/octet-stream"; } return ResponseEntity.ok() .contentType(MediaType.parseMediaType(contentType)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(filename, StandardCharsets.UTF_8) + "\"") .body(resource); }注意normalize()和startsWith的路径越界校验,防止用户把datePath或filename构造为../../穿越出去。如果不需要下载,而是预览图片,可以去掉Content-Disposition头。但为了安全,我一般建议对非图片格式一律强制下载。
5.3 配合拦截器做文件权限控制
有时候文件不是公开的,需要登录或授权才能访问。这时候可以给文件读取接口加拦截器:
registry.addInterceptor(tokenInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/user/login", "/api/upload/public/**", "/api/files/public/**");而/api/files/{...}保持需要 token,拦截器校验完 token 之后放行,Controller 再去判断有没有权限访问这个文件。如果文件涉及用户隐私,记得在文件元数据表里存 ownerId,读取时校验 ownerId 或角色,防止越权访问他人文件。这在网盘类项目、个人信息类项目中真的很重要。
5.4 后续扩展:云存储、异步上传、大文件断点续传
本地文件存盘只是入门。生产环境我更推荐使用 MinIO 或云对象存储,因为对象存储天然支持桶策略、防盗链、CDN 回源,而且不需要自己处理服务器磁盘扩容。接入 MinIO 时,我只需要在上面的FileStorage接口下新增一个MinioFileStorage实现:
@Component @ConditionalOnProperty(name = "custom.file.storage", havingValue = "minio") public class MinioFileStorage implements FileStorage { ... }使用@ConditionalOnProperty可以在不修改业务代码的情况下,通过配置切换本地存储和 MinIO 存储。如果你用的 Spring Boot 2.7+,还可以用自动配置类(@AutoConfiguration)来统一装配,不过对大多数项目来说,@ConditionalOnProperty已经够用了。
异步上传是另一个方向。对超大文件或视频类上传,同步接口会占用线程很久,甚至拖垮 Tomcat。可以考虑先同步接收并校验元数据,然后使用队列异步转存到对象存储。比如用 Spring 的@Async或者消息队列来处理转码、压缩任务。这里的核心是:不要在上传请求线程里做太重的 IO 和计算,先把文件保存到临时空间,然后立刻返回"上传成功,处理中"。
还有断点续传,那就要前端配合分片上传,后端用@RequestParam("chunk")和@RequestParam("chunks")接收分片序号,最后合并。这些是进阶话题,这篇文章先点到为止,等后面单独开一篇断点续传的实战讲。
6. 我在实际项目中的几个收尾习惯
最后分享几个我从这些坑里总结出来的习惯,没什么高深理论,但很管用。
第一个,所有上传接口的返回结构必须统一。成功时返回文件访问 URL 或存储路径,失败时返回错误码和可读提示。前端很依赖这个约定,你后端换存储方案、改路径规则,只要返回结构不变,前端基本不用动。
第二个,上传日志一定要记录文件名、大小、存储路径、上传人、上传结果。一旦出现违规文件或安全问题,这些日志是追溯的凭据。我习惯在存储层打完日志后,再在 Controller 层记录一条业务日志,包含上传者 IP 和设备类型。
第三个,别忽略测试用例。文件上传的接口测试至少覆盖:空文件、超限文件、错误类型、伪装后缀、路径穿越、并发上传。这些用例写起来不难,但能有效避免后续改代码时把上传功能改坏。结合 MockMvc 的multipart()方法可以直接模拟上传请求:
mockMvc.perform(multipart("/api/upload") .file(new MockMultipartFile("file", "test.jpg", "image/jpeg", bytes)) .header("Authorization", "Bearer " + token)) .andExpect(status().isOk());第四个,上传目录要定期清理。临时目录、失败的半成品文件、过期的待合并分片,都会慢慢蚕食磁盘。我一般在项目里加一个 Spring 定时任务,每天凌晨删除超过 24 小时的临时文件,避免"硬盘突然满了"的悲剧。
第五个,controller 层参数用@Validated+@NotNull等注解做基础校验,但文件类型和大小校验建议放在存储层或者独立的校验组件里。因为存储层才是最终真正接触文件的地方,校验放得越靠底,越不会被上层漏掉。
这篇文章从文件上传配置、Controller 实现、后端校验、拦截器设计、问题排查到整合实践,基本覆盖了 Spring Boot 里文件上传和拦截器的常见场景。核心不是记住某个配置项,而是理解 multipart 请求在从客户端到达 Controller 的过程中,哪一层可能在什么时候干扰它,以及如何在设计上避免这些干扰。如果你正在写上传功能,或者被拦截器和上传的交互折腾得头疼,希望这篇文章能帮你省下几个小时的排查时间。