1. 问题现象与初步排查
1.1 跨域报错的具体表现
前阵子接了个前后端分离项目,后端用的 Spring Boot 加 Sa-Token 做登录认证,前端独立部署在另一个端口。联调阶段前端同事找我,说所有的接口请求都挂在跨域上了,浏览器控制台一片红,报错信息大概长这样:
Access to XMLHttpRequest at 'http://api.example.com/user/info' from origin 'http://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.这种报错见得多了,第一反应就是后端没配 CORS,或者配了但没生效。我打开后端的全局跨域配置检查了一圈,allowedOrigins、allowedMethods都写得没问题,甚至还加了allowCredentials(true)。按理说 Spring 的CorsRegistry配好之后,预检请求的响应头应该自动带上允许跨域的字段,怎么还会报 "No 'Access-Control-Allow-Origin'"?
继续看了下具体的请求方法,报错的请求全部是非简单请求,也就是先发OPTIONS预检再发实际请求的那种。于是我把后端日志打开,单独发了一个OPTIONS请求过去,结果发现响应状态码是 401,响应体里只有一行字:
未登录:请先登录这就有意思了——一个预检请求,居然被 Sa-Token 当成业务请求给拦了。预检请求本身不带业务参数,也不该携带凭证,结果被认证过滤器判定为"未登录",直接一刀切返回 401。浏览器收到 401,发现响应头里没有Access-Control-Allow-Origin,自然判定跨域失败,实际请求根本不会发出去。
1.2 从网络面板到后端日志的排查链路
排查这种过滤器导致的跨域问题,不能只看表面报错。我的排查顺序一般是这样:
第一步,打开浏览器开发者工具的网络面板,看请求的 Method 是OPTIONS还是GET/POST/PUT。如果是OPTIONS,说明问题大概率出在预检请求处理链上,此时重点不是看业务代码,而是看这个预检请求被谁拦截了。
第二步,直接复制这个OPTIONS请求的 URL,用命令行工具手动重放一次,去掉所有自定义请求头,只保留必需的Origin头。手动重放的好处是能绕过浏览器的预检逻辑,直接看到后端喂给浏览器的真实响应状态码和响应头。比如我用这个命令模拟预检:
curl -i -X OPTIONS \ -H "Origin: http://app.example.com" \ -H "Access-Control-Request-Method: GET" \ http://api.example.com/user/info返回结果里状态码是 401,响应头里只有Server、Date、Content-Type这些基础字段,完全没有Access-Control-Allow-Origin。这说明请求确实到达了业务链路,而且被某个认证环节提前拦截处理了,根本没走到后面的 CORS 配置层。
第三步,把项目里注册的 Filter 和拦截器全部列出来,逐个确认执行顺序。这一步通常是问题爆发的高发区。一般的 Spring Boot 项目中,CORS 配置常见的做法有三种:实现WebMvcConfigurer的addCorsMappings、注册自定义CorsFilter、或者直接启用@CrossOrigin注解。无论哪种方式,生效前提都是请求能得到 Spring MVC 的处理。如果某个 Filter 在更早的链路就把请求断掉了,CORS 配置就是纸上谈兵。
Sa-Token 的SaServletFilter或SaTokenFilter就属于这种"早期拦截"的过滤器。它注册在 Spring 容器中,默认执行顺序不固定,完全取决于初始化顺序和@Order注解。一旦它先于 CORS 处理器执行,那么不带 token 的预检请求就成了"非法请求",直接返回 401,CORS 响应头也就无从谈起。
注意:跨域问题里相当一部分不是"CORS 没配置",而是"CORS 配置没机会执行"。排查时先确认请求到底被谁拦截、在链路哪一层被拦,比在配置类里反复改参数有效得多。
2. 拆解 CORS 工作机制与 Sa-Token 过滤器原理
2.1 CORS 预检请求 OPTIONS 的生命周期
要彻底搞懂这个误判,得先弄清楚 CORS 预检请求到底是怎么一回事。浏览器在发跨域请求时,先看这个请求是不是"简单请求"。简单请求指请求方法是GET/HEAD/POST,且只有Content-Type为application/x-www-form-urlencoded、multipart/form-data或text/plain这种有限集合,再加上一些非自定义头。大多数业务请求携带application/json的Content-Type,或者带自定义 token 请求头,已经是典型的非简单请求了。
对于非简单请求,浏览器会先发一个OPTIONS预检请求,询问服务器是否允许这个源、这个方法、这些请求头。预检请求的响应必须满足三个条件才算通过:
- 响应头里有
Access-Control-Allow-Origin,且值与请求的Origin匹配 - 响应头里有
Access-Control-Allow-Methods,且包含实际请求要用的方法 - 响应头里有
Access-Control-Allow-Headers,且包含实际请求要用的自定义头
浏览器拿到正确的预检响应之后,才会真正发出业务请求。如果预检请求没有得到预期的响应头,浏览器会直接报 CORS 错误,实际请求根本不会发出去。这也是为什么跨域问题在浏览器里表现为"请求失败",但服务端日志里却看不到对应业务请求日志的原因之一——不是后端没收到,而是后端只收到了预检请求,业务请求压根没发。
Spring Boot 对 CORS 的处理逻辑可以在AbstractHandlerMapping和CorsProcessor中看到:请求进入后,处理器映射先尝试匹配CorsConfiguration,如果匹配到预检请求,会直接构造一个预检响应,带上所有允许跨域的响应头,然后结束本次处理。也就是说,Spring 对待OPTIONS预检请求和普通请求是分开处理的——预检请求并不会进入 Controller 层。
问题就出在:Sa-Token 过滤器拦截请求的时间点早于 Spring MVC 的处理器映射环节。过滤器层面没法区分"这是预检请求,我应该放行"还是"这是一个没有登录的普通请求"。如果不做特殊处理,OPTIONS就会和普通未登录请求一视同仁,被认证逻辑拦下来。
2.2 Sa-Token 过滤器在链路中的位置与行为
Sa-Token 提供了两种常用的拦截用法:一种是SaInterceptor拦截器,配合SaRouter做路由拦截;另一种是SaServletFilter,也就是题目里说的过滤器。两者都在请求进入 Controller 之前执行,但过滤器比拦截器更靠前。
我在这个项目里用的是SaServletFilter加SaRouter的组合,核心配置长这样:
@Configuration public class SaTokenConfigure { @Bean public SaServletFilter saServletFilter() { return new SaServletFilter() .addInclude("/**") .setAuth(obj -> { // 没登录就抛异常,由全局处理器转成 401 StpUtil.checkLogin(); }) .setError(e -> { return JSONUtil.createObj() .set("code", 401) .set("msg", e.getMessage()); }) .setBeforeAuth(r -> { // 前置处理 }); } }这段配置的含义非常直白:所有请求进来,先检查登录状态,没登录的统统拦截。在前后端分离的常规场景中,这个逻辑本身没有问题,因为确实每个业务接口都需要登录。但它忽略了一个关键事实——浏览器在发起真正的GET /user/info请求之前,会先发一个不带 token 的OPTIONS /user/info。
于是预检请求成了"未登录请求",被StpUtil.checkLogin()直接拦截。Sa-Token 抛出未登录异常,setError返回了 401 JSON。整个链路里,没有人处理 CORS 响应头,所以浏览器拿到的就是一份没有Access-Control-Allow-Origin的 401 响应。
更深一层说,这不算 Sa-Token 的 bug,而是过滤器职责边界的问题。Sa-Token 只负责认证,它不区分事务性预检请求和业务请求;CORS 配置只关心响应头,它不参与认证逻辑。两者在过滤器链路里相遇时,必须有人主动协调它们的关系。
2.3 CORS 误判的根本原因
把现象剥开,所谓"CORS 误判"其实包含三层意思:
第一层是过滤顺序问题。如果 Sa-Token 过滤器注册顺序早于 CORS 处理器(比如自定义的CorsFilter),预检请求就会被 Sa-Token 先截获。Spring Boot 默认的自动配置里,CorsFilter的执行顺序取决于注册方式,@Component自动注册的过滤器顺序极不确定,如果 Sa-Token 过滤器又用了@Order指定了很小的值,两者顺序就容易颠倒。
第二层是 Sa-Token 的认证判断对所有请求一视同仁。checkLogin()不会区分请求方法是OPTIONS还是GET。预检请求天然不该带业务 token,这就导致预检请求在认证环节必然被判失败,除非代码里明确放行OPTIONS。
第三层是响应头缺失导致浏览器层面的跨域判定失败。即使后端把 401 响应返回给浏览器了,因为响应头缺少 CORS 相关字段,浏览器依然会报跨域错误。有时候开发者看到 401 响应,误以为是登录失效问题,瞎调登录逻辑,结果越调越乱,其实根子就在过滤器链路的前后顺序。
这三层叠加,就构成了一个非常经典的"过滤器导致 CORS 误判"场景。理解了原理,解决方案就清晰了:要么让 CORS 处理器在 Sa-Token 过滤器之前执行,要么在 Sa-Token 的认证逻辑里显式放行预检请求,要么双管齐下,确保任意一条链路都能正确处理OPTIONS。
3. 解决方案:让 CORS 先于登录校验执行
3.1 方案一:单独放行 OPTIONS 请求
思路最直接的方法:在 Sa-Token 的认证逻辑里,判断请求方法,如果是OPTIONS,直接放行,不执行登录校验。
在SaServletFilter的setAuth回调里,可以拿到当前请求对象。如果判断是预检请求,直接return,跳过StpUtil.checkLogin()。具体配置如下:
@Bean public SaServletFilter saServletFilter() { return new SaServletFilter() .addInclude("/**") .setAuth(obj -> { SaRequest req = SaHolder.getRequest(); // 预检请求直接放行,不参与登录校验 if ("OPTIONS".equals(req.getMethod())) { return; } // 其余请求正常校验登录状态 StpUtil.checkLogin(); }) .setError(e -> { return JSONUtil.createObj() .set("code", 401) .set("msg", e.getMessage()); }); }这种做法的好处是改动量最小,几行代码就能解决预检请求被拦截的问题。但要注意一个细节:如果 CORS 配置本身设置不当,比如没有正确设置Access-Control-Allow-Origin或没有允许对应的自定义头,放行了OPTIONS之后会在下一个环节失败。放行只是给 CORS 处理器一个处理机会,真正决定预检是否通过的还是 CORS 配置本身。
另外,如果项目里用的不是SaServletFilter,而是SaInterceptor配合SaRouter,也完全可以用同样的思路处理。比如在SaRouter的匹配规则里,对OPTIONS方法直接排除:
SaRouter.match(SaHttpMethod.OPTIONS).stop(); SaRouter.match("/**").check(r -> StpUtil.checkLogin());这里的stop()表示停止匹配,当前路径不再执行后续拦截逻辑。Sa-Token 内置了SaHttpMethod枚举,专门用于这种按 HTTP 方法做路由匹配的场景,比手写字符串比较更安全可靠。
3.2 方案二:调整过滤器注册顺序
第二个思路是从源头改变执行顺序:让自定义的 CORS 过滤器在 Sa-Token 过滤器之前执行。这样预检请求先经过 CORS 处理器,被 Spring 直接构造预检响应返回,Sa-Token 过滤器甚至都没机会看到这个请求。
Spring Boot 中过滤器注册顺序由FilterRegistrationBean的setOrder方法控制,数字越小优先级越高。一个完整配置如下:
@Configuration public class CorsConfig { @Bean public FilterRegistrationBean<CorsFilter> corsFilterRegistration() { CorsConfiguration config = new CorsConfiguration(); // 生产环境替换为具体域名,不要用 * 且 allowCredentials(true) 同时存在的情况 config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); // 如果使用 token 认证,通常需要允许携带凭证 config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); FilterRegistrationBean<CorsFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(new CorsFilter(source)); // 确保在 Sa-Token 过滤器之前执行,最高优先级 registration.setOrder(Ordered.HIGHEST_PRECEDENCE); registration.addUrlPatterns("/*"); return registration; } }这版配置里需要特别留意一点:addAllowedOriginPattern("*")和setAllowCredentials(true)的组合。旧版本的 Spring 处理addAllowedOrigin("*")加allowCredentials(true)时会直接报错,因为通配符源与凭证模式不兼容。AllowedOriginPattern是后来提供的更宽松的写法,允许在使用通配符的同时携带凭证。生产环境更稳妥的做法是显式写清允许的域名列表,例如config.addAllowedOrigin("http://app.example.com"),这样安全性和可控性都更好。
设置完order之后,可以用上一节提到的 curl 命令再次模拟预检请求,观察响应头。如果响应头里正常出现了Access-Control-Allow-Origin,说明顺序调整生效了。不过这里有个隐含风险:CorsFilter和 Sa-Token 过滤器都定义了setOrder,而 Sa-Token 过滤器如果也设置了自己的order值,需要确认两者的相对关系。Sa-Token 的SaServletFilter本身可以通过setOrder方法指定顺序,如果它设置为Ordered.HIGHEST_PRECEDENCE + 1,而 CORS 设置为Ordered.HIGHEST_PRECEDENCE,那么 CORS 还是先执行。
3.3 方案三:使用 CORS 配置与 Sa-Token 解耦
第三个方案是从架构层面把 CORS 处理和认证过滤拆开,让它俩各自管好各自的职责。最简单的方式是利用 Spring MVC 自带的全局 CORS 配置,也就是实现WebMvcConfigurer接口的addCorsMappings方法:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这种方式是 Spring MVC 在处理器映射层面处理的,它会优先处理预检请求,不会进入 Controller。但问题在于,如果 Sa-Token 的过滤器拦在更前面,这个配置同样无法生效。也就是说,单纯配置addCorsMappings并不解决过滤器链路问题,必须配合"放行 OPTIONS"或者"调整过滤器顺序"一起使用。
所以第三种方案的实际内涵是:把 CORS 配置从"依赖某个过滤器"变为"依赖 Spring 的处理器机制",再结合方案一的做法,在 Sa-Token 层放行预检请求。这样预检请求能顺利走到 Spring MVC 的 CORS 处理逻辑,而普通业务请求仍然被 Sa-Token 严格保护。从职责上说,Sa-Token 管认证,Spring 管跨域,边界清晰,排查时也好定位。
不过要提醒一点:如果项目里已经用了 Spring Security 之类的安全框架,CORS 配置可能会有另一层拦截,这里不展开讨论。对于只用 Sa-Token 的项目,这个方案完全够用。
3.4 方案对比与选型建议
把三种方案放到一张表里对比,方便按项目实际情况选择。
| 方案 | 改动成本 | 风险点 | 适用场景 |
|---|---|---|---|
| 放行 OPTIONS | 低,几行代码 | 必须确保 CORS 配置本身正确,否则放行后仍会失败 | 已有全局 CORS 配置,只是被 Sa-Token 拦截的项目 |
| 调整过滤器顺序 | 中,新增 CorsFilter 注册 | 影响全部请求的过滤器链路,需关注其他过滤器顺序 | 需要精细控制过滤器链路的项目 |
| CORS 与 Sa-Token 解耦 | 中高,重构配置结构 | 配置分散,需要团队都理解机制 | 新项目或结构可调整的成熟项目 |
我个人在实际项目里优先推荐方案一。原因很朴素:跨域问题本身是边缘问题,不值得为了它大动干戈重构过滤器链路。放行OPTIONS只影响预检请求,不影响任何业务请求的认证逻辑,风险面最小。如果排查后发现真的存在过滤器顺序问题,再叠加方案二一起使用,通常就能彻底解决。
提示:无论选哪种方案,解决后都必须做两类验证:一类是命令行模拟预检请求,确认响应头正确;另一类是用浏览器实际访问前端页面,确认真实业务请求能携带自定义头成功跨域。命令行能看到响应头细节,浏览器能验证完整链路,两者缺一不可。
4. 实操记录与代码落地
4.1 基于方案一的最小改动
我用方案一给当前项目做了第一轮修复。由于项目原本就用addCorsMappings配置了跨域,所以只需改SaServletFilter的认证逻辑,加一个OPTIONS判断。
改完之后的完整配置类如下:
package com.example.config; import cn.dev33.satoken.filter.SaServletFilter; import cn.dev33.satoken.router.SaRouter; import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.util.SaResult; import cn.hutool.json.JSONUtil; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class SaTokenConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } @Bean public SaServletFilter saServletFilter() { return new SaServletFilter() .addInclude("/**") .setAuth(obj -> { // 预检请求直接放行 if ("OPTIONS".equals(SaHolder.getRequest().getMethod())) { return; } // 业务请求统一校验登录状态 SaRouter.match("/**").check(r -> StpUtil.checkLogin()); }) .setError(e -> { return SaResult.error(e.getMessage()).setCode(401); }); } }注意这段代码里SaRouter.match("/**").check(...)的写法。实际项目中setAuth回调可以单独写一个方法,里面用SaRouter.match()做细致路由匹配,比如放行/login、/captcha等匿名接口。这里只演示最小改动,所以保留了对所有业务路径的校验逻辑。
改完以后,我直接在本地用 curl 做了验证。第一次重放预检请求,输出如下:
$ curl -i -X OPTIONS \ > -H "Origin: http://app.example.com" \ > -H "Access-Control-Request-Method: GET" \ > http://localhost:8080/user/info HTTP/1.1 200 Vary: Origin Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Access-Control-Allow-Origin: http://app.example.com Access-Control-Allow-Methods: GET Access-Control-Allow-Headers: content-type Access-Control-Max-Age: 3600 Content-Length: 0状态码 200,响应头正确。这说明预检请求已经被 Sa-Token 放行,并且 Spring MVC 正确构造了 CORS 响应。我又用一个带 token 的业务请求测试了普通接口,确认登录校验仍然正常工作。两轮测试通过后,让前端同事重新联调,跨域报错消失,请求恢复正常。
4.2 基于方案二的过滤器顺序调整
方案二在实际操作中多一个步骤:需要注册CorsFilter为 Bean,并显式指定顺序。这里有一个常见的坑,就是直接用@Component注解标注CorsFilter,却不设置顺序,结果过滤器的执行顺序完全不可控。
以下是更稳妥的写法:
@Configuration public class CorsFilterConfig { @Bean public FilterRegistrationBean<CorsFilter> corsFilterRegistrationBean() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); FilterRegistrationBean<CorsFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new CorsFilter(source)); registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); registrationBean.addUrlPatterns("/*"); return registrationBean; } }如果项目里同时存在多个过滤器,不要只给CorsFilter设置顺序,也要确认 Sa-Token 过滤器的顺序。SaServletFilter可以通过setOrder设置,也可以在FilterRegistrationBean里包裹后再设置顺序。比如你要让 CORS 比 Sa-Token 先执行,而 Sa-Token 又比其他业务过滤器先执行,可以这样设计:
@Bean public FilterRegistrationBean<SaServletFilter> saFilterRegistrationBean() { FilterRegistrationBean<SaServletFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(saServletFilter()); registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 1); registrationBean.addUrlPatterns("/*"); return registrationBean; }这个模式下,执行链就是:
CorsFilter(最高优先级)处理跨域SaServletFilter(次高优先级)处理登录认证- 其他业务过滤器依次执行
预检请求在第一层就被CorsFilter返回响应,Sa-Token 甚至不会执行到。普通请求则经过 CORS 过滤器放行后,进入 Sa-Token 认证,认证通过后继续走业务逻辑。
这个方案的优点是链路清晰,缺点是如果项目里还注册了其他全局过滤器,比如日志过滤器、XSS 过滤器,你必须对所有相关过滤器都确认一遍 order 值,否则容易在调整顺序时埋下新的隐患。
4.3 完整配置参考
实际项目里往往两种方案同时用。我把最后落地的完整配置贴出来,供想直接"抄作业"的读者参考。这个配置同时做了两件事:Sa-Token 层放行OPTIONS,并在过滤器层面保证 CORS 处理器优先执行。
package com.example.config; import cn.dev33.satoken.filter.SaServletFilter; import cn.dev33.satoken.util.SaResult; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; @Configuration public class FilterChainConfig { /** * 跨域过滤器,放到最高优先级 */ @Bean public FilterRegistrationBean<CorsFilter> corsFilterRegistrationBean() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); FilterRegistrationBean<CorsFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new CorsFilter(source)); registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); registrationBean.addUrlPatterns("/*"); return registrationBean; } /** * Sa-Token 过滤器,排在 CORS 之后 */ @Bean public FilterRegistrationBean<SaServletFilter> saTokenFilterRegistrationBean() { SaServletFilter filter = new SaServletFilter() .addInclude("/**") .setAuth(obj -> { // 预检请求不参与认证 if ("OPTIONS".equals(obj.getRequest().getMethod())) { return; } // 登录校验逻辑 // StpUtil.checkLogin(); }) .setError(e -> SaResult.error(e.getMessage()).setCode(401)); FilterRegistrationBean<SaServletFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(filter); registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 1); registrationBean.addUrlPatterns("/*"); return registrationBean; } }这份配置在多个项目里实测过,能稳定解决 Sa-Token 过滤器与 CORS 的顺序冲突。需要注意,addAllowedOriginPattern("*")和setAllowCredentials(true)的组合较新,旧版本 Spring 可能不支持AllowedOriginPattern,只有addAllowedOrigin。遇到兼容问题时,要么升级 Spring Boot 版本到 2.4+,要么把allowCredentials改成false并用明确的域名代替通配符。
实际操作时还有一个细节:预检请求的
Access-Control-Allow-Headers可能会让你吃亏。前端可能携带自定义请求头Authorization或X-Token,如果你在 CORS 配置里写死了允许的 headers,比如只写了Authorization,但前端实际发的是X-Token,预检照样会失败。稳妥做法是开发阶段配置allowedHeaders("*"),生产环境再收紧。
5. 常见问题与排查技巧实录
5.1 高频率踩坑点速查
梳理一下这套技术栈下最容易踩的坑,以及对应的处理思路。
| 现象 | 根本原因 | 解决方向 |
|---|---|---|
| 预检请求返回 401 | Sa-Token 过滤器在 CORS 处理器之前执行,拦截了 OPTIONS | 放行 OPTIONS 或调整过滤器顺序 |
| 预检请求返回 200,但没有 CORS 响应头 | CORS 配置本身缺失,或被其他过滤器吞掉 | 检查 CORS 配置是否生效,确认过滤器链路有没有提前返回 |
配置了 CORS 仍跨域失败,响应头出现Access-Control-Allow-Origin: *和Access-Control-Allow-Credentials: true冲突 | 通配符源和凭证模式不兼容 | 使用allowedOriginPatterns或明确指定域名 |
| 实际请求能通过预检,但业务请求仍报跨域 | 实际请求带的响应头校验不一致 | 确认实际请求的方法和头都在预检响应的允许范围内 |
| 多个过滤器互相影响,调整顺序后其他功能异常 | 过滤器链路中还有其他全局过滤器被波及 | 对所有过滤器注册顺序做一次全面梳理 |
5.2 一条命令快速定位预检问题
排查跨域问题,我强烈建议学会用 curl 模拟预检请求。这个命令可以直接绕过浏览器,直击后端真实的响应头。每次改完配置,先跑一遍这条命令再让前端测试,能省下不少联调时间。
curl -i -X OPTIONS \ -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: content-type,authorization" \ http://localhost:8080/api/user/login对比两份响应,一份是修复前的:
HTTP/1.1 401 Content-Type: application/json {"code":401,"msg":"未登录:请先登录"}一份是修复后的:
HTTP/1.1 200 Access-Control-Allow-Origin: http://localhost:5173 Access-Control-Allow-Methods: POST Access-Control-Allow-Headers: content-type,authorization Access-Control-Max-Age: 3600 Content-Type: text/plain Vary: Origin Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers差异一眼就能看出来。修复前的问题就是:请求被认证逻辑拦截,且没有跨域响应头。修复后就完全正常了。
5.3 排查中容易忽视的隐藏变量
跨域问题看似简单,藏得深的坑不少。我踩过几次之后,总结了三个容易忽视的隐藏变量。
第一个是代理层。项目如果部署在 Nginx 或者其他反向代理后面,Nginx 本身也可能往响应头里塞 CORS 配置,或者提前拦截预检请求。有时你配置了 Nginx 的add_header Access-Control-Allow-Origin *,后端应用也配了 CORS,两边配置不一致时就会出现"玄学"现象——本地好好的,测试环境却垮了。遇到这种情况,先把代理层对 CORS 的处理统一交给一边处理,或者明确两边配置的职责边界。
第二个是自定义过滤器。很多项目里有人写过一个OncePerRequestFilter来做日志记录或者接口签名校验,里面可能会对请求体做getInputStream()之类的读取操作,导致后续业务拿不到请求体。这类过滤器如果也参与了链路,并且对OPTIONS请求有特殊处理逻辑,比如记录了异常日志,也要检查是否存在类似问题。表面看是 Sa-Token CORS 误判,实际可能是另一层的过滤器误伤。
第三个是全局异常处理器。如果 Sa-Token 的未登录异常被全局@RestControllerAdvice处理后,返回的对象结构恰好没有设置 CORS 头,那么任何被拦截的请求都会变成跨域失败。问题表现上类似,但排查方向已经不在 Sa-Token 配置了。看日志时留意异常抛出的位置,以及异常处理器返回的响应头信息是否齐全。
5.4 修复后如何验证才算真正闭环
我一般按以下清单做最终验证,全部通过才算问题闭环:
- 用 curl 重放
OPTIONS预检请求,确认状态码是 200 或 204,响应头包含Access-Control-Allow-Origin和Access-Control-Allow-Headers。 - 用 curl 重放一个带 token 的业务请求,确认接口正常返回业务数据,且响应头里同样包含
Access-Control-Allow-Origin。 - 用 curl 重放一个不带 token 的业务请求,确认仍然返回 401,说明认证逻辑没有被误放行。
- 在浏览器实际操作前端页面,打开控制台确认 Network 面板没有 CORS 报错,请求状态码正常。
- 观察后端日志,确认预检请求没有进入业务控制器,避免预检请求被当成正常业务请求处理。
这五步里,第三步最容易被漏掉。有些人放行了OPTIONS,顺手也把其他请求给放通了,导致认证形同虚设。务必确认只有OPTIONS被放行,其他非预检请求仍然受 Sa-Token 保护。
经验之谈:Sa-Token 过滤器引发的 CORS 误判,本质不是 Sa-Token 的问题,而是过滤器链路上职责重叠导致的冲突。解决思路永远围绕两个字:顺序。要么在空间上让 CORS 先执行,要么在逻辑上让认证主动避开预检请求。想明白这一点,任何框架的组合都不会再被这个问题卡住。
我在实际项目中用这套思路处理过不少类似问题,比如 Sa-Token 和自定义 JWT 过滤器、Sa-Token 和跨域网关、Sa-Token 和日志过滤器之间的顺序冲突。核心方法都是通用的:先画出请求链路图,标出每个过滤器处理哪些请求,再看有没有节点产生冲突,最后决定是调整顺序还是放行特定请求。养成这个习惯之后,这类问题从"玄学"变成了"按图索骥",排查速度能快不少。