
p30 这个坎卡住的人比想象中多。你的进度大概是这样的拦截器写完了Redis 里也确实塞了 token前端点登录Network 面板里/user/login明明白白返回 200token 也拿到手了——可页面就是不按剧本走要么闪一下又弹回登录页要么干脆停在原地不动。然后你开始怀疑前端代码写错了翻出那份压缩过的 JS把location.href改来改去重新部署强刷结果页面还是老样子。这时候最容易得出的错误结论是前端有 bug而真正的原因大概率在后端拦截器的路径匹配、Redis 的序列化方式或者你改的那份文件压根不是浏览器正在加载的那一份。下面我按自己实际排查的顺序把这套redis 登录 token 前端跳转的链路从头到尾拆一遍包括每一步为什么容易出错、怎么用三分钟定位责任方以及那些改了前端仍然白改的真实原因。1. 先把跳回主界面这句话拆开到底是谁在跳1.1 三种完全不同的跳转来源很多人一看到页面跳了第一反应就是前端路由写错了。但在黑马点评这套架构里能让页面发生跳转的地方至少有三个它们的表现很像成因却完全不搭边。第一种是后端返回 401前端响应拦截器主动跳。这是最常见的一种。后端LoginInterceptor判定当前请求没有登录用户直接response.setStatus(401)前端 axios 的响应拦截器在 error 分支里捕到这个状态码清掉 sessionStorage 里的 token然后location.href /login.html。整个过程干净利落看起来就像前端自己在跳。第二种是前端路由守卫或页面初始化逻辑跳。有些同学会把判断写在router.beforeEach里或者写在首页mounted钩子里去请求一次用户信息接口请求失败就跳登录页。这种情况下 Network 面板里会多出一次接口调用成功率取决于后端。第三种是静态资源层面的重定向比如 Nginx 的rewrite、try_files配错或者某些情况下访问目录没带index.html被服务器补了一次 301/307。这一种最少见但一旦碰上前端改到天亮也没用因为请求根本没到后端。判断属于哪一种其实只需要看一个东西跳转发生之前Network 面板里最后一个 XHR 请求的返回状态码。如果它是 401问题在后端拦截器如果它是 200 但页面还是跳了问题在前端本地逻辑如果压根没有 XHR那就要去查 Nginx 和路由配置。1.2 三分钟定位责任方我习惯的固定动作是这三步比翻代码快得多打开 DevTools 的 Network 面板勾上Preserve log保留日志筛选Fetch/XHR。这个勾很关键因为页面跳转会导致日志被清空不勾的话你永远看不到跳转前那一刻发生了什么。点击登录观察/user/login或者/api/user/login这次请求HTTP 状态是不是 200响应体里有没有 token 字段如果这一步就挂了那后面全是白扯。看登录成功之后紧接着发出的那一次请求通常是/user/me、/blog/of/user这类需要登录态的接口。它的状态码是 401 还是 200这三步走完你基本就能确定问题出在哪一层。我见过太多人跳过这一步直接去改前端结果花两个小时改了一个根本没被执行到的分支。提示DevTools 的 Network 面板里状态码 401 会显示成红色但它前面的请求可能是 200。别只看颜色按时间顺序点开每一次请求看响应体尤其是响应体的内容——有些项目返回的是 HTTP 200 业务码 401这种在颜色上完全看不出来。2. 从点击登录到进入主页请求到底经过了哪些关卡2.1 服务端token 从哪来存到哪谁去校验黑马点评 p30 这一节的登录方案核心思路是把登录态从 Session 搬到 Redis。流程是这样用户提交手机号和验证码后端校验通过后用UUID.randomUUID().toString(true)生成一个随机 token把用户信息序列化成 JSON 存进 Rediskey 是login:token:{token}同时设置 30 分钟过期时间。String token UUID.randomUUID().toString(true); String tokenKey LOGIN_USER_KEY token; stringRedisTemplate.opsForValue().set( tokenKey, JSONUtil.toJsonStr(userDTO), LOGIN_USER_TTL, TimeUnit.MINUTES ); return token;这里有个细节特别值得说为什么用StringRedisTemplate而不是RedisTemplate。StringRedisTemplate的 key 和 value 序列化器都是StringRedisSerializer存进去的是肉眼可读的字符串你用redis-cli一看就明白。而RedisTemplate默认用的是JdkSerializationRedisSerializerkey 会被序列化成一串带\xac\xed前缀的二进制keys *出来一堆乱码排查问题时能把人看崩溃。所以在这个项目里统一用StringRedisTemplate需要存对象就手动JSONUtil.toJsonStr这是成本最低、排查最友好的做法。校验侧靠两个拦截器配合。RefreshTokenInterceptor负责有 token 就续命它从请求头authorization里取 token去 Redis 查用户信息查到就塞进ThreadLocal并且把 key 的过期时间重新刷成 30 分钟查不到就什么都不做直接放行交给下一个拦截器处理。LoginInterceptor负责真的拦人它只看ThreadLocal里有没有用户没有就返回 401。public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (UserHolder.getUser() null) { response.setStatus(401); return false; } return true; }两个拦截器的注册顺序非常关键RefreshTokenInterceptor的order必须小于LoginInterceptor否则ThreadLocal永远是空的所有需要登录的接口都会返回 401看起来就像登录成功了但一直被踢。2.2 前端token 存哪里怎么带什么条件下跳前端这边干的活其实就三件事。登录成功后把 token 存进sessionStorage每次发请求时通过请求拦截器给 header 加上authorization响应拦截器捕获 401 后清 token 并且跳回登录页。axios.interceptors.request.use(config { const token sessionStorage.getItem(token); if (token) { config.headers[authorization] token; } return config; }); axios.interceptors.response.use( response response, error { if (error.response error.response.status 401) { sessionStorage.removeItem(token); location.href /login.html; } return Promise.reject(error); } );链路走到这里任何一环对不上都会表现成登录以后一直跳token 没存进去、存进去了但 key 名不一致、请求头名字写错、后端没读到、读取的 Redis key 前缀对不上、拦截器顺序反了——它们在外行看来是同一个现象。2.3 Nginx 这一层最容易被完全忽略黑马点评的前端是用 Nginx 托管的静态页面和/api反向代理都在一个nginx.conf里。这里藏着一个很多人没意识到的坑proxy_pass结尾有没有斜杠会直接改变转发给后端的路径。Nginx 配置浏览器请求后端实际收到location /api { proxy_pass http://127.0.0.1:8081; }/api/user/login/api/user/loginlocation /api { proxy_pass http://127.0.0.1:8081/; }/api/user/login/user/login看起来只是少了一个斜杠实际后果是后端拦截器的excludePathPatterns里写的路径可能全部对不上。如果你的放行列表写的是/user/login而后端收到的是/api/user/login那登录接口本身就会被LoginInterceptor拦下来直接返回 401。这时候前端表现就是点登录接口 401页面跳回登录页——你会以为是前端跳转有问题其实后端在第一步就把你拒了。3. 为什么改完前端还是跳三个高频真凶3.1 真凶一序列化方式不一致导致的静默失效这是最隐蔽的一种。写入的时候用了StringRedisTemplate读取的时候手一抖换成了RedisTemplate或者反过来代码不报错编译通过运行也不抛异常但就是查不到数据。// 写入 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(userDTO), 30, TimeUnit.MINUTES); // 读取时用错了模板 Object obj redisTemplate.opsForValue().get(key); // 反序列化出来的东西对不上因为两边的序列化器不同读出来的字节流没法正确还原成对象JSONUtil.toBean一顿操作之后得到个空对象或者直接抛异常被 catch 掉了最后ThreadLocal里是空的LoginInterceptor判定未登录401前端跳转。整个链条没有任何一处报错这就是它难查的原因。验证方法很简单连上 Redis 敲几行命令redis-cli -h 127.0.0.1 -p 6379 keys login:* get login:token:xxxxxxxxxxxxxxxx ttl login:token:xxxxxxxxxxxxxxxx如果keys login:*什么都查不出来但你确认后端写入时没抛异常那就是 key 被 JDK 序列化成二进制了肉眼看不见。加上--raw参数再试一次redis-cli --raw keys *看到一坨带\xac\xed的乱码答案就出来了。注意这不只是 p30 的问题任何写入用一种模板、读取用另一种模板的场景都会踩。统一成StringRedisTemplate 手动 JSON 序列化是最省心的路径也方便用可视化工具直接看数据。3.2 真凶二放行路径少了前缀或者拦截器压根没注册拦截器的放行列表是最容易写错的地方因为它写错了不会有任何提示。registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)) .addPathPatterns(/**) .order(0); registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( /user/code, /user/login, /blog/hot, /shop/**, /shop-type/**, /upload/**, /voucher/** ) .order(1);这段代码看着没毛病但如果 Nginx 转发时保留了/api前缀那真实路径就变成了/api/user/login而放行列表里写的是/user/login匹配不上登录接口被拦。解决办法二选一要么把 Nginx 的proxy_pass改成带斜杠的形式剥掉前缀要么把放行列表里的路径全部加上/api。哪种都行但必须让两边的认知一致。另一种更隐形的情况是忘了注册RefreshTokenInterceptor只注册了LoginInterceptor。这时候ThreadLocal永远是空的任何需要登录的接口都是 401前端永远在跳。这种问题的特点是登录接口本身返回 200 而且带 token但下一个请求必挂。3.3 真凶三你改的那份文件浏览器根本没加载这是标题里修改前端代码后还是一直跳转最典型的成因也是最让人血压升高的一个。黑马点评配套的前端资源是打包压缩过的产物JS 里的变量名被压成了a、b、c函数被内联代码可读性基本为零。你在某个dist目录里改了location.href但Nginx 指向的是另一个目录你改的那份不是线上生效的那份或者浏览器把旧的 JS 强缓存了Network 面板里 Size 列显示(disk cache)或(memory cache)压根没去服务器拿新文件或者文件名的 hash 没变浏览器对比缓存后直接复用了本地副本。判断方法Network 面板里点开那个 JS 文件看 Response Headers 里的Cache-Control和实际 Size 列。如果 Size 列写的是(disk cache)那就一定是缓存问题。解决办法也很直接DevTools 的 Network 面板勾上Disable cache然后Ctrl Shift R硬刷新或者改一下文件名比如从index-abc123.js改成index-def456.js让浏览器认为这是个新资源再或者干脆把 Nginx 的缓存策略调一下给静态资源加个短一点的expires。4. 我实际排查的顺序从 401 的出处倒推4.1 第一步确认登录接口自己是不是干净的先用 curl 绕开前端直接打后端接口这一步能排除掉 90% 的前端干扰curl -i -X POST http://127.0.0.1:8081/user/login \ -H Content-Type: application/json \ -d {phone:13612345678,code:123456}看三件事HTTP 状态码是不是 200响应体里有没有data字段且里面装着 token响应头里有没有异常的 Set-Cookie。如果这一步就返回 401 或 500那问题百分之百在后端逻辑或路径匹配上跟前端没关系。4.2 第二步确认 Redis 里到底有没有这条记录登录成功之后立刻去 Redis 里看一眼这是最直接的证据redis-cli -h 127.0.0.1 -p 6379 --raw keys login:* redis-cli -h 127.0.0.1 -p 6379 ttl login:token:xxxxxxxx看到 key 并且 TTL 在 1800 秒左右说明写入这一环是通的。看不到 key 或者 TTL 是 -1永不过期说明没设过期时间或 -2key 不存在那就要回头看写入逻辑。这里有个小细节如果你在浏览器里登录完不操作30 分钟后 token 就过期了这时候再去查当然查不到别自己把自己吓到。4.3 第三步手工带上 token 再打一次需要登录的接口这一步是分水岭。把第二步查到的 token 复制出来手工构造一个带authorization头的请求curl -i http://127.0.0.1:8081/user/me \ -H authorization: 你复制出来的token返回 200说明后端的整条读取链路是通的问题在前端怎么存、怎么带返回 401说明后端读不到这条 token问题大概率在拦截器的路径匹配、拦截器注册顺序或者序列化方式上。现象最可能的原因下一步动作登录接口就返回 401放行路径没带/api前缀检查 Nginxproxy_pass和excludePathPatterns登录 200 但 Redis 查不到 key写入用了RedisTemplate/ 写入异常被吞统一用StringRedisTemplate检查异常日志Redis 有 key 但手工请求仍 401请求头名字不一致 / 拦截器顺序反了核对authorization拼写核对order手工请求 200浏览器仍跳前端存 key 名不一致 / 缓存了旧 JS查 Session Storage勾 Disable cache4.4 第四步核对前端存的和后端读的是不是同一个名字打开 DevTools 的 Application 面板展开 Session Storage看你登录之后里面到底有没有token这一项。名字对不上的情况太常见了——前端存的是token请求头里读的是Authorization首字母大写后端取的是authorization全小写。HTTP 头字段本身是不区分大小写的但如果你用了getHeader(Authorization)而前端发的是全小写某些容器实现下是能取到的某些就有问题所以统一小写是最保险的。同样Redis 里的 key 前缀也要核一遍。LOGIN_USER_KEY这个常量是不是在两个地方用的是同一个值有人写着写着在前端配置里改了个前缀写入和读取就对不上了。5. 前端那点事在压缩过的 JS 里把跳转逻辑揪出来5.1 用关键词把拦截器代码定位出来前端代码是压缩过的但字符串常量不会被压缩。打开 DevTools 的 Sources 面板按Ctrl Shift F打开全局搜索依次搜这几个关键词authorization能直接定位到请求拦截器sessionStorage能看到 token 存在哪个 key 下401能看到响应拦截器里的判断条件login.html能看到跳转的目标地址搜到之后先在那个位置打一个断点然后重新走一次登录流程。断点命中时看作用域里的变量值能立刻知道到底是哪一步判断走错了。如果代码被压缩得太狠可以在 Sources 面板左下角点那个{}按钮做一次格式化可读性会好很多。如果 Nginx 托管目录你有写权限用grep比在浏览器里搜更快grep -rn sessionStorage /usr/local/nginx/html/js/ | head -20 grep -rn login.html /usr/local/nginx/html/js/ | head -205.2 不碰前端也能验证假设的兜底手段有时候你只是想知道是不是前端那个响应拦截器在跳而不是真的要改前端。这时候有个很省事的办法临时把后端的 401 改成 200。// 临时验证用测完记得改回来 response.setStatus(200); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false;如果改完之后页面不跳了只是接口返回体里带着未登录信息那就能确认跳转是前端响应拦截器干的事问题定位完成。这个手法的价值在于它把跳转这个动作从整条链路里摘出来了剩下的就是纯粹的数据问题。另一个更优雅的做法是用 DevTools 的Local Overrides本地覆盖功能在 Sources 面板左边找到那个 JS 文件右键选Save for overrides然后在本地副本里改代码刷新页面就会加载你改过的版本。这个功能的妙处是不用动 Nginx 目录、不用重新部署纯粹用于验证假设验证完把 override 删掉就行。提示Local Overrides 改的是浏览器本地的副本别人访问同一个地址拿到的还是原始文件。所以它适合验证不适合交付。验证完了要把真正的修改落到源文件里重新打包。5.3 真要改前端怎么改才不白改如果确认必须改前端有几个顺序上的细节值得注意。第一先备份。把整个静态资源目录复制一份出来改坏了能立刻回滚。第二改完必须破缓存。要么改文件名加 hash要么在 Nginx 里给 JS 配置add_header Cache-Control no-cache。只改内容不改文件名浏览器很可能继续用旧副本然后你就会陷入我明明改了为什么没生效的死循环。第三判断条件要写对。有些项目的响应拦截器只判断res.data.code 401但后端实际返回的是 HTTP 状态码 401响应体是空的。这种情况下判断永远不成立页面就不会跳反过来如果后端返回 HTTP 200 而响应体里带着业务码 401那只判断 HTTP 状态的写法又会漏掉。两边都得看一遍再写判断。第四登录成功后的跳转要确认 sessionStorage 已经写进去了。如果写在异步回调里而跳转写在回调外面那就是典型的token 还没存好就开始跳下一个页面的路由守卫一查没有 token又把你弹回登录页来回震荡。6. 修完之后才慢慢想明白的几个细节6.1 ThreadLocal 一定要在 afterCompletion 里清掉这个坑跟跳转没有直接关系但属于同一个模块里迟早要踩的。RefreshTokenInterceptor把用户塞进了ThreadLocal如果不清理Tomcat 的线程池会把上一次请求的用户信息带到下一次请求上。表现是某个匿名用户偶发地变成了另一个已登录用户或者你自己测试时明明没登录却显示已登录。Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserHolder.removeUser(); }这一段必须写而且必须写在RefreshTokenInterceptor里不能写在LoginInterceptor里——因为LoginInterceptor在未登录时会返回 falseafterCompletion可能根本不执行。6.2 token 续期与有效期的取舍p30 用的是每次请求刷新 TTL的方案只要用户还在操作token 就一直有效一旦停手超过 30 分钟就得重新登录。这个方案的优点是实现简单缺点也很明显——如果一个用户一直开着页面不动30 分钟后点任何按钮都会被弹回登录页体验不太好。如果想让体验好一点可以把 TTL 拉到 1 到 2 小时或者在前端加一层401 时先尝试静默刷新的逻辑。不过对学习项目来说30 分钟挺好它能让你快速观察到过期行为也方便验证expire有没有生效。6.3 一份可以照着走的检查清单把上面这些散点收成一张表下次再遇到登录不跳转就直接从上往下过一遍检查项怎么看正常表现登录接口 HTTP 状态curl 或 Network 面板200响应体里有 tokenNetwork 面板看响应体有Redis 里存在login:token:*redis-cli --raw keys login:*能查到非乱码TTL 合理redis-cli ttl key1800 左右不是 -1Session Storage 存了 tokenDevTools → Applicationkey 名与前端读取一致请求头带了 authorizationNetwork 面板看 Request Headers与后端getHeader参数一致手工带 token 请求成功curl 加 header200拦截器注册顺序看WebMvcConfigurer配置Refresh 的 order 小于 Login放行路径与实际路径一致对比 Nginx 配置和excludePathPatterns完全一致JS 是否走了缓存Network 面板 Size 列不是 from disk/memory cache我自己一开始也在这个问题上绕了很久当时的想法是登录成功了页面还不跳那肯定是前端的事结果花了两个小时改 JS最后发现是proxy_pass少了个斜杠导致放行路径对不上后端根本没让登录请求过去。从那以后我养成了一个习惯只要页面跳转跟登录态有关先在 Network 面板确认最后一次请求的状态码再决定往哪边查。这个顺序能省下的时间比记住十个配置项都多。另外还有一个很实用的小习惯就是每改一处跟登录相关的配置都顺手清一次 Redis 里对应的 key 再重新登录一遍避免被上一次测试残留的脏数据误导——这一步看似多余但它能排除掉大量看起来像 bug、其实是脏数据的假象。