
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 的预检请求就成了非法请求直接返回 401CORS 响应头也就无从谈起。注意跨域问题里相当一部分不是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 FilterRegistrationBeanCorsFilter 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); FilterRegistrationBeanCorsFilter 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 FilterRegistrationBeanCorsFilter corsFilterRegistrationBean() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); FilterRegistrationBeanCorsFilter 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 FilterRegistrationBeanSaServletFilter saFilterRegistrationBean() { FilterRegistrationBeanSaServletFilter 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 FilterRegistrationBeanCorsFilter corsFilterRegistrationBean() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); FilterRegistrationBeanCorsFilter registrationBean new FilterRegistrationBean(); registrationBean.setFilter(new CorsFilter(source)); registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); registrationBean.addUrlPatterns(/*); return registrationBean; } /** * Sa-Token 过滤器排在 CORS 之后 */ Bean public FilterRegistrationBeanSaServletFilter 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)); FilterRegistrationBeanSaServletFilter 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 高频率踩坑点速查梳理一下这套技术栈下最容易踩的坑以及对应的处理思路。现象根本原因解决方向预检请求返回 401Sa-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 和日志过滤器之间的顺序冲突。核心方法都是通用的先画出请求链路图标出每个过滤器处理哪些请求再看有没有节点产生冲突最后决定是调整顺序还是放行特定请求。养成这个习惯之后这类问题从玄学变成了按图索骥排查速度能快不少。