第11课:文件上传下载、跨域CORS配置,SpringBoot4.x跨域新方案

文章目录

    • [1. 课前导读](#1. 课前导读)
    • [2. 知识目标](#2. 知识目标)
    • [3. 理论讲解](#3. 理论讲解)
      • [3.1 Spring Boot 4.x文件上传底层重构](#3.1 Spring Boot 4.x文件上传底层重构)
      • [3.2 MultipartFile核心API与文件上传流程](#3.2 MultipartFile核心API与文件上传流程)
      • [3.3 文件上传的安全防护](#3.3 文件上传的安全防护)
      • [3.4 Spring Boot 4.x multipart配置](#3.4 Spring Boot 4.x multipart配置)
      • [3.5 CORS跨域的本质](#3.5 CORS跨域的本质)
      • [3.6 Spring Boot 4.x CORS四种解决方案](#3.6 Spring Boot 4.x CORS四种解决方案)
      • [3.7 Spring Boot 4.x CORS新变化](#3.7 Spring Boot 4.x CORS新变化)
    • [4. 环境搭建](#4. 环境搭建)
      • [4.1 项目创建](#4.1 项目创建)
      • [4.2 pom.xml](#4.2 pom.xml)
      • [4.3 application.yml](#4.3 application.yml)
    • [5. 代码实战](#5. 代码实战)
      • [5.1 文件存储配置类](#5.1 文件存储配置类)
      • [5.2 文件存储服务](#5.2 文件存储服务)
      • [5.3 异常类](#5.3 异常类)
      • [5.4 文件上传下载Controller](#5.4 文件上传下载Controller)
      • [5.5 CORS全局配置](#5.5 CORS全局配置)
      • [5.6 CORS测试Controller](#5.6 CORS测试Controller)
      • [5.7 启动类](#5.7 启动类)
      • [5.8 前端测试页面](#5.8 前端测试页面)
      • [5.9 运行与验证](#5.9 运行与验证)
    • [6. 底层源码剖析](#6. 底层源码剖析)
      • [6.1 MultipartAutoConfiguration自动配置原理](#6.1 MultipartAutoConfiguration自动配置原理)
      • [6.2 StandardServletMultipartResolver的工作机制](#6.2 StandardServletMultipartResolver的工作机制)
      • [6.3 StandardMultipartHttpServletRequest的文件解析](#6.3 StandardMultipartHttpServletRequest的文件解析)
      • [6.4 CORS处理的源码机制](#6.4 CORS处理的源码机制)
    • [7. 常见坑点与生产问题排查](#7. 常见坑点与生产问题排查)
    • [8. 本章总结](#8. 本章总结)
    • [9. 课后作业](#9. 课后作业)
  • [🔗 SpringBoot 4.x 从入门到企业级实战系列课程](#🔗 SpringBoot 4.x 从入门到企业级实战系列课程)

本文是《SpringBoot 4.x 从入门到精通》系列第11课,全文约13000字,建议阅读时间50分钟。

环境要求:Spring Boot 4.x(基于4.0.x GA),Spring Framework 7.0.x,Java 17+(推荐Java 21/25)


1. 课前导读

上一课我们完成了API响应体系的构建------统一返回格式、全局异常处理和Problem Details。你已经能够写出格式规范、异常清晰的REST接口了。

但真实的企业级Web应用,除了处理JSON数据,还有两个绕不开的刚需:文件上传下载 和跨域请求处理。

文件上传下载听起来简单------无非是接收一个MultipartFile,调用transferTo()写入磁盘,下载时返回Resource。但就是这看似简单的功能,却是生产环境事故的高发区 :路径遍历攻击(用户上传的文件名带有../../,可能覆盖系统文件)、文件名冲突(两个用户上传同名文件相互覆盖)、内存溢出(4GB大文件直接缓冲到内存导致OOM)、恶意文件上传(上传可执行文件欺骗其他用户运行)------每一个都是真实发生过的安全事件。

跨域问题同样是前后端分离架构中的高频痛点。浏览器的同源策略会拦截协议、域名、端口任一项不同的请求,而前后端分离项目天然就是跨域的。更隐蔽的是OPTIONS预检请求被拦截------对于PUT、DELETE等非简单请求,浏览器会先发送OPTIONS请求,如果后端没有正确处理,真实请求根本不会发出。

Spring Boot 4.x在这个领域带来了三个你必须知道的变化。

第一,文件上传底层彻底重构。 Spring Boot 4.x完全抛弃了依赖十几年的commons-fileupload第三方库,基于Servlet 3.0原生Part规范从零重构了文件上传底层实现。这意味着配置属性从spring.http.multipart.*迁移到了spring.servlet.multipart.*,旧的CommonsMultipartFile类已被彻底移除。

第二,CORS配置能力增强。 Spring Boot 4.x对allowedOriginPatterns的支持更加完善,支持通配符模式匹配动态来源域名。在AOT(提前编译)模式下,CORS配置的处理时机更早,与Spring Security 7的深度整合也让跨域配置与安全框架的协作更加顺畅。

第三,生产环境安全配置要求更严格。 allowedOrigins不能设为"*"当allowCredentials为true时------这是Spring Framework 7.0中强制执行的规则,违反会直接抛出异常。

本节课将系统讲解Spring Boot 4.x的文件上传下载与跨域CORS配置,从底层重构原理、安全防护实践,到四种跨域解决方案的完整对比,帮你把这两个"简单但危险"的功能做到生产级。

前置知识:第9课的Web开发基础、第10课的响应处理、HTTP协议基础(Content-Type、Header)。

学习难点 :理解MultipartFile底层从commons-fileupload到Servlet原生API的迁移原理;掌握文件上传的安全防护措施(路径遍历、类型白名单、文件名清理);区分简单请求与预检请求的触发条件;掌握allowedOrigins与allowedOriginPatterns的适用场景差异。

【配图描述:Spring Boot 4.x 文件上传下载与CORS跨域全景图------从Multipart解析、文件安全存储到跨域请求处理的完整链路】


2. 知识目标

学完本节课,你必须掌握以下核心知识点:

  1. Spring Boot 4.x文件上传底层重构 :从commons-fileupload到Servlet原生Part规范的迁移,StandardMultipartFile与MockMultipartFile的区别,配置属性从spring.http.multipart.*到spring.servlet.multipart.*的变化。
  2. 文件上传的完整实现与安全防护 :MultipartFile接收文件、transferTo()流式写入、文件名清理防路径遍历、文件类型白名单校验、大文件流式处理避免内存溢出。
  3. 文件下载的实现 :ResponseEntity<Resource>返回文件、Content-Disposition设置下载文件名、中文文件名编码处理、X-Content-Type-Options安全头。
  4. Spring Boot 4.x CORS四种解决方案 :@CrossOrigin局部注解、WebMvcConfigurer全局配置、CorsFilter过滤器、Spring Security CORS配置的用法与适用场景。
  5. CORS核心配置项 :allowedOrigins与allowedOriginPatterns的区别、allowCredentials与通配符的冲突规则、maxAge预检请求缓存、exposedHeaders暴露自定义头。
  6. Spring Boot 4.x CORS新变化 :allowedOriginPatterns通配符支持、AOT模式下的CORS处理时机、与Spring Security 7的整合。

3. 理论讲解

3.1 Spring Boot 4.x文件上传底层重构

在Spring 5及更早版本中,Spring采用第三方开源组件commons-fileupload实现文件上传,对应实现类是CommonsMultipartFile。这个组件底层需要依赖操作系统的磁盘读写和临时目录创建权限,同时强绑定第三方JAR包,带来了沉重的技术债务:不同Servlet容器(Tomcat、Jetty)适配参差不齐,容易出现容器版本冲突和文件临时目录权限报错;底层磁盘临时文件的生命周期由第三方组件管控,存在文件残留、IO资源泄漏、安全漏洞等隐性问题;代码耦合太深,受第三方组件约束,框架无法灵活迭代文件上传底层逻辑。

从Spring 6、Spring Boot 4.x(JDK 21起步)版本开始,Spring官方彻底砍掉了这套基于第三方的老旧实现 ,依托Servlet 3.0原生Part规范从零重构文件上传底层,完全抛弃commons-fileupload,消除历史技术债务,全链路基于JDK和Servlet原生API实现,不再维护、兼容旧版CommonsMultipartFile。

这对开发者的影响:

第一,MultipartFile的官方实现类变了。 在Spring 6体系中,MultipartFile接口仅提供两个官方实现类:

实现类 修饰与位置 运行环境
StandardMultipartFile private静态内部类,隶属StandardMultipartHttpServletRequest 生产环境真实HTTP文件上传
MockMultipartFile public公开类,位于spring-test测试依赖包 JUnit单元测试模拟文件

如果你在代码中直接引用了CommonsMultipartFile,升级到4.x后编译会失败,需要改用MultipartFile接口编程。

第二,配置属性前缀变了。 在Spring Boot 4.x中,multipart相关的配置属性从spring.http.multipart.*迁移到了spring.servlet.multipart.*。这是一个容易遗漏的迁移点------旧配置不会报错,但不会生效。

第三,文件解析器变了。 Spring Boot 4.x使用StandardServletMultipartResolver作为默认的MultipartResolver,而不再使用基于commons-fileupload的CommonsMultipartResolver。

3.2 MultipartFile核心API与文件上传流程

MultipartFile是org.springframework.web.multipart包下的顶层接口,继承自InputStreamSource,是Spring MVC定义文件上传操作的统一抽象契约。

核心API:

方法 返回值 说明
getName() String 表单中文件参数的名称
getOriginalFilename() String 客户端原始文件名
getContentType() String 文件MIME类型
isEmpty() boolean 是否为空文件
getSize() long 文件大小(字节)
getBytes() byte\[\] 获取文件内容(小文件)
getInputStream() InputStream 获取输入流(大文件流式处理)
transferTo(File) void 将文件写入目标位置

文件上传的完整流程:

  1. 客户端发送multipart/form-data请求
  2. StandardServletMultipartResolver解析请求,生成StandardMultipartHttpServletRequest
  3. Spring MVC将文件绑定到Controller方法的MultipartFile参数上
  4. Controller调用FileStorageService处理文件
  5. Service层执行安全校验(文件名清理、类型白名单、大小检查)
  6. 通过transferTo()将文件写入磁盘

3.3 文件上传的安全防护

这是文件上传中最核心的内容。一个看似简单的transferTo(new File(file.getOriginalFilename())),实际上包含了多个安全漏洞。

安全威胁一:路径遍历攻击(Path Traversal)。 攻击者构造文件名如../../etc/cron.d/evil,如果直接使用原始文件名,文件会被写入到预期目录之外的系统路径。

防护措施 :永远不要信任客户端提供的文件名。使用FilenameUtils.getName()或手动清理,去除所有路径分隔符。更安全的做法是使用UUID生成新文件名,仅将原始文件名作为元数据存储。

安全威胁二:文件名冲突。 两个用户上传同名文件photo.png,后上传的会覆盖先上传的。

防护措施 :使用日期子目录(2026/09/13/)+ UUID重命名,确保文件名全局唯一。

安全威胁三:内存溢出(OOM)。 如果直接将整个文件读入内存(如file.getBytes()),上传4GB文件会导致JVM内存溢出。

防护措施 :使用transferTo()或getInputStream()进行流式处理,避免将整个文件缓冲到内存中。Spring Boot 4.x的multipart解析支持流式写入磁盘,配置file-size-threshold控制内存缓冲的阈值。

安全威胁四:恶意文件类型。 用户上传可执行文件(如.exe、.sh),如果被其他用户下载运行,可能造成安全事件。

防护措施 :实施文件类型白名单校验,同时检查Content-Type和文件扩展名。对于图片文件,还可以通过读取文件头(Magic Number)验证真实类型。

3.4 Spring Boot 4.x multipart配置

Spring Boot 4.x中,multipart相关配置通过MultipartProperties类管理,前缀为spring.servlet.multipart。

yaml 复制代码
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 10MB          # 单个文件最大大小
      max-request-size: 50MB       # 整个请求最大大小(多文件时需注意)
      file-size-threshold: 1MB     # 超过此大小写入磁盘,否则保存在内存
      location: /tmp/uploads       # 临时文件目录
      resolve-lazily: false        # 是否延迟解析

关键配置说明:

  • max-file-size:限制单个文件的大小。容器(如Tomcat)会在进入Spring MVC之前就检查此限制,超限时直接返回MaxUploadSizeExceededException。
  • max-request-size:限制整个multipart请求的大小。多文件上传时需要将其设置为max-file-size × 文件数量 + 表单字段大小。
  • file-size-threshold:小于此值的文件保存在内存中,大于此值的写入磁盘临时文件。对于大文件上传,建议设置较小的阈值(如1MB),避免内存占用过高。
  • resolve-lazily:设置为true时,只有当调用getFile()等方法时才真正解析请求,有助于减少不必要的解析开销。

3.5 CORS跨域的本质

跨域问题的根源是浏览器的同源策略(Same-Origin Policy) 。同源 = 协议 + 域名 + 端口 完全相同。当请求的URL与当前页面的URL在协议、域名或端口中任一项不同时,浏览器就会触发跨域限制。

关键认知:请求已经到达服务器,只是浏览器拦截了响应。 跨域请求在发出后,实际上已经到达了服务器端,服务器也正常处理并返回了响应。但浏览器在收到响应后,会检查响应头中是否包含Access-Control-Allow-Origin等CORS头。如果没有,浏览器会拦截响应,JavaScript代码无法读取到数据。

简单请求 vs 预检请求:

类型 触发条件 浏览器行为
简单请求 GET/POST/HEAD + 特定Content-Type 直接发送
预检请求 PUT/DELETE/自定义Header/application/json 先发OPTIONS,通过后再发真实请求

对于PUT、DELETE请求,或者使用了Content-Type: application/json的POST请求,浏览器会先发送一个OPTIONS请求(称为"预检请求"),询问服务器是否允许该跨域请求。只有服务器返回正确的CORS响应头后,浏览器才会发送真实请求。

3.6 Spring Boot 4.x CORS四种解决方案

方案一:@CrossOrigin注解(局部)。 在Controller类或方法上添加@CrossOrigin注解,精确控制哪些接口允许跨域。适用于需要精细化控制的场景,但缺点是需要逐个Controller添加,维护成本高。

方案二:WebMvcConfigurer全局配置(推荐)。 实现WebMvcConfigurer接口,重写addCorsMappings()方法,这是Spring Boot 4.x中最优雅的跨域配置方式。代码清晰、语义明确,且可以与拦截器配置统一管理。

方案三:CorsFilter过滤器。 通过注册CorsFilter Bean实现全局跨域配置。优点是可以控制过滤器的执行顺序,适合需要在过滤器链早期处理跨域的场景。

方案四:Spring Security CORS配置(有Security时必用)。 当项目中使用了Spring Security时,必须通过Security的CORS配置来启用跨域,否则Security的过滤器链会拦截OPTIONS预检请求,导致跨域失败。这是最容易被忽视的坑点。

3.7 Spring Boot 4.x CORS新变化

变化一:allowedOriginPatterns支持更完善。 Spring Boot 4.x对allowedOriginPatterns的支持更加完善,支持通配符模式匹配动态来源域名。例如,https://*.example.com可以匹配https://api.example.com和https://www.example.com。这解决了生产环境中前端域名可能有多个子域名的场景。

变化二:allowedOrigins与allowCredentials的冲突强制检查。 当allowCredentials为true时,allowedOrigins不能设为"*"。这是Spring Framework 7.0中强制执行的规则,违反会直接抛出异常。生产环境必须将允许的域名限制为实际的前端域名。

变化三:AOT模式下CORS配置处理更早。 在AOT(提前编译)模式下,CORS配置的处理时机更早,配置的解析和绑定在构建时完成,减少了运行时的启动开销。

变化四:与Spring Security 7深度整合。 Spring Boot 4.x中,CORS配置与Spring Security 7的整合更加紧密。Security的过滤器链会正确处理CORS预检请求,不再需要手动排除OPTIONS请求。


4. 环境搭建

4.1 项目创建

本节课创建一个文件上传下载与CORS实验项目,包含完整的文件存储服务和四种CORS配置方案。

项目结构

复制代码
file-cors-demo/
├── pom.xml
└── src/main/
    ├── java/com/example/filedemo/
    │   ├── FileCorsDemoApplication.java
    │   ├── config/
    │   │   ├── StorageProperties.java
    │   │   └── CorsConfig.java
    │   ├── service/
    │   │   └── FileStorageService.java
    │   ├── controller/
    │   │   ├── FileController.java
    │   │   └── CorsTestController.java
    │   ├── dto/
    │   │   └── FileUploadResponse.java
    │   └── exception/
    │       ├── FileStorageException.java
    │       └── FileNotFoundException.java
    └── resources/
        ├── application.yml
        └── static/
            └── upload-test.html

4.2 pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.8</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>file-cors-demo</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>file-cors-demo</name>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

4.3 application.yml

yaml 复制代码
spring:
  application:
    name: file-cors-demo
  servlet:
    multipart:
      enabled: true
      max-file-size: 10MB
      max-request-size: 50MB
      file-size-threshold: 1MB
      location: ${java.io.tmpdir}/file-cors-uploads

server:
  port: 8080

# 自定义文件存储配置
app:
  storage:
    location: ./uploads
    allowed-types:
      - image/jpeg
      - image/png
      - image/gif
      - application/pdf
      - text/plain
    max-filename-length: 200

logging:
  level:
    com.example: DEBUG

5. 代码实战

5.1 文件存储配置类

StorageProperties.java:

java 复制代码
package com.example.filedemo.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

import java.util.List;

/**
 * 文件存储配置属性。
 * 
 * 绑定 application.yml 中 app.storage 前缀的配置。
 */
@ConfigurationProperties(prefix = "app.storage")
public record StorageProperties(

        /** 文件存储根目录 */
        @DefaultValue("./uploads") String location,

        /** 允许上传的文件类型白名单 */
        @DefaultValue({"image/jpeg", "image/png", "image/gif"})
        List<String> allowedTypes,

        /** 文件名最大长度 */
        @DefaultValue("200") int maxFilenameLength
) {}

5.2 文件存储服务

FileStorageService.java:

java 复制代码
package com.example.filedemo.service;

import com.example.filedemo.config.StorageProperties;
import com.example.filedemo.exception.FileNotFoundException;
import com.example.filedemo.exception.FileStorageException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.*;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

/**
 * 文件存储服务。
 * 
 * 这是本节课最核心的类,所有危险操作(文件名校验、路径清理、
 * 流式写入)都集中在这里,便于审计和安全控制。
 * 
 * 核心安全措施:
 * 1. 文件名清理:防止路径遍历攻击
 * 2. 类型白名单:防止恶意文件上传
 * 3. UUID重命名:防止文件名冲突
 * 4. 日期分目录:避免单目录文件过多
 * 5. 流式写入:避免大文件内存溢出
 */
@Service
public class FileStorageService {

    private static final Logger log = LoggerFactory.getLogger(FileStorageService.class);

    private final Path storageRoot;
    private final StorageProperties properties;

    public FileStorageService(StorageProperties properties) {
        this.properties = properties;
        this.storageRoot = Paths.get(properties.location())
                .toAbsolutePath().normalize();
        init();
    }

    /**
     * 初始化存储目录。
     */
    private void init() {
        try {
            Files.createDirectories(storageRoot);
            log.info("文件存储目录已初始化: {}", storageRoot);
        } catch (IOException e) {
            throw new FileStorageException("无法创建存储目录: " + storageRoot, e);
        }
    }

    /**
     * 存储上传的文件。
     * 
     * @param file 上传的文件
     * @return 存储后的文件名(UUID格式)
     */
    public String store(MultipartFile file) {
        // 1. 校验文件是否为空
        if (file.isEmpty()) {
            throw new FileStorageException("上传文件不能为空");
        }

        // 2. 清理原始文件名(防止路径遍历)
        String originalFilename = file.getOriginalFilename();
        String safeFilename = sanitizeFilename(originalFilename);

        // 3. 校验文件类型白名单
        String contentType = file.getContentType();
        if (contentType == null || !properties.allowedTypes().contains(contentType)) {
            throw new FileStorageException(
                    "不支持的文件类型: " + contentType +
                    ",允许的类型: " + properties.allowedTypes());
        }

        // 4. 校验文件扩展名
        String extension = getExtension(safeFilename);
        if (!isAllowedExtension(extension)) {
            throw new FileStorageException("不支持的扩展名: " + extension);
        }

        // 5. 生成 UUID 文件名,保留扩展名
        String storedFilename = UUID.randomUUID() + "." + extension;

        // 6. 按日期分目录存储
        String datePath = LocalDate.now()
                .format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
        Path targetDir = storageRoot.resolve(datePath).normalize();

        // 7. 安全检查:确保目标路径在存储根目录内
        if (!targetDir.startsWith(storageRoot)) {
            throw new FileStorageException("非法的存储路径");
        }

        try {
            Files.createDirectories(targetDir);
            Path targetFile = targetDir.resolve(storedFilename);
            // 8. 流式写入(避免大文件内存溢出)
            file.transferTo(targetFile.toFile());
            log.info("文件存储成功: original={}, stored={}, size={}",
                    safeFilename, datePath + "/" + storedFilename, file.getSize());
            return datePath + "/" + storedFilename;
        } catch (IOException e) {
            throw new FileStorageException("文件存储失败", e);
        }
    }

    /**
     * 加载文件为 Resource。
     */
    public Resource load(String storedFilename) {
        try {
            Path filePath = storageRoot.resolve(storedFilename).normalize();
            // 安全检查:防止路径遍历
            if (!filePath.startsWith(storageRoot)) {
                throw new FileStorageException("非法的文件路径");
            }
            Resource resource = new UrlResource(filePath.toUri());
            if (resource.exists() && resource.isReadable()) {
                return resource;
            }
            throw new FileNotFoundException("文件不存在: " + storedFilename);
        } catch (MalformedURLException e) {
            throw new FileStorageException("文件路径格式错误", e);
        }
    }

    /**
     * 清理文件名------防止路径遍历攻击。
     * 
     * 处理逻辑:
     * 1. 提取纯文件名(去除路径部分)
     * 2. 替换特殊字符
     * 3. 限制长度
     */
    private String sanitizeFilename(String filename) {
        if (filename == null || filename.isBlank()) {
            return "unnamed";
        }
        // 提取纯文件名(去除路径分隔符)
        String name = Paths.get(filename).getFileName().toString();
        // 替换危险字符
        name = name.replaceAll("[^a-zA-Z0-9._\\-\\u4e00-\\u9fa5]", "_");
        // 限制长度
        if (name.length() > properties.maxFilenameLength()) {
            name = name.substring(0, properties.maxFilenameLength());
        }
        return name;
    }

    /**
     * 获取文件扩展名。
     */
    private String getExtension(String filename) {
        int dotIndex = filename.lastIndexOf('.');
        if (dotIndex == -1 || dotIndex == filename.length() - 1) {
            return "";
        }
        return filename.substring(dotIndex + 1).toLowerCase();
    }

    /**
     * 校验扩展名是否在白名单中。
     */
    private boolean isAllowedExtension(String extension) {
        return switch (extension) {
            case "jpg", "jpeg", "png", "gif", "pdf", "txt" -> true;
            default -> false;
        };
    }
}

5.3 异常类

FileStorageException.java:

java 复制代码
package com.example.filedemo.exception;

public class FileStorageException extends RuntimeException {
    public FileStorageException(String message) { super(message); }
    public FileStorageException(String message, Throwable cause) { super(message, cause); }
}

FileNotFoundException.java:

java 复制代码
package com.example.filedemo.exception;

public class FileNotFoundException extends RuntimeException {
    public FileNotFoundException(String message) { super(message); }
}

5.4 文件上传下载Controller

FileUploadResponse.java:

java 复制代码
package com.example.filedemo.dto;

public record FileUploadResponse(
        String originalFilename,
        String storedFilename,
        long size,
        String contentType
) {}

FileController.java:

java 复制代码
package com.example.filedemo.controller;

import com.example.filedemo.dto.FileUploadResponse;
import com.example.filedemo.service.FileStorageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
 * 文件上传下载接口。
 */
@RestController
@RequestMapping("/api/files")
public class FileController {

    private static final Logger log = LoggerFactory.getLogger(FileController.class);
    private final FileStorageService storageService;

    public FileController(FileStorageService storageService) {
        this.storageService = storageService;
    }

    /**
     * 单文件上传。
     * POST /api/files/upload
     * Content-Type: multipart/form-data
     */
    @PostMapping("/upload")
    public FileUploadResponse upload(@RequestParam("file") MultipartFile file) {
        String storedName = storageService.store(file);
        return new FileUploadResponse(
                file.getOriginalFilename(),
                storedName,
                file.getSize(),
                file.getContentType()
        );
    }

    /**
     * 多文件上传。
     * POST /api/files/upload-multiple
     */
    @PostMapping("/upload-multiple")
    public List<FileUploadResponse> uploadMultiple(
            @RequestParam("files") MultipartFile[] files) {
        return java.util.Arrays.stream(files)
                .map(file -> {
                    String storedName = storageService.store(file);
                    return new FileUploadResponse(
                            file.getOriginalFilename(),
                            storedName,
                            file.getSize(),
                            file.getContentType()
                    );
                })
                .toList();
    }

    /**
     * 文件下载。
     * GET /api/files/download/{storedFilename}
     * 
     * 使用 ResponseEntity<Resource> 返回文件,
     * 设置 Content-Disposition 为 attachment 触发浏览器下载。
     */
    @GetMapping("/download/{*storedFilename}")
    public ResponseEntity<Resource> download(
            @PathVariable String storedFilename) {
        Resource resource = storageService.load(storedFilename);

        // 从存储路径中提取文件名
        String filename = storedFilename.contains("/")
                ? storedFilename.substring(storedFilename.lastIndexOf('/') + 1)
                : storedFilename;

        // 编码文件名(解决中文乱码)
        String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8)
                .replace("+", "%20");

        return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        "attachment; filename=\"" + encodedFilename + "\"; " +
                        "filename*=UTF-8''" + encodedFilename)
                .header("X-Content-Type-Options", "nosniff")
                .body(resource);
    }

    /**
     * 在线预览(inline 而非 attachment)。
     * GET /api/files/preview/{*storedFilename}
     */
    @GetMapping("/preview/{*storedFilename}")
    public ResponseEntity<Resource> preview(
            @PathVariable String storedFilename) {
        Resource resource = storageService.load(storedFilename);
        return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .header(HttpHeaders.CONTENT_DISPOSITION, "inline")
                .header("X-Content-Type-Options", "nosniff")
                .body(resource);
    }
}

5.5 CORS全局配置

CorsConfig.java:

java 复制代码
package com.example.filedemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * CORS 全局配置。
 * 
 * 通过实现 WebMvcConfigurer 接口的 addCorsMappings() 方法
 * 完成全局跨域配置,这是 Spring Boot 4.x 推荐的方式。
 * 
 * 注意:
 * 1. allowedOrigins 不能设为 "*" 当 allowCredentials 为 true 时
 * 2. allowedOriginPatterns 支持通配符模式(4.x 增强)
 * 3. 生产环境务必将允许的域名限制为实际的前端域名
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                // 开发环境前端地址
                .allowedOrigins(
                        "http://localhost:5173",
                        "http://localhost:3000"
                )
                // 使用 allowedOriginPatterns 支持通配符子域名
                .allowedOriginPatterns("https://*.example.com")
                // 允许的 HTTP 方法
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                // 允许的请求头
                .allowedHeaders("*")
                // 暴露给前端的响应头
                .exposedHeaders("Content-Disposition", "X-Total-Count")
                // 允许携带凭证(Cookie、Authorization 头)
                .allowCredentials(true)
                // 预检请求缓存时间(秒)
                .maxAge(3600);
    }
}

5.6 CORS测试Controller

CorsTestController.java:

java 复制代码
package com.example.filedemo.controller;

import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
 * CORS 跨域测试接口。
 */
@RestController
@RequestMapping("/api/cors")
public class CorsTestController {

    /**
     * GET 请求------简单请求,直接发送。
     * GET /api/cors/hello
     */
    @GetMapping("/hello")
    public Map<String, Object> hello() {
        return Map.of(
                "message", "Hello CORS!",
                "type", "简单请求(GET)",
                "note", "浏览器直接发送,检查响应头中的 CORS 字段"
        );
    }

    /**
     * POST 请求------JSON 格式触发预检请求。
     * POST /api/cors/echo
     * Content-Type: application/json
     */
    @PostMapping("/echo")
    public Map<String, Object> echo(@RequestBody Map<String, Object> body) {
        return Map.of(
                "received", body,
                "type", "预检请求(POST + application/json)",
                "note", "浏览器先发 OPTIONS 请求,通过后发送真实 POST 请求"
        );
    }

    /**
     * PUT 请求------非简单方法,触发预检请求。
     * PUT /api/cors/update
     */
    @PutMapping("/update")
    public Map<String, Object> update(@RequestBody Map<String, Object> body) {
        return Map.of(
                "updated", body,
                "type", "预检请求(PUT)",
                "note", "PUT 方法不在简单请求列表中,触发 OPTIONS 预检"
        );
    }

    /**
     * 自定义请求头------触发预检请求。
     * GET /api/cors/custom-header
     * Header: X-Custom-Header: test
     */
    @GetMapping("/custom-header")
    public Map<String, Object> customHeader(
            @RequestHeader(value = "X-Custom-Header", required = false)
            String customHeader) {
        return Map.of(
                "customHeader", customHeader != null ? customHeader : "未提供",
                "type", "预检请求(自定义请求头)",
                "note", "自定义请求头不在简单请求允许的 Headers 中"
        );
    }
}

5.7 启动类

java 复制代码
package com.example.filedemo;

import com.example.filedemo.config.StorageProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

@SpringBootApplication
@EnableConfigurationProperties(StorageProperties.class)
public class FileCorsDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(FileCorsDemoApplication.class, args);
    }
}

5.8 前端测试页面

static/upload-test.html:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>文件上传与跨域测试</title>
    <style>
        body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
        .section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px; }
        button { padding: 8px 16px; margin: 5px; cursor: pointer; }
        pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
        input[type="file"] { margin: 10px 0; }
    </style>
</head>
<body>
    <h1>Spring Boot 4.x 文件上传与跨域测试</h1>

    <div class="section">
        <h2>文件上传</h2>
        <input type="file" id="fileInput">
        <button onclick="uploadFile()">上传文件</button>
        <pre id="uploadResult"></pre>
    </div>

    <div class="section">
        <h2>CORS 测试</h2>
        <button onclick="testGet()">GET 简单请求</button>
        <button onclick="testPost()">POST 预检请求</button>
        <button onclick="testPut()">PUT 预检请求</button>
        <pre id="corsResult"></pre>
    </div>

    <script>
        async function uploadFile() {
            const fileInput = document.getElementById('fileInput');
            if (!fileInput.files.length) { alert('请选择文件'); return; }
            const formData = new FormData();
            formData.append('file', fileInput.files[0]);
            try {
                const res = await fetch('/api/files/upload', {
                    method: 'POST', body: formData
                });
                const data = await res.json();
                document.getElementById('uploadResult').textContent =
                    JSON.stringify(data, null, 2);
            } catch (e) {
                document.getElementById('uploadResult').textContent = '错误: ' + e.message;
            }
        }

        async function testGet() {
            const res = await fetch('/api/cors/hello');
            const data = await res.json();
            document.getElementById('corsResult').textContent =
                JSON.stringify(data, null, 2);
        }

        async function testPost() {
            const res = await fetch('/api/cors/echo', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ msg: 'hello' })
            });
            const data = await res.json();
            document.getElementById('corsResult').textContent =
                JSON.stringify(data, null, 2);
        }

        async function testPut() {
            const res = await fetch('/api/cors/update', {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ id: 1, name: 'updated' })
            });
            const data = await res.json();
            document.getElementById('corsResult').textContent =
                JSON.stringify(data, null, 2);
        }
    </script>
</body>
</html>

5.9 运行与验证

编译并启动

bash 复制代码
cd file-cors-demo
mvn clean install
mvn spring-boot:run

测试文件上传

bash 复制代码
# 单文件上传
curl -X POST http://localhost:8080/api/files/upload \
  -F "file=@/path/to/test.png"

# 多文件上传
curl -X POST http://localhost:8080/api/files/upload-multiple \
  -F "files=@file1.png" \
  -F "files=@file2.pdf"

# 测试路径遍历攻击(文件名包含 ../)
# 文件名会被清理为安全名称,不会写入预期目录之外

测试文件下载

bash 复制代码
# 下载文件(storedFilename 从上一步上传的响应中获取)
curl -O -J http://localhost:8080/api/files/download/2026/09/13/uuid.png

测试CORS跨域

bash 复制代码
# 模拟跨域请求(添加 Origin 头)
curl -v -H "Origin: http://localhost:5173" \
  http://localhost:8080/api/cors/hello
# 观察响应头中是否包含 Access-Control-Allow-Origin

# 模拟预检请求(OPTIONS)
curl -v -X OPTIONS \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Content-Type" \
  http://localhost:8080/api/cors/update
# 观察响应头中的 Access-Control-Allow-Methods 和 Access-Control-Allow-Headers

6. 底层源码剖析

6.1 MultipartAutoConfiguration自动配置原理

Spring Boot 4.x中,multipart的自动配置由MultipartAutoConfiguration负责,位于org.springframework.boot.servlet.autoconfigure包下。

java 复制代码
// 源码路径:org.springframework.boot.servlet.autoconfigure.MultipartAutoConfiguration
@AutoConfiguration
@ConditionalOnClass({ Servlet.class, StandardServletMultipartResolver.class,
        MultipartConfigElement.class })
@ConditionalOnProperty(prefix = "spring.servlet.multipart", name = "enabled",
        matchIfMissing = true)
@ConditionalOnWebApplication(type = Type.SERVLET)
@EnableConfigurationProperties(MultipartProperties.class)
public class MultipartAutoConfiguration {

    private final MultipartProperties multipartProperties;

    @Bean
    @ConditionalOnMissingBean({ MultipartConfigElement.class,
            CommonsMultipartResolver.class })
    public MultipartConfigElement multipartConfigElement() {
        return this.multipartProperties.createMultipartConfig();
    }

    @Bean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
    @ConditionalOnMissingBean(MultipartResolver.class)
    public StandardServletMultipartResolver multipartResolver() {
        StandardServletMultipartResolver multipartResolver =
                new StandardServletMultipartResolver();
        multipartResolver.setResolveLazily(this.multipartProperties.isResolveLazily());
        return multipartResolver;
    }
}

关键点:

  • @ConditionalOnClass(StandardServletMultipartResolver.class):确保classpath中存在Servlet原生multipart支持类。Spring Boot 4.x不再需要commons-fileupload依赖。
  • @ConditionalOnProperty(prefix = "spring.servlet.multipart", name = "enabled", matchIfMissing = true):默认启用multipart,可以通过配置关闭。
  • @ConditionalOnMissingBean(MultipartResolver.class):如果用户自定义了MultipartResolver,则跳过默认配置。这是用户覆盖默认行为的方式。

6.2 StandardServletMultipartResolver的工作机制

StandardServletMultipartResolver是Spring Boot 4.x的默认MultipartResolver实现:

java 复制代码
// 源码路径:org.springframework.web.multipart.support.StandardServletMultipartResolver
public class StandardServletMultipartResolver implements MultipartResolver {

    private boolean resolveLazily = false;

    @Override
    public boolean isMultipart(HttpServletRequest request) {
        // 检查请求的 Content-Type 是否为 multipart/form-data
        return StringUtils.startsWithIgnoreCase(
                request.getContentType(), "multipart/");
    }

    @Override
    public MultipartHttpServletRequest resolveMultipart(
            HttpServletRequest request) throws MultipartException {
        // 创建 StandardMultipartHttpServletRequest
        return new StandardMultipartHttpServletRequest(
                request, this.resolveLazily);
    }
}

关键点 :StandardServletMultipartResolver不依赖任何第三方库,完全基于Servlet 3.0的HttpServletRequest.getParts()方法实现。它检查请求的Content-Type是否为multipart/开头,如果是则创建StandardMultipartHttpServletRequest包装原始请求。

6.3 StandardMultipartHttpServletRequest的文件解析

StandardMultipartHttpServletRequest负责将Servlet的Part对象转换为Spring的MultipartFile:

java 复制代码
// 源码路径:org.springframework.web.multipart.support.StandardMultipartHttpServletRequest
public class StandardMultipartHttpServletRequest 
        extends AbstractMultipartHttpServletRequest {

    @Override
    protected void initializeMultipart() {
        // 1. 获取 Servlet 容器的 Part 对象
        Collection<Part> parts = getRequest().getParts();
        // 2. 将每个 Part 转换为 MultipartFile
        for (Part part : parts) {
            String filename = extractFilename(part);
            if (filename != null) {
                // 创建 StandardMultipartFile
                this.multipartFiles.put(part.getName(),
                        new StandardMultipartFile(part, filename));
            }
        }
    }
}

StandardMultipartFile是MultipartFile接口的私有静态内部类实现,它直接委托给Servlet的Part对象:

java 复制代码
// 源码路径:org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.StandardMultipartFile
private static class StandardMultipartFile implements MultipartFile {
    private final Part part;
    private final String filename;

    @Override
    public void transferTo(File dest) throws IOException {
        // 直接委托给 Part.write()
        this.part.write(dest.getPath());
    }
    // ...
}

关键点 :transferTo()方法直接委托给Servlet容器的Part.write()方法,由容器负责将文件写入目标位置。这就是Spring Boot 4.x"全链路基于JDK和Servlet原生API实现"的体现。

6.4 CORS处理的源码机制

CORS的处理由CorsInterceptor和DefaultCorsProcessor协同完成:

java 复制代码
// 源码路径:org.springframework.web.servlet.handler.AbstractHandlerMapping
private HandlerExecutionChain getCorsHandlerExecutionChain(
        HttpServletRequest request, HandlerExecutionChain chain,
        @Nullable CorsConfiguration config) {
    if (CorsUtils.isPreFlightRequest(request)) {
        // 预检请求:创建 PreFlightHandler 处理
        HandlerInterceptor[] interceptors = chain.getInterceptors();
        return new HandlerExecutionChain(
                new PreFlightHandler(config), interceptors);
    }
    // 非预检请求:在拦截器链前面添加 CorsInterceptor
    chain.addInterceptor(0, new CorsInterceptor(config));
    return chain;
}

关键点 :对于预检请求(OPTIONS),Spring MVC会创建一个PreFlightHandler直接处理,不执行Controller方法。对于非预检请求,CorsInterceptor会被添加到拦截器链的最前面,在业务逻辑执行前设置CORS响应头。

allowedOriginPatterns的匹配逻辑:

java 复制代码
// 源码路径:org.springframework.web.cors.CorsConfiguration
public String checkOrigin(@Nullable String origin) {
    if (!StringUtils.hasText(origin)) {
        return null;
    }
    String originToCheck = trimTrailingSlash(origin);
    if (!ObjectUtils.isEmpty(this.allowedOrigins)) {
        if (this.allowedOrigins.contains(ALL)) {
            // 检查 allowCredentials 冲突
            validateAllowCredentials();
            return ALL;
        }
        for (String allowedOrigin : this.allowedOrigins) {
            if (originToCheck.equalsIgnoreCase(allowedOrigin)) {
                return origin;
            }
        }
    }
    if (!ObjectUtils.isEmpty(this.allowedOriginPatterns)) {
        // 使用 PathPattern 匹配通配符模式
        for (OriginPattern p : this.allowedOriginPatterns) {
            if (p.getDeclaredPattern().equals(ALL) 
                    || p.getPattern().matcher(originToCheck).matches()) {
                return origin;
            }
        }
    }
    return null;
}

关键点 :当allowedOrigins包含"*"且allowCredentials为true时,validateAllowCredentials()会抛出异常。这就是Spring Framework 7.0中强制执行的规则------通配符来源与携带凭证不能同时使用。


7. 常见坑点与生产问题排查

坑点1:文件上传配置属性前缀错误

报错现象 :设置了spring.http.multipart.max-file-size=10MB,但上传大文件时仍然报错,配置不生效。

原因 :Spring Boot 4.x中multipart配置属性从spring.http.multipart.*迁移到了spring.servlet.multipart.*。

解决方案 :将配置前缀改为spring.servlet.multipart.*。旧配置不会报错,但不会生效。

坑点2:路径遍历攻击导致文件写入系统目录

报错现象:上传的文件被写入了预期目录之外的路径,或文件上传后找不到。

原因 :直接使用了file.getOriginalFilename()作为文件名,没有清理路径遍历字符(如../)。

解决方案 :使用Paths.get(filename).getFileName().toString()提取纯文件名,或使用UUID重命名文件。永远不要信任客户端提供的文件名。

坑点3:CORS配置中allowedOrigins与allowCredentials冲突

报错现象 :启动时报错java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*"。

原因 :Spring Framework 7.0中强制检查allowedOrigins和allowCredentials的冲突。当allowCredentials=true时,allowedOrigins不能包含"*"。

解决方案 :将allowedOrigins设置为具体的前端域名,或使用allowedOriginPatterns(支持通配符且与allowCredentials兼容)。

坑点4:OPTIONS预检请求被拦截

报错现象:PUT、DELETE请求跨域失败,浏览器控制台报CORS错误,但GET请求正常。

原因:对于非简单请求,浏览器会先发送OPTIONS预检请求。如果后端没有正确处理OPTIONS请求(如被Security或自定义拦截器拦截),预检请求失败,真实请求不会发出。

解决方案:确保CORS配置在拦截器链的最前面执行。如果使用Spring Security,必须通过Security的CORS配置启用跨域。自定义拦截器需要放行OPTIONS请求。

坑点5:大文件上传导致内存溢出

报错现象 :上传大文件时抛出OutOfMemoryError。

原因 :直接将MultipartFile读入内存(如file.getBytes()),或file-size-threshold设置过大,导致大文件被缓存在内存中。

解决方案 :使用file.transferTo()或file.getInputStream()进行流式处理。将file-size-threshold设置为较小的值(如1MB),超过阈值的文件会自动写入磁盘临时文件。

坑点6:中文文件名下载乱码

报错现象:下载文件时,中文文件名显示为乱码或URL编码后的字符串。

原因 :Content-Disposition头中的文件名没有进行正确的URL编码。

解决方案 :使用URLEncoder.encode(filename, StandardCharsets.UTF_8)进行编码,并在Content-Disposition中同时提供filename和filename*=UTF-8''两种格式。

坑点7:多文件上传时max-request-size不足

报错现象:上传多个文件时,单个文件大小未超限,但整体请求被拒绝。

原因 :max-request-size限制的是整个multipart请求的大小(包括所有文件和表单字段)。默认值通常小于max-file-size × 文件数量。

解决方案 :将max-request-size设置为max-file-size × 最大文件数量 + 表单字段大小。

坑点8:文件存储目录权限不足

报错现象 :FileStorageException: 文件存储失败,底层是AccessDeniedException。

原因:应用进程没有对存储目录的写入权限,或目录不存在。

解决方案 :确保应用启动时自动创建存储目录(Files.createDirectories),并检查运行用户的目录权限。在Docker环境中,注意挂载卷的权限设置。


8. 本章总结

本节课围绕Spring Boot 4.x的文件上传下载与跨域CORS配置,从底层重构、安全防护到四种CORS解决方案,构建了完整的文件与跨域处理知识体系。

文件上传底层重构 :Spring Boot 4.x完全抛弃了commons-fileupload,基于Servlet 3.0原生Part规范从零重构文件上传底层。MultipartFile的官方实现类变为StandardMultipartFile(生产环境)和MockMultipartFile(测试环境)。配置属性从spring.http.multipart.*迁移到spring.servlet.multipart.*。

文件上传安全防护 :路径遍历攻击(文件名清理)、文件名冲突(UUID重命名)、内存溢出(流式处理)、恶意文件类型(类型白名单)。所有安全措施集中在FileStorageService中,便于审计。

文件下载实现 :使用ResponseEntity<Resource>返回文件,设置Content-Disposition: attachment触发浏览器下载,X-Content-Type-Options: nosniff防止MIME类型嗅探攻击。

CORS四种解决方案 :@CrossOrigin局部注解(精细化控制)、WebMvcConfigurer全局配置(推荐)、CorsFilter过滤器(控制顺序)、Spring Security CORS配置(有Security时必用)。

Spring Boot 4.x CORS新变化 :allowedOriginPatterns通配符支持更完善,allowedOrigins与allowCredentials的冲突强制检查,AOT模式下CORS处理更早,与Spring Security 7深度整合。

核心配置规则 :allowCredentials=true时allowedOrigins不能设为"*"。预检请求(OPTIONS)需要被正确处理。生产环境务必将允许的域名限制为实际的前端域名。


9. 课后作业

基础题1

在本节课的file-cors-demo项目中,完成以下任务:

  1. 修改StorageProperties,增加maxFileSize属性(默认5MB),在FileStorageService.store()中添加文件大小校验,超过限制时抛出FileStorageException
  2. 在application.yml中设置app.storage.max-file-size=5MB,测试上传一个6MB的文件,验证是否被拒绝
  3. 添加一个GET /api/files/list接口,返回存储目录下所有文件的列表(文件名、大小、上传日期)
  4. 解释spring.servlet.multipart.max-file-size和app.storage.max-file-size的区别,以及为什么需要在两个地方分别限制

基础题2

使用curl测试CORS跨域场景,记录响应头:

  1. 发送带Origin: http://localhost:5173的GET请求,记录响应头中的Access-Control-Allow-Origin值
  2. 发送带Origin: http://evil.com的GET请求,记录响应头中是否包含Access-Control-Allow-Origin
  3. 发送OPTIONS预检请求,记录响应头中的Access-Control-Allow-Methods和Access-Control-Max-Age
  4. 解释为什么第二个请求没有返回CORS头,以及maxAge的作用是什么

综合实战题

构建一个完整的文件管理与跨域配置模块,要求:

  1. 文件存储服务增强:

    • 支持断点续传(记录已上传的分片)
    • 支持文件秒传(通过文件MD5判断是否已存在)
    • 支持文件预览(根据文件类型返回不同的Content-Type)
  2. CORS多环境配置:

    • 开发环境:允许http://localhost:5173和http://localhost:3000
    • 测试环境:允许https://*.test.example.com(使用allowedOriginPatterns)
    • 生产环境:允许https://app.example.com,allowCredentials=true
    • 使用@Profile注解实现不同环境的CORS配置
  3. REST接口:

    • POST /api/files/upload:文件上传(单文件)
    • POST /api/files/chunk:分片上传
    • GET /api/files/download/{filename}:文件下载
    • GET /api/files/preview/{filename}:文件预览
    • DELETE /api/files/{filename}:删除文件
    • GET /api/files/list:文件列表
  4. 验证要求:

    • 使用curl测试所有接口,记录响应格式
    • 测试路径遍历攻击(文件名包含../),验证是否被清理
    • 测试跨域请求(添加Origin头),验证CORS响应头是否正确
    • 测试预检请求(OPTIONS),验证是否返回200状态码
    • 使用--debug模式启动,查看MultipartAutoConfiguration的CONDITIONS EVALUATION REPORT

扩展挑战 :实现一个自定义的MultipartResolver,在文件解析时记录每个文件的解析耗时,并暴露为Micrometer指标。解释为什么在Spring Boot 4.x中自定义MultipartResolver需要特别注意Servlet容器的兼容性。

🔗 SpringBoot 4.x 从入门到企业级实战系列课程

去订阅

相关推荐
wuminyu1 小时前
JVM规范学习总结:class执行性能差异
java·linux·c语言·jvm·c++
一条泥憨鱼1 小时前
【RabbitMQ】高级篇
java·分布式·rabbitmq·php·ruby
京师20万禁军教头1 小时前
50-常用类-Arrays
java
寰梦1 小时前
Spring Boot + MinIO 大文件上传完整实战:MD5 秒传、分片上传、断点续传与引用一致性
java·spring boot·文件上传·minio
行者-全栈开发1 小时前
【码动四季·秋】把 Spring Boot 的 Tomcat 换成 Undertow:一次容器替换的三处暗坑
spring boot·tomcat·容器化·依赖管理·undertow
长弓三石2 小时前
Spring Boot 4.1 + AgentScope:企业级 Agent 平台的 MCP 双向集成实战
人工智能·spring boot·后端·harness·开源智能体
云和数据.ChenGuang10 小时前
Python 重写(Override)深度解析:从 MRO 到 super() 的完整机制
java·开发语言·人工智能·python·langchain·1024程序员节
摇滚侠10 小时前
《On Java 中文版 基础卷》阅读笔记 实现隐藏 12
java·数据库·笔记
silvamux_12 小时前
10款模型写种草笔记:可发布性、返工来源与成本数据缺口复盘
java·数据库·笔记