
"我只是把 Spring Boot 从 2.7.x 升到了 3.3.1,前后端分离项目的跨域就全挂了!"—— 这是最近技术社区讨论度极高的问题。作为一名深耕 Java 领域十余年的技术专家,我在主导多个大型项目升级时也遇到了同样的情况。
跨域(CORS)问题的本质是浏览器的安全机制,但 Spring Boot 3.3.x 基于 Spring Framework 6.1 + 对跨域处理进行了底层重构。根据 Spring 官方文档(https://docs.spring.io/spring-framework/docs/6.1.x/reference/html/web.html#mvc-cors),这次调整并非简单优化,而是涉及预检请求处理、凭证支持和配置优先级的根本性变更,直接导致许多旧版本的 "标准答案" 不再适用。
本文将从 HTTP 协议底层讲起,结合 Spring Boot 3.3.x 的最新特性,提供 5 种实战解决方案,每个方案都包含可直接运行的代码实例和避坑指南。无论你是刚接触跨域的新手,还是需要解决升级难题的资深开发者,都能在这里找到答案。
在解决问题前,我们必须先理解 "跨域" 到底是什么。很多开发者只知道 "配置一下 allowedOrigins 就行",但遇到复杂场景就束手无策,根源在于没有掌握底层逻辑。
浏览器的同源策略(Same-Origin Policy)规定:只有当两个 URL 的协议、域名、端口完全相同时,才能自由交互。例如:
https://example.com/api 访问 https://example.com/user(同源)https://frontend.example.com 访问 https://api.example.com(域名不同)http://example.com 访问 https://example.com(协议不同)https://example.com:80 访问 https://example.com:8080(端口不同)这种限制是为了防止恶意网站窃取用户数据。想象一下:如果没有同源策略,黑客的网站可以随意调用银行网站的接口,后果不堪设想。
当需要跨域请求时,浏览器会通过CORS(跨域资源共享)协议与服务器协商是否允许访问。整个过程分为两种场景:
满足以下条件的请求属于简单请求,浏览器直接发送请求,然后根据响应头判断是否允许访问:
流程如下:

不满足简单请求条件的请求(如 PUT/DELETE 方法、带自定义头、Content-Type 为 application/json),浏览器会先发送OPTIONS 预检请求,确认服务器允许后再发送真实请求:

Spring Boot 的跨域配置,本质就是通过控制服务器返回的Access-Control-*系列响应头,告诉浏览器 "是否允许当前跨域请求"。
Spring Boot 3.3.x 基于 Spring Framework 6.1,对跨域处理进行了三大关键调整(官方文档明确说明):
allowedMethods时默认允许所有方法,3.3.x 中默认仅允许 GET、POST、HEAD。若前端发送 PUT/DELETE 请求,必须显式配置允许这些方法,否则预检失败。allowCredentials=true(允许前端携带 Cookie/Token)时,allowedOrigins不能设为*(允许所有源),必须指定具体域名。这是因为浏览器不允许 "既开放所有源,又允许携带敏感凭证" 的矛盾配置。WebMvcConfigurer和配置文件方式),旧版本的共存逻辑不再适用。为了让所有示例可直接运行,我们先搭建一个标准项目,包含 Web、Security、MyBatis-Plus 等常用组件。
<?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>3.3.1</version>
<relativePath/>
</parent>
<groupId>com.jam.cors</groupId>
<artifactId>spring-boot-33x-cors-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-boot-33x-cors-demo</name>
<description>Spring Boot 3.3.x跨域处理实战示例</description>
<properties>
<java.version>17</java.version>
<commons-lang3.version>3.14.0</commons-lang3.version>
<springdoc.version>2.3.0</springdoc.version>
<mybatis-plus.version>3.5.6</mybatis-plus.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<!-- 字符串工具类 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
<!-- 集合工具类 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>6.1.8</version>
</dependency>
<!-- Swagger3 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/cors_demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8
username: root
password: root123456
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: com.jam.cors.entity
configuration:
map-underscore-to-camel-case: true
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operationsSorter: method
packages-to-scan: com.jam.cors.controller
CREATE DATABASE IF NOT EXISTS cors_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE cors_demo;
-- 用户表(用于测试接口)
CREATE TABLE IF NOT EXISTS sys_user (
id BIGINT AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(50) NOT NULL COMMENT '用户名',
phone VARCHAR(20) NOT NULL COMMENT '手机号',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME NOT NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
package com.jam.cors.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户实体类
*
* @author 果酱
*/
@Data
@TableName("sys_user")
@Schema(description = "用户实体")
public class User {
@TableId(type = IdType.AUTO)
@Schema(description = "用户ID")
private Long id;
@Schema(description = "用户名")
private String username;
@Schema(description = "手机号")
private String phone;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}
package com.jam.cors.vo.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 用户添加请求参数
*
* @author 果酱
*/
@Data
@Schema(description = "用户添加请求参数")
public class UserAddReq {
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED)
private String username;
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED)
private String phone;
}
package com.jam.cors.vo.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 用户更新请求参数
*
* @author 果酱
*/
@Data
@Schema(description = "用户更新请求参数")
public class UserUpdateReq {
@Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long id;
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED)
private String phone;
}
package com.jam.cors.vo.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一响应结果
*
* @author 果酱
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "统一响应结果")
public class Result<T> {
@Schema(description = "状态码:200成功,其他失败")
private int code;
@Schema(description = "提示信息")
private String message;
@Schema(description = "响应数据")
private T data;
/**
* 成功响应
*
* @param data 响应数据
* @return 响应结果
*/
public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data);
}
/**
* 失败响应
*
* @param message 失败信息
* @return 响应结果
*/
public static <T> Result<T> fail(String message) {
return new Result<>(500, message, null);
}
}
package com.jam.cors.controller;
import com.jam.cors.entity.User;
import com.jam.cors.service.UserService;
import com.jam.cors.vo.request.UserAddReq;
import com.jam.cors.vo.request.UserUpdateReq;
import com.jam.cors.vo.response.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 用户管理接口(用于测试跨域)
*
* @author 果酱
*/
@RestController
@RequestMapping("/api/user")
@Tag(name = "用户管理接口", description = "包含GET/POST/PUT/DELETE方法,用于测试跨域")
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
@Operation(summary = "根据ID查询用户", description = "简单请求(GET)")
public Result<User> getUserById(
@Parameter(description = "用户ID", required = true) @PathVariable Long id) {
log.info("查询用户,ID:{}", id);
return Result.success(userService.getUserById(id));
}
@PostMapping
@Operation(summary = "添加用户", description = "预检请求(Content-Type=application/json)")
public Result<Long> addUser(
@Parameter(description = "用户信息", required = true) @RequestBody UserAddReq addReq) {
log.info("添加用户:{}", addReq.getUsername());
return Result.success(userService.addUser(addReq));
}
@PutMapping
@Operation(summary = "更新用户", description = "预检请求(PUT方法)")
public Result<Boolean> updateUser(
@Parameter(description = "更新信息", required = true) @RequestBody UserUpdateReq updateReq) {
log.info("更新用户,ID:{}", updateReq.getId());
return Result.success(userService.updateUser(updateReq));
}
@DeleteMapping("/{id}")
@Operation(summary = "删除用户", description = "预检请求(DELETE方法)")
public Result<Boolean> deleteUser(
@Parameter(description = "用户ID", required = true) @PathVariable Long id) {
log.info("删除用户,ID:{}", id);
return Result.success(userService.deleteUser(id));
}
}
package com.jam.cors.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.cors.entity.User;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户Mapper接口
*
* @author 果酱
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
package com.jam.cors.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.cors.entity.User;
import com.jam.cors.vo.request.UserAddReq;
import com.jam.cors.vo.request.UserUpdateReq;
/**
* 用户服务接口
*
* @author 果酱
*/
public interface UserService extends IService<User> {
/**
* 根据ID查询用户
*
* @param id 用户ID
* @return 用户信息
*/
User getUserById(Long id);
/**
* 添加用户
*
* @param addReq 添加参数
* @return 新增用户ID
*/
Long addUser(UserAddReq addReq);
/**
* 更新用户
*
* @param updateReq 更新参数
* @return 是否成功
*/
boolean updateUser(UserUpdateReq updateReq);
/**
* 删除用户
*
* @param id 用户ID
* @return 是否成功
*/
boolean deleteUser(Long id);
}
package com.jam.cors.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.cors.entity.User;
import com.jam.cors.mapper.UserMapper;
import com.jam.cors.service.UserService;
import com.jam.cors.vo.request.UserAddReq;
import com.jam.cors.vo.request.UserUpdateReq;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* 用户服务实现类
*
* @author 果酱
*/
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Override
public User getUserById(Long id) {
Objects.requireNonNull(id, "用户ID不能为空");
return getById(id);
}
@Override
public Long addUser(UserAddReq addReq) {
Objects.requireNonNull(addReq, "添加参数不能为空");
StringUtils.hasText(addReq.getUsername(), "用户名不能为空");
StringUtils.hasText(addReq.getPhone(), "手机号不能为空");
User user = new User();
user.setUsername(addReq.getUsername());
user.setPhone(addReq.getPhone());
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
save(user);
return user.getId();
}
@Override
public boolean updateUser(UserUpdateReq updateReq) {
Objects.requireNonNull(updateReq, "更新参数不能为空");
Objects.requireNonNull(updateReq.getId(), "用户ID不能为空");
StringUtils.hasText(updateReq.getPhone(), "手机号不能为空");
User user = new User();
user.setId(updateReq.getId());
user.setPhone(updateReq.getPhone());
user.setUpdateTime(LocalDateTime.now());
return updateById(user);
}
@Override
public boolean deleteUser(Long id) {
Objects.requireNonNull(id, "用户ID不能为空");
return removeById(id);
}
}
这是最常用的跨域处理方式,通过WebMvcConfigurer配置全局跨域规则,适合大多数前后端分离场景。
package com.jam.cors.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Spring Boot 3.3.x 全局跨域配置
* 基于WebMvcConfigurer实现,适用于未集成Spring Security的项目
*
* @author 果酱
*/
@Configuration
public class GlobalCorsConfig {
/**
* 配置跨域规则
*
* @return WebMvcConfigurer实例
*/
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 对所有路径应用跨域配置
registry.addMapping("/**")
// 允许的源(生产环境必须指定具体域名,不能用*)
.allowedOrigins("https://frontend.example.com", "https://admin.example.com")
// 允许的请求方法(必须包含OPTIONS,否则预检请求失败)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
// 允许的请求头(*表示允许所有,也可指定具体头)
.allowedHeaders("Content-Type", "Authorization", "X-Requested-With")
// 是否允许携带凭证(Cookie/Token)
.allowCredentials(true)
// 预检请求的缓存时间(秒),减少预检请求次数
.maxAge(3600)
// 允许暴露的响应头(默认只能获取基本头,如需获取自定义头需配置)
.exposedHeaders("X-Total-Count");
// 可为特定路径配置不同规则(优先级高于/**)
registry.addMapping("/api/public/**")
.allowedOrigins("*") // 公开接口允许所有源
.allowedMethods("GET") // 仅允许GET方法
.maxAge(1800);
}
};
}
}
*,特别是allowCredentials=true时,*会导致跨域失败(浏览器安全限制)。OPTIONS,否则预检请求会被拦截。Spring Boot 3.3.x 默认只允许 GET、POST、HEAD,不包含 PUT/DELETE 等方法。true时,前端请求需设置withCredentials: true(如 Axios),否则后端不会接收 Cookie/Token。使用前端代码测试(以 Axios 为例):
// 简单请求(GET)
axios.get('http://localhost:8080/api/user/1')
.then(response => console.log(response.data))
.catch(error => console.error(error));
// 预检请求(POST+JSON)
axios.post('http://localhost:8080/api/user', {
username: 'jam',
phone: '13800138000'
}, {
withCredentials: true // 允许携带凭证
})
.then(response => console.log(response.data))
.catch(error => console.error(error));
打开浏览器开发者工具的 Network 面板,查看请求头和响应头:
Origin(前端域名)Access-Control-Allow-Origin(与请求 Origin 匹配)、Access-Control-Allow-Credentials: true等对于规则简单的场景,可直接在application.yml中配置跨域,无需编写 Java 代码,更适合 DevOps 场景。
spring:
mvc:
cors:
mappings:
# 全局规则(/**)
"/**":
# 允许的源(多个用逗号分隔)
allowed-origins: "https://frontend.example.com,https://admin.example.com"
# 允许的方法
allowed-methods: "GET,POST,PUT,DELETE,OPTIONS"
# 允许的请求头
allowed-headers: "Content-Type,Authorization,X-Requested-With"
# 是否允许凭证
allow-credentials: true
# 预检缓存时间(秒)
max-age: 3600
# 允许暴露的响应头
exposed-headers: "X-Total-Count"
# 特定路径规则(优先级更高)
"/api/public/**":
allowed-origins: "*"
allowed-methods: "GET"
max-age: 1800
配置文件方式的优先级规则与 Java 配置类一致:
/api/public/**)的配置覆盖通配符路径(/**)@CrossOrigin注解用于对单个控制器或方法进行跨域配置,适合特殊接口的定制化需求。
package com.jam.cors.controller;
import com.jam.cors.entity.User;
import com.jam.cors.service.UserService;
import com.jam.cors.vo.request.UserAddReq;
import com.jam.cors.vo.request.UserUpdateReq;
import com.jam.cors.vo.response.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.CrossOrigin;
/**
* 带跨域注解的用户控制器
*
* @author 果酱
*/
@RestController
@RequestMapping("/api/user")
@Tag(name = "用户管理接口", description = "控制器级别@CrossOrigin配置")
@RequiredArgsConstructor
@Slf4j
// 控制器级别跨域配置
@CrossOrigin(
origins = {"https://frontend.example.com", "https://admin.example.com"},
methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS},
allowedHeaders = {"Content-Type", "Authorization"},
allowCredentials = true,
maxAge = 3600,
exposedHeaders = "X-Total-Count"
)
public class UserControllerWithCrossOrigin {
private final UserService userService;
// 方法实现与之前相同...
@GetMapping("/{id}")
@Operation(summary = "根据ID查询用户")
public Result<User> getUserById(@PathVariable Long id) {
log.info("查询用户,ID:{}", id);
return Result.success(userService.getUserById(id));
}
}
@DeleteMapping("/{id}")
@Operation(summary = "删除用户(特殊跨域规则)")
// 方法级别配置覆盖控制器配置
@CrossOrigin(
origins = "https://security.example.com", // 仅允许安全域名访问删除接口
maxAge = 1800
)
public Result<Boolean> deleteUser(@PathVariable Long id) {
log.info("删除用户,ID:{}", id);
return Result.success(userService.deleteUser(id));
}
@CrossOrigin的属性与CorsRegistry的方法一一对应(如origins对应allowedOrigins)。@CrossOrigin注解不能配置路径模式(如/api/**),只能对注解所在的控制器 / 方法生效。当项目使用 Spring Security 时,必须在 Security 配置中处理跨域,因为 Security 的过滤器链会先于 WebMvc 拦截请求。
package com.jam.cors.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* 集成Spring Security的跨域配置
* 注意:Security的CORS配置会覆盖WebMvc的配置
*
* @author 果酱
*/
@Configuration
public class SecurityCorsConfig {
/**
* 配置Security过滤器链
* 必须先配置cors(),再配置其他安全规则
*
* @param http HttpSecurity对象
* @return SecurityFilterChain实例
* @throws Exception 配置异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. 配置CORS(必须放在最前面)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 2. 关闭CSRF(前后端分离项目通常关闭)
.csrf(csrf -> csrf.disable())
// 3. 配置权限规则
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/user/**").authenticated()
.anyRequest().authenticated()
);
return http.build();
}
/**
* 定义CORS配置源
*
* @return CorsConfigurationSource实例
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// 创建CORS配置对象
CorsConfiguration config = new CorsConfiguration();
// 允许的源(生产环境指定具体域名)
config.setAllowedOrigins(Arrays.asList(
"https://frontend.example.com",
"https://admin.example.com"
));
// 允许的方法(包含预检请求OPTIONS)
config.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "OPTIONS"
));
// 允许的请求头
config.setAllowedHeaders(Arrays.asList(
"Content-Type", "Authorization", "X-Requested-With"
));
// 允许携带凭证
config.setAllowCredentials(true);
// 预检请求缓存时间
config.setMaxAge(3600L);
// 允许暴露的响应头
config.setExposedHeaders(Arrays.asList("X-Total-Count"));
// 应用到所有路径
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
// 特定路径单独配置
CorsConfiguration publicConfig = new CorsConfiguration();
publicConfig.setAllowedOrigins(Arrays.asList("*"));
publicConfig.setAllowedMethods(Arrays.asList("GET"));
publicConfig.setMaxAge(1800L);
source.registerCorsConfiguration("/api/public/**", publicConfig);
return source;
}
}
cors()必须在其他安全配置(如csrf()、authorizeHttpRequests())之前,否则跨域配置可能被安全规则覆盖。对于需要动态调整跨域规则的场景(如从数据库读取允许的源),可通过自定义过滤器实现。
package com.jam.cors.filter;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* 动态跨域过滤器
* 适用于需要从数据库/配置中心获取跨域规则的场景
*
* @author 果酱
*/
@Component
@Slf4j
@Order(1) // 确保过滤器在其他过滤器之前执行
public class DynamicCorsFilter implements Filter {
/**
* 允许的源列表(实际项目中可从数据库/配置中心加载)
*/
private static final List<String> ALLOWED_ORIGINS = Arrays.asList(
"https://frontend.example.com",
"https://admin.example.com",
"https://mobile.example.com"
);
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 1. 获取请求源(Origin头)
String origin = httpRequest.getHeader("Origin");
log.debug("请求源:{},请求方法:{}", origin, httpRequest.getMethod());
// 2. 验证源是否允许
if (isOriginAllowed(origin)) {
httpResponse.setHeader("Access-Control-Allow-Origin", origin);
}
// 3. 设置通用跨域响应头
httpResponse.setHeader("Access-Control-Allow-Methods",
"GET,POST,PUT,DELETE,OPTIONS");
httpResponse.setHeader("Access-Control-Allow-Headers",
"Content-Type,Authorization,X-Requested-With");
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpResponse.setHeader("Access-Control-Max-Age", "3600");
httpResponse.setHeader("Access-Control-Expose-Headers", "X-Total-Count");
// 4. 处理预检请求(直接返回200)
if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod())) {
httpResponse.setStatus(HttpServletResponse.SC_OK);
return;
}
// 5. 继续执行过滤器链
chain.doFilter(request, response);
}
/**
* 判断请求源是否允许
* 实际项目中可从数据库或配置中心查询
*
* @param origin 请求源
* @return 是否允许
*/
private boolean isOriginAllowed(String origin) {
// 允许空Origin(部分场景如本地文件请求)
if (StringUtils.isBlank(origin)) {
return true;
}
// 检查是否在允许列表中
return ALLOWED_ORIGINS.contains(origin);
}
}
在实际项目中,可将允许的源、方法等信息存储在数据库,通过定时任务或配置中心动态更新:
/**
* 从数据库加载允许的源
*/
private List<String> loadAllowedOriginsFromDb() {
// 实际项目中使用MyBatis-Plus查询数据库
log.info("从数据库加载允许的跨域源");
// 示例:return corsMapper.selectAllAllowedOrigins();
return Arrays.asList("https://frontend.example.com", "https://admin.example.com");
}
若同时使用自定义过滤器和 Spring Security,需在 Security 配置中放行过滤器:
@Override
public void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(new DynamicCorsFilter(), UsernamePasswordAuthenticationFilter.class)
// 其他配置...
}
原因:当allowCredentials=true时,浏览器不允许allowedOrigins为*(安全限制)。
解决:显式指定允许的域名,如allowedOrigins: ["https://frontend.example.com"]。
原因:Spring Boot 3.3.x 默认不允许 PUT/DELETE 方法,需显式配置。
解决:在allowedMethods中添加PUT,DELETE,OPTIONS。
原因:Security 的 CORS 配置覆盖了 WebMvc 的配置,且可能未正确配置。
解决:按方案四在 Security 配置中单独配置 CORS,且确保cors()在其他配置之前。
原因:未配置exposedHeaders,浏览器默认只允许获取基本响应头。
解决:添加exposedHeaders: "X-Total-Count"(替换为你的自定义头)。
原因:
allowedMethods中包含OPTIONS解决:
allowedMethods包含OPTIONS方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
全局配置类 | 大多数前后端分离项目,规则固定 | 灵活、可扩展、全局生效 | 需要编写代码 |
配置文件 | 规则简单,需环境差异化配置 | 零代码、易维护 | 不支持动态规则 |
@CrossOrigin 注解 | 特殊接口的定制化需求 | 细粒度控制 | 规则分散,不适合全局 |
Spring Security 集成 | 使用 Security 的项目 | 与安全规则联动 | 配置顺序要求严格 |
自定义过滤器 | 动态规则、复杂业务 | 高度灵活 | 需处理细节(如预检请求) |
参考:
通过本文的讲解,相信你已经掌握了 Spring Boot 3.3.x 跨域处理的核心原理和实战方案。记住:跨域问题的本质是浏览器的安全机制,解决问题的关键在于理解 CORS 协议的工作流程和 Spring Boot 的配置逻辑。在实际项目中,应根据具体场景选择合适的方案,并始终遵循安全最佳实践。