【项目扩展实战|第6篇】Spring Boot 拦截器和 ThreadLocal 保存登录用户

发布时间:2026/7/21 7:40:44
【项目扩展实战|第6篇】Spring Boot 拦截器和 ThreadLocal 保存登录用户 前言前面我们已经完成了 JWT 登录认证也在 AOP 操作日志中提到过LoginUserContext。但是当时只是简单用了一个工具类还没有完整讲它的数据是怎么来的。这一篇我们就来补上这个关键环节使用拦截器解析 Token并通过 ThreadLocal 保存当前登录用户信息。在真实项目中很多地方都需要知道当前是谁在操作比如查询我的个人信息新增数据时自动填充创建人修改数据时记录更新人操作日志记录操作人权限校验判断当前用户角色如果每个接口都手动解析 Token代码会非常重复。所以更常见的做法是请求进入后端 | 拦截器读取 Token | 解析出用户 ID 和用户名 | 保存到 ThreadLocal | Controller / Service / AOP 中直接获取当前用户 | 请求结束后清理 ThreadLocal这一篇我们就完整实现这个流程。一、为什么需要拦截器先看一种不推荐的写法GetMapping(/me)publicResultUserVOme(HttpServletRequestrequest){Stringtokenrequest.getHeader(Authorization);LonguserIdJwtUtil.parseUserId(token);UserVOuserVOuserService.getCurrentUser(userId);returnResult.success(userVO);}这样写的问题很明显每个需要登录的接口都要解析 TokenController 代码变得臃肿Token 校验逻辑重复后期修改认证逻辑很麻烦AOP、Service 中也不方便拿当前用户所以我们把登录校验统一放到拦截器中。Controller 只需要这样写GetMapping(/me)publicResultUserVOme(){UserVOuserVOuserService.getCurrentUser();returnResult.success(userVO);}当前用户是谁由 ThreadLocal 提供。二、准备当前登录用户对象先定义一个当前登录用户对象。packagecom.example.context;importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;DataNoArgsConstructorAllArgsConstructorpublicclassLoginUser{privateLonguserId;privateStringusername;}文字说明这个类不一定要和数据库用户表完全一致。它只保存当前请求中常用的登录信息比如用户 ID用户名用户角色租户 ID部门 ID这里为了讲清楚流程只保留userId和username。三、封装 ThreadLocal 工具类packagecom.example.context;publicclassLoginUserContext{privatestaticfinalThreadLocalLoginUserLOGIN_USER_HOLDERnewThreadLocal();privateLoginUserContext(){}publicstaticvoidset(LoginUserloginUser){LOGIN_USER_HOLDER.set(loginUser);}publicstaticLoginUserget(){returnLOGIN_USER_HOLDER.get();}publicstaticLonggetUserId(){LoginUserloginUserget();returnloginUsernull?null:loginUser.getUserId();}publicstaticStringgetUsername(){LoginUserloginUserget();returnloginUsernull?null:loginUser.getUsername();}publicstaticvoidclear(){LOGIN_USER_HOLDER.remove();}}文字说明ThreadLocal可以给当前线程保存一份独立数据。一次 HTTP 请求通常由一个线程处理所以我们可以把当前用户信息放到 ThreadLocal 中。后续在同一次请求里无论是 Controller、Service还是 AOP都可以通过LoginUserContext.getUserId()拿到当前用户 ID。这里一定要注意LOGIN_USER_HOLDER.remove();请求结束后必须清理 ThreadLocal否则在线程池环境下可能出现用户信息残留。四、准备 JWT 工具类前面登录认证已经生成过 Token这里补一个解析示例。packagecom.example.util;importcom.example.context.LoginUser;importio.jsonwebtoken.Claims;importio.jsonwebtoken.Jwts;importjava.nio.charset.StandardCharsets;publicclassJwtUtil{privatestaticfinalStringSECRET_KEYreplace-with-your-secret-key;privateJwtUtil(){}publicstaticLoginUserparseLoginUser(Stringtoken){ClaimsclaimsJwts.parserBuilder().setSigningKey(SECRET_KEY.getBytes(StandardCharsets.UTF_8)).build().parseClaimsJws(token).getBody();LonguserIdLong.valueOf(claims.get(userId).toString());Stringusernameclaims.get(username).toString();returnnewLoginUser(userId,username);}}文字说明这个工具类根据 Token 解析出用户信息。实际项目中密钥不要直接写死在代码里更推荐放到配置文件中并且不要提交真实密钥。比如jwt:secret-key:${JWT_SECRET_KEY}然后通过配置类读取。这里为了让文章代码更直观所以写成了简化示例。五、编写登录拦截器1. 拦截器代码packagecom.example.interceptor;importcom.example.common.ResultCode;importcom.example.context.LoginUser;importcom.example.context.LoginUserContext;importcom.example.exception.BusinessException;importcom.example.util.JwtUtil;importjakarta.servlet.http.HttpServletRequest;importjakarta.servlet.http.HttpServletResponse;importorg.springframework.stereotype.Component;importorg.springframework.util.StringUtils;importorg.springframework.web.servlet.HandlerInterceptor;ComponentpublicclassLoginInterceptorimplementsHandlerInterceptor{privatestaticfinalStringAUTHORIZATION_HEADERAuthorization;privatestaticfinalStringBEARER_PREFIXBearer ;OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){Stringtokenrequest.getHeader(AUTHORIZATION_HEADER);if(!StringUtils.hasText(token)){thrownewBusinessException(ResultCode.UNAUTHORIZED);}if(token.startsWith(BEARER_PREFIX)){tokentoken.substring(BEARER_PREFIX.length());}try{LoginUserloginUserJwtUtil.parseLoginUser(token);LoginUserContext.set(loginUser);returntrue;}catch(Exceptione){thrownewBusinessException(ResultCode.TOKEN_INVALID);}}OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex){LoginUserContext.clear();}}文字说明这个拦截器主要做了三件事从请求头中获取Authorization解析 Token 得到当前登录用户把用户信息保存到 ThreadLocal请求头一般长这样Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...所以代码中先判断是否以Bearer开头如果是就把前缀去掉tokentoken.substring(BEARER_PREFIX.length());解析成功后LoginUserContext.set(loginUser);这样后续业务代码就可以直接获取当前用户了。最后在afterCompletion中清理LoginUserContext.clear();这个步骤非常重要不能省略。六、注册拦截器只写拦截器类还不够还需要把它注册到 Spring MVC 中。packagecom.example.config;importcom.example.interceptor.LoginInterceptor;importlombok.RequiredArgsConstructor;importorg.springframework.context.annotation.Configuration;importorg.springframework.web.servlet.config.annotation.*;ConfigurationRequiredArgsConstructorpublicclassWebMvcConfigimplementsWebMvcConfigurer{privatefinalLoginInterceptorloginInterceptor;OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){registry.addInterceptor(loginInterceptor).addPathPatterns(/**).excludePathPatterns(/user/login,/user/login/code,/captcha/send,/error);}}文字说明这里通过.addPathPatterns(/**)表示拦截所有请求。再通过.excludePathPatterns(...)排除不需要登录的接口。比如登录接口本身就不能被登录拦截器拦截否则用户还没登录就没法调用登录接口了。常见排除路径有登录接口注册接口验证码发送接口静态资源Swagger 接口文档错误路径/error实际项目中可以根据自己的接口路径调整。七、在 Controller 中获取当前用户下面写一个查询当前用户信息的接口。1. Controller 层packagecom.example.controller;importcom.example.common.Result;importcom.example.service.UserService;importcom.example.vo.UserVO;importlombok.RequiredArgsConstructor;importorg.springframework.web.bind.annotation.*;RestControllerRequestMapping(/user)RequiredArgsConstructorpublicclassUserController{privatefinalUserServiceuserService;GetMapping(/me)publicResultUserVOgetCurrentUser(){UserVOuserVOuserService.getCurrentUser();returnResult.success(userVO);}}文字说明可以看到Controller 中没有解析 Token也没有接收用户 ID。这是因为用户 ID 已经由拦截器解析并放入 ThreadLocal 了。Controller 只需要调用 Service 即可。2. Service 接口packagecom.example.service;importcom.example.vo.UserVO;publicinterfaceUserService{UserVOgetCurrentUser();}3. Service 实现类packagecom.example.service.impl;importcom.example.common.ResultCode;importcom.example.context.LoginUserContext;importcom.example.entity.User;importcom.example.exception.BusinessException;importcom.example.mapper.UserMapper;importcom.example.service.UserService;importcom.example.vo.UserVO;importlombok.RequiredArgsConstructor;importorg.springframework.stereotype.Service;ServiceRequiredArgsConstructorpublicclassUserServiceImplimplementsUserService{privatefinalUserMapperuserMapper;OverridepublicUserVOgetCurrentUser(){LonguserIdLoginUserContext.getUserId();if(userIdnull){thrownewBusinessException(ResultCode.UNAUTHORIZED);}UseruseruserMapper.selectById(userId);if(usernull){thrownewBusinessException(ResultCode.USER_NOT_FOUND);}UserVOuserVOnewUserVO();userVO.setId(user.getId());userVO.setUsername(user.getUsername());userVO.setNickname(user.getNickname());userVO.setAvatar(user.getAvatar());returnuserVO;}}文字说明Service 中通过LoginUserContext.getUserId()获取当前登录用户 ID。然后根据用户 ID 查询数据库组装成UserVO返回。这样做有一个好处调用方不需要传用户 ID避免用户伪造 ID 查询别人的信息。比如如果接口设计成GET /user/1001那就需要额外判断当前登录用户有没有权限访问1001这个用户。而/user/me这种接口直接使用当前登录用户更安全也更清晰。4. Mapper 层packagecom.example.mapper;importcom.example.entity.User;importorg.apache.ibatis.annotations.Mapper;importorg.apache.ibatis.annotations.Select;MapperpublicinterfaceUserMapper{Select(select id, username, nickname, avatar, status from user where id #{id})UserselectById(Longid);}文字说明Mapper 层只负责根据用户 ID 查询用户信息。这里不要把密码字段查出来也不要返回给前端。如果项目中原本的User实体包含密码字段也要注意在 VO 转换时不要带出去。八、在 AOP 操作日志中使用当前用户上一篇操作日志里面我们写过operateLog.setOperatorId(LoginUserContext.getUserId());operateLog.setOperatorName(LoginUserContext.getUsername());现在就能理解这两行代码的数据来源了。完整流程是请求进入 | LoginInterceptor 解析 Token | LoginUserContext.set(loginUser) | Controller 执行业务 | AOP 记录操作日志 | LoginUserContext.getUserId() | 保存操作人信息 | 请求结束 | LoginUserContext.clear()这样 AOP 不需要关心 Token 怎么解析只需要从上下文拿当前用户。九、ThreadLocal 为什么适合保存当前请求用户ThreadLocal的特点是每个线程都有自己独立的一份变量副本。在 Web 项目中一个请求通常由一个线程处理。所以当前请求中的用户信息可以绑定到当前线程上。例如用户 A 和用户 B 同时请求接口线程 1 - 用户 A - LoginUserContext 保存 A 的信息 线程 2 - 用户 B - LoginUserContext 保存 B 的信息两个线程互不影响。这也是为什么很多项目会用 ThreadLocal 保存当前登录人、租户信息、请求链路 ID 等上下文数据。十、涉及知识点1. HandlerInterceptorHandlerInterceptor是 Spring MVC 提供的拦截器接口。常用方法有preHandleController 执行前调用postHandleController 执行后、视图渲染前调用afterCompletion请求完成后调用登录校验一般放在preHandle中。ThreadLocal 清理一般放在afterCompletion中。2. Authorization 请求头前后端分离项目中Token 通常放在请求头中Authorization: Bearer token内容后端从请求头中取出 Token再进行解析和校验。3. ThreadLocalThreadLocal 可以在线程内部保存数据。它不是用来做缓存的而是用来保存当前线程上下文。本篇保存的是当前登录用户。4. 拦截器和过滤器的区别过滤器是 Servlet 规范中的组件执行时机更靠前。拦截器是 Spring MVC 提供的组件更适合处理 Controller 层相关逻辑。在 Spring Boot 项目中登录校验用过滤器或拦截器都可以。对于普通业务项目用拦截器更容易和 Spring MVC 结合。十一、常见问题1. 为什么一定要在 afterCompletion 清理 ThreadLocal因为 Web 容器使用的是线程池。一个请求结束后线程不会销毁而是会被复用去处理下一个请求。如果不清理 ThreadLocal可能导致下一个请求拿到上一个用户的信息。所以这一步必须写LoginUserContext.clear();2. 登录接口为什么要排除拦截因为登录接口本身就是用来获取 Token 的。如果登录接口也被拦截就会出现还没登录却要求必须带 Token 的问题。所以需要排除.excludePathPatterns(/user/login)3. Token 解析失败应该返回什么一般返回未登录或登录已过期。例如{code:3001,message:Token 无效或已过期,data:null}具体格式由前面的统一异常处理决定。4. Service 层能不能直接使用 HttpServletRequest不建议。Service 层应该尽量不依赖 Web 请求对象。使用 ThreadLocal 后Service 可以更干净地获取当前用户信息。十二、实际开发建议1. ThreadLocal 中不要放太大的对象不要把完整用户实体、权限列表、大量配置都塞进 ThreadLocal。一般保存常用字段即可比如用户 ID用户名角色标识租户 ID需要详细信息时再根据 ID 查询数据库或缓存。2. Token 密钥不要写死示例代码为了演示方便写了常量。实际项目中建议放到配置文件或环境变量中并且不要提交真实密钥。3. 拦截路径要分清楚哪些接口需要登录哪些接口不需要登录要提前规划。比如商品详情可能允许未登录访问而下单、收藏、修改资料必须登录。4. 当前用户 ID 不要由前端传像“查询我的信息”“新增我的地址”“查看我的订单”这类接口当前用户 ID 应该从 Token 中获取而不是由前端传。这样可以减少越权问题。十三、总结这一篇我们完整实现了拦截器和 ThreadLocal 保存登录用户的流程。核心思路是前端请求时携带 Token后端拦截器读取请求头解析 Token 得到用户信息把用户信息保存到 ThreadLocalController、Service、AOP 中直接获取当前用户请求结束后清理 ThreadLocal这个功能非常重要因为它把登录认证从单个接口中抽离出来变成了一个统一的请求处理流程。到这里JWT 登录认证、统一异常处理、AOP 操作日志、Redis 缓存、ThreadLocal 当前用户上下文就串起来了整个项目的后端基础能力也越来越完整了。