Spring Boot 拦截器,我把学习踩坑过程画了张图
前言
你刚写完第一个登录功能,兴致勃勃去点"个人中心"------诶,怎么直接进去了?再试一次,连没登录的后台接口都能直接调。
你愣了两秒,才意识到一个问题:我写了登录,但没做拦截。 任何一个没登录的人,只要知道 URL 就能访问所有页面。
"等一下,那 Session 里的 User 是谁放进来的?我拦截器从 Session 拿到的 User,又是谁存的?"
我在敲第一个拦截器的时候,脑子里就炸出了这一串连环问。这篇文章就是我当时整个思考过程的还原------我不打算只给你一个现成的代码,而是带着你把我踩过的坑、想漏的地方、后来优化的点,全部走一遍。
环境说明
| 组件 | 版本/说明 |
|---|---|
| JDK | 17 |
| 框架 | Spring Boot 3.x |
| 鉴权方式 | Session 机制(前后端不分离 / 同域场景) |
| 核心依赖 | spring-boot-starter-web |
| IDE | IntelliJ IDEA |
问题复现
没有拦截器的项目长什么样?看一下关键 Controller:
java
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
public Result<UserVO> profile(HttpSession session) {
User user = (User) session.getAttribute("loginUser");
if (user == null) {
return Result.fail(401, "未登录");
}
// ... 查数据库返回用户信息
return Result.success(userVO);
}
}
每个需要登录的接口,你都在里面手动判断 session.getAttribute("loginUser") == null。写 5 个接口还好,写到第 20 个的时候你已经开始复制粘贴了------而且总有那么一两个接口忘记加,直接暴露出用户隐私数据。
更致命的是:你没法统一控制返回格式。一个接口返回 Result.fail(401, "未登录"),另一个接口直接返回 "please login" 字符串------前端根本没法统一处理。
我当时的思考过程(全流程还原)
第一步:明确目标
我要在请求到达 Controller 之前,统一检查用户是否登录。如果登录了就放行,没登录就拦截住,返回统一的错误信息。
第二步:脑海中浮现的第一个方案
我想着在 Controller 外面包一层统一的校验,让请求先经过它。那这个"关卡"放在哪?
然后我查了资料,发现 Spring 提供了 HandlerInterceptor。它的生命周期是这样的:
Controller 拦截器(Interceptor) HandlerMapping DispatcherServlet 浏览器 Controller 拦截器(Interceptor) HandlerMapping DispatcherServlet 浏览器 #mermaid-svg-zH58TG2kjUt0iwT3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zH58TG2kjUt0iwT3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zH58TG2kjUt0iwT3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zH58TG2kjUt0iwT3 .error-icon{fill:#552222;}#mermaid-svg-zH58TG2kjUt0iwT3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zH58TG2kjUt0iwT3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zH58TG2kjUt0iwT3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zH58TG2kjUt0iwT3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zH58TG2kjUt0iwT3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zH58TG2kjUt0iwT3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zH58TG2kjUt0iwT3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zH58TG2kjUt0iwT3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zH58TG2kjUt0iwT3 .marker.cross{stroke:#333333;}#mermaid-svg-zH58TG2kjUt0iwT3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zH58TG2kjUt0iwT3 p{margin:0;}#mermaid-svg-zH58TG2kjUt0iwT3 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zH58TG2kjUt0iwT3 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-zH58TG2kjUt0iwT3 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-zH58TG2kjUt0iwT3 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-zH58TG2kjUt0iwT3 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-zH58TG2kjUt0iwT3 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-zH58TG2kjUt0iwT3 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-zH58TG2kjUt0iwT3 .sequenceNumber{fill:white;}#mermaid-svg-zH58TG2kjUt0iwT3 #sequencenumber{fill:#333;}#mermaid-svg-zH58TG2kjUt0iwT3 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-zH58TG2kjUt0iwT3 .messageText{fill:#333;stroke:none;}#mermaid-svg-zH58TG2kjUt0iwT3 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zH58TG2kjUt0iwT3 .labelText,#mermaid-svg-zH58TG2kjUt0iwT3 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-zH58TG2kjUt0iwT3 .loopText,#mermaid-svg-zH58TG2kjUt0iwT3 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-zH58TG2kjUt0iwT3 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-zH58TG2kjUt0iwT3 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-zH58TG2kjUt0iwT3 .noteText,#mermaid-svg-zH58TG2kjUt0iwT3 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-zH58TG2kjUt0iwT3 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zH58TG2kjUt0iwT3 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zH58TG2kjUt0iwT3 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zH58TG2kjUt0iwT3 .actorPopupMenu{position:absolute;}#mermaid-svg-zH58TG2kjUt0iwT3 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-zH58TG2kjUt0iwT3 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zH58TG2kjUt0iwT3 .actor-man circle,#mermaid-svg-zH58TG2kjUt0iwT3 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-zH58TG2kjUt0iwT3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt preHandle() 返回 true preHandle() 返回 false GET /api/user/profile 寻找 HandlerMethod 执行 preHandle() 放行 → 执行 Controller 返回数据 postHandle() / afterCompletion() 响应 JSON 直接返回 401(Controller 不执行)
明白了------它就像一个收费站,preHandle() 返回 true 就抬杆放行,返回 false 就拦住。
第三步:我第一个写错的版本
我一开始的想法------从 Session 里拿 User:
java
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
HttpSession session = request.getSession();
User user = (User) session.getAttribute("loginUser");
if (user != null) {
return true; // 已登录,放行
}
// 未登录,返回错误
response.setContentType("application/json;charset=utf-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}");
return false;
}
}
这段代码功能上没问题,但有个隐患:我把整个 User 对象全量从 Session 取出。如果 User 里有密码、手机号等敏感字段,其他地方万一误用了就麻烦。而且 User 对象序列化后体积不小,Session 反复存取也是开销。
第四步:配置拦截路径时踩雷
注册拦截器的时候,我差点把所有路径都拦截了:
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**") // 拦截所有路径
.excludePathPatterns(
"/user/login",
"/user/register",
"/css/**", "/js/**", "/images/**", // 静态资源放行!
"/error"
);
}
}
如果你忘了放行静态资源,登录页的 CSS/JS 全挂,页面裸 HTML 丑到无法直视。我当时排查了 10 分钟才发现是这个问题。
最终方案(逐步优化版本)
版本 1:基础拦截 + ThreadLocal
发现问题之后,我做了两件事:从 Session 只提取最小必要信息;用 ThreadLocal 传递用户信息,避免在每个接口里手动传参。
先定义一个轻量的用户持有对象:
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserHolder {
private Long id;
private String username;
private String avatar;
// 不放 password、phone 等敏感字段
}
然后改造 ThreadLocal 工具类:
java
public class UserContext {
private static final ThreadLocal<UserHolder> holder = new ThreadLocal<>();
public static void set(UserHolder user) {
holder.set(user);
}
public static UserHolder get() {
return holder.get();
}
public static void remove() {
holder.remove(); // 必须清理!否则内存泄漏
}
}
拦截器改造:
java
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 明确:Session 里的 User 是登录接口放进去的
// 登录时:session.setAttribute("loginUser", fullUserObject);
HttpSession session = request.getSession(false); // false = 不创建新 Session
if (session == null) {
writeUnauthorized(response);
return false;
}
User user = (User) session.getAttribute("loginUser");
if (user == null) {
writeUnauthorized(response);
return false;
}
// 只取必要字段,避免敏感信息泄漏
UserHolder holder = new UserHolder(user.getId(), user.getUsername(), user.getAvatar());
UserContext.set(holder);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 关键一步:请求结束后必须清理 ThreadLocal
// 否则 Tomcat 线程池复用线程时,下个请求会读到上个请求的用户信息
UserContext.remove();
}
private void writeUnauthorized(HttpServletResponse response) throws IOException {
response.setContentType("application/json;charset=utf-8");
response.setStatus(401);
response.getWriter().write("{\"code\":401,\"msg\":\"未登录,请先登录\"}");
}
}
版本 2:路径白名单外部化
硬编码的放行路径一旦多了,改起来很烦。抽到 application.yml:
yaml
app:
interceptor:
exclude-paths:
- /user/login
- /user/register
- /public/**
- /css/**
- /js/**
- /images/**
注册时动态读取:
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.interceptor.exclude-paths}")
private List<String> excludePaths;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns(excludePaths);
}
}
原理分析(为什么这么设计)
1. Interceptor 与 Filter 的区别
| 对比维度 | Filter | Interceptor |
|---|---|---|
| 所属规范 | Servlet 规范 | Spring MVC 框架 |
| 执行时机 | 请求进入 DispatcherServlet 之前 | DispatcherServlet 之后 、Controller 之前 |
| 访问 Controller 信息 | 拿不到具体 HandlerMethod | 可以,包括方法、参数、注解 |
| 是否依赖 Spring IoC | 一般不(需手动注入) | 可以自动注入 Bean |
| 拦截粒度 | 按 URL pattern 匹配 | 按 URL + 注解(可以更精细) |
一句话选型原则 :如果你只需要做通用的请求过滤(编码设置、CORS、XSS 过滤),用 Filter。如果你需要感知具体调哪个方法、传了哪些参数、有没有某个注解,用 Interceptor。
2. 为什么 ThreadLocal 必须清理
#mermaid-svg-BjolAK1f549GvfpC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BjolAK1f549GvfpC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BjolAK1f549GvfpC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BjolAK1f549GvfpC .error-icon{fill:#552222;}#mermaid-svg-BjolAK1f549GvfpC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BjolAK1f549GvfpC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BjolAK1f549GvfpC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BjolAK1f549GvfpC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BjolAK1f549GvfpC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BjolAK1f549GvfpC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BjolAK1f549GvfpC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BjolAK1f549GvfpC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BjolAK1f549GvfpC .marker.cross{stroke:#333333;}#mermaid-svg-BjolAK1f549GvfpC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BjolAK1f549GvfpC p{margin:0;}#mermaid-svg-BjolAK1f549GvfpC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BjolAK1f549GvfpC .cluster-label text{fill:#333;}#mermaid-svg-BjolAK1f549GvfpC .cluster-label span{color:#333;}#mermaid-svg-BjolAK1f549GvfpC .cluster-label span p{background-color:transparent;}#mermaid-svg-BjolAK1f549GvfpC .label text,#mermaid-svg-BjolAK1f549GvfpC span{fill:#333;color:#333;}#mermaid-svg-BjolAK1f549GvfpC .node rect,#mermaid-svg-BjolAK1f549GvfpC .node circle,#mermaid-svg-BjolAK1f549GvfpC .node ellipse,#mermaid-svg-BjolAK1f549GvfpC .node polygon,#mermaid-svg-BjolAK1f549GvfpC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BjolAK1f549GvfpC .rough-node .label text,#mermaid-svg-BjolAK1f549GvfpC .node .label text,#mermaid-svg-BjolAK1f549GvfpC .image-shape .label,#mermaid-svg-BjolAK1f549GvfpC .icon-shape .label{text-anchor:middle;}#mermaid-svg-BjolAK1f549GvfpC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BjolAK1f549GvfpC .rough-node .label,#mermaid-svg-BjolAK1f549GvfpC .node .label,#mermaid-svg-BjolAK1f549GvfpC .image-shape .label,#mermaid-svg-BjolAK1f549GvfpC .icon-shape .label{text-align:center;}#mermaid-svg-BjolAK1f549GvfpC .node.clickable{cursor:pointer;}#mermaid-svg-BjolAK1f549GvfpC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BjolAK1f549GvfpC .arrowheadPath{fill:#333333;}#mermaid-svg-BjolAK1f549GvfpC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BjolAK1f549GvfpC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BjolAK1f549GvfpC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BjolAK1f549GvfpC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BjolAK1f549GvfpC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BjolAK1f549GvfpC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BjolAK1f549GvfpC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BjolAK1f549GvfpC .cluster text{fill:#333;}#mermaid-svg-BjolAK1f549GvfpC .cluster span{color:#333;}#mermaid-svg-BjolAK1f549GvfpC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BjolAK1f549GvfpC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BjolAK1f549GvfpC rect.text{fill:none;stroke-width:0;}#mermaid-svg-BjolAK1f549GvfpC .icon-shape,#mermaid-svg-BjolAK1f549GvfpC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BjolAK1f549GvfpC .icon-shape p,#mermaid-svg-BjolAK1f549GvfpC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BjolAK1f549GvfpC .icon-shape .label rect,#mermaid-svg-BjolAK1f549GvfpC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BjolAK1f549GvfpC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BjolAK1f549GvfpC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BjolAK1f549GvfpC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 没做登录拦截的接口
登录拦截的接口
请求1 到达 Tomcat线程T1
Interceptor 设置 UserContext
Controller / Service 正常处理
afterCompletion 未清理 UserContext
线程 T1 被归还到 Tomcat 线程池
请求2 到达 分配到同一线程 T1
UserContext 还残留请求1的User信息
业务代码误拿了请求1的User!
被覆盖掉,不影响
数据错乱、权限泄露等生产事故
一句话 :Tomcat 使用线程池处理请求,线程不会销毁而是复用。如果你在 afterCompletion() 里不调用 UserContext.remove(),下一个分配到该线程的请求可能读到上一个请求的用户信息------轻则数据错乱,重则权限泄露。
3. 你的方案 vs 常见做法的对比
| 维度 | 最朴素的做法(新手) | 你的方案(ThreadLocal) | 进阶方案(JWT) |
|---|---|---|---|
| 用户信息来源 | 在每个 Controller 手动 getSession | Session → 轻量 DTO → ThreadLocal | Token → JWT 解析 → ThreadLocal |
| 信息存储 | 散落在各个 Controller | 统一在 ThreadLocal | 统一在 ThreadLocal |
| 敏感信息管控 | 容易泄露 | 只取必要字段 | 可以控制 |
| 适用场景 | 单体应用 | 单体应用 | 前后端分离、微服务 |
| 无状态 | 否(依赖 Session) | 否(依赖 Session) | 是 |
| 实现复杂度 | 低 | 中 | 中高 |
4. 完整的请求链路
UserContext Service Controller 登录拦截器 DispatcherServlet Filter链 浏览器 UserContext Service Controller 登录拦截器 DispatcherServlet Filter链 浏览器 #mermaid-svg-HNC0gopn8BLBQ1QM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HNC0gopn8BLBQ1QM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HNC0gopn8BLBQ1QM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HNC0gopn8BLBQ1QM .error-icon{fill:#552222;}#mermaid-svg-HNC0gopn8BLBQ1QM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HNC0gopn8BLBQ1QM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HNC0gopn8BLBQ1QM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HNC0gopn8BLBQ1QM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HNC0gopn8BLBQ1QM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HNC0gopn8BLBQ1QM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HNC0gopn8BLBQ1QM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HNC0gopn8BLBQ1QM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HNC0gopn8BLBQ1QM .marker.cross{stroke:#333333;}#mermaid-svg-HNC0gopn8BLBQ1QM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HNC0gopn8BLBQ1QM p{margin:0;}#mermaid-svg-HNC0gopn8BLBQ1QM .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HNC0gopn8BLBQ1QM text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-HNC0gopn8BLBQ1QM .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HNC0gopn8BLBQ1QM .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-HNC0gopn8BLBQ1QM .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-HNC0gopn8BLBQ1QM .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-HNC0gopn8BLBQ1QM #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-HNC0gopn8BLBQ1QM .sequenceNumber{fill:white;}#mermaid-svg-HNC0gopn8BLBQ1QM #sequencenumber{fill:#333;}#mermaid-svg-HNC0gopn8BLBQ1QM #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-HNC0gopn8BLBQ1QM .messageText{fill:#333;stroke:none;}#mermaid-svg-HNC0gopn8BLBQ1QM .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HNC0gopn8BLBQ1QM .labelText,#mermaid-svg-HNC0gopn8BLBQ1QM .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-HNC0gopn8BLBQ1QM .loopText,#mermaid-svg-HNC0gopn8BLBQ1QM .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-HNC0gopn8BLBQ1QM .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HNC0gopn8BLBQ1QM .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-HNC0gopn8BLBQ1QM .noteText,#mermaid-svg-HNC0gopn8BLBQ1QM .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-HNC0gopn8BLBQ1QM .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HNC0gopn8BLBQ1QM .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HNC0gopn8BLBQ1QM .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HNC0gopn8BLBQ1QM .actorPopupMenu{position:absolute;}#mermaid-svg-HNC0gopn8BLBQ1QM .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-HNC0gopn8BLBQ1QM .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HNC0gopn8BLBQ1QM .actor-man circle,#mermaid-svg-HNC0gopn8BLBQ1QM line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-HNC0gopn8BLBQ1QM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt User存在 User不存在 发送请求 通过(UTF-8、CORS等) preHandle() 从Session取User 提取轻量UserHolder 存入ThreadLocal 放行 执行业务 getUserHolder() 返回数据 afterCompletion() UserContext.remove() 响应 JSON响应 直接返回401 JSON
总结
写拦截器的整个过程中,我学到的最重要的几点:
- 拦截器的位置:夹在 HandlerMapping 和 Controller 之间,比 Filter 更靠近业务层,适合做基于"用户"的校验。
- 从 Session 只取必要字段,不要整坨 User 对象到处传------既防止泄漏,也避免 Session 序列化膨胀。
- ThreadLocal 必须 paired with remove(),在 afterCompletion() 里清理,这不是锦上添花,是必须做。
- 配置拦截路径时别漏了静态资源放行------否则网页样式全挂,排查起来很匪夷所思。
- 进阶思考:如果你的项目是前后端分离,或者将来要上微服务,可以把 Session 方案换成 JWT 无状态 token 方案------拦截器的结构几乎不变,核心只是把"从 Session 取"改成"从 Header 解析 Token"。
最后一个小建议:不要为了"省事"省略 application.yml 里的白名单配置。硬编码的放行路径改起来太痛苦了,尤其是你部署上线后运维让你加一个路径,你还得重新打包部署------放到配置文件里,改一下重启就完事。