Spring Boot 拦截器,我把学习踩坑过程画了张图

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


总结

写拦截器的整个过程中,我学到的最重要的几点:

  1. 拦截器的位置:夹在 HandlerMapping 和 Controller 之间,比 Filter 更靠近业务层,适合做基于"用户"的校验。
  2. 从 Session 只取必要字段,不要整坨 User 对象到处传------既防止泄漏,也避免 Session 序列化膨胀。
  3. ThreadLocal 必须 paired with remove(),在 afterCompletion() 里清理,这不是锦上添花,是必须做。
  4. 配置拦截路径时别漏了静态资源放行------否则网页样式全挂,排查起来很匪夷所思。
  5. 进阶思考:如果你的项目是前后端分离,或者将来要上微服务,可以把 Session 方案换成 JWT 无状态 token 方案------拦截器的结构几乎不变,核心只是把"从 Session 取"改成"从 Header 解析 Token"。

最后一个小建议:不要为了"省事"省略 application.yml 里的白名单配置。硬编码的放行路径改起来太痛苦了,尤其是你部署上线后运维让你加一个路径,你还得重新打包部署------放到配置文件里,改一下重启就完事。


参考资料

相关推荐
治愈系贝壳1 小时前
ARM ---day1
linux·arm开发·学习
qdprobot1 小时前
齐护AIOT科创社团学习套件桌面总程AiTall V3版人工智能Mixly图形化编程AI小智物联网AI具身智能应用椴木科创作品
人工智能·物联网·学习
用户713874229001 小时前
深入理解 Claude Code 的 Skill 机制:可复用能力与上下文成本的平衡术
后端
亦暖筑序1 小时前
AgentScope-Java 入门:保存评审状态并生成 Markdown 报告
java·人工智能·后端
遇乐的果园1 小时前
前端学习笔记-vue列表处理
前端·vue.js·学习
小弥儿1 小时前
GitHub今日热榜 | 2026-07-16:AI知识库与反AI味设计成今日双主线
人工智能·学习·github
invicinble1 小时前
关于微服务基本框架搭建--fegin最小使用demo
java·spring boot·微服务
Csvn1 小时前
📊 SQL 入门 Day 4:聚合函数 — COUNT / SUM / AVG / MIN / MAX
后端·sql
菩提树下的打坐1 小时前
CI 中的测试分层:让金字塔真正落地
学习·ci/cd
用户298698530142 小时前
Java 创建 CSV 文件的三种实用方法:从零构建、数组写入与 Excel 转换
java·后端·excel