文章目录从UUID到JWT再到Filter/InterceptorSpring Boot登录认证进阶之路1. 基础登录模拟数据 UUID令牌1.1 项目结构1.2 请求DTO1.3 Service——模拟用户与令牌管理1.4 Controller1.5 测试2. 从UUID到JWT让令牌自带“身份证”2.1 有状态 vs 无状态对比2.2 添加JWT依赖2.3 编写JwtUtil工具类2.4 精简Service3. 踩坑Bearer前缀与测试那些事3.1 另一个坑JWT立即过期4. 过滤器Filter第一道防线4.1 Filter的作用4.2 创建LoginCheckFilterSpring Boot 3.x 版本4.3 Filter的尴尬异常无法被Spring全局捕获5. Interceptor登场纳入Spring的异常体系5.1 Filter vs Interceptor5.2 自定义未授权异常5.3 编写LoginCheckInterceptor5.4 配置拦截器白名单6. 统一异常处理RestControllerAdvice7. 总结一张清单回顾所有要点最后的话从UUID到JWT再到Filter/InterceptorSpring Boot登录认证进阶之路这篇文章要带你从零实现一个Spring Boot登录接口并一步步将它从“临时UUID令牌”演变成无状态的JWT再通过Filter → Interceptor → 统一异常处理最终得到一个规范、可维护的认证架构。我们不依赖前端只使用IDEA内置的HTTP Client做所有测试。所有代码都会给出你可以复制即用。1. 基础登录模拟数据 UUID令牌我们先从最简单的入手接收用户名密码验证后返回一个临时令牌。所有用户数据先用HashMap硬编码在内存里令牌就用UUID随机生成。1.1 项目结构src/main/java/com/example/demo ├── DemoApplication.java // 启动类 ├── config │ └── WebConfig.java // 配置拦截器、跨域等 ├── controller │ └── UserController.java // 登录、用户接口 ├── dto │ └── LoginRequest.java // 登录请求体 ├── exception │ ├── GlobalExceptionHandler.java // 全局异常处理 │ └── UnauthorizedException.java // 自定义未授权异常 ├── filter │ └── LoginCheckFilter.java // 登录校验过滤器可选 ├── interceptor │ └── LoginCheckInterceptor.java // 登录校验拦截器 ├── service │ └── UserService.java // 用户服务验证逻辑 └── util └── JwtUtil.java // JWT 工具类1.2 请求DTO// LoginRequest.javapublicclassLoginRequest{privateStringusername;privateStringpassword;// 必须有无参构造Spring才能把JSON转成对象publicLoginRequest(){}// getter/setter 略}注意如果只有全参构造而没有无参构造Spring反序列化时会直接报400这是一个新手非常容易踩的坑。1.3 Service——模拟用户与令牌管理ServicepublicclassUserService{// 模拟数据库中的用户privatestaticfinalMapString,StringMOCK_USERSnewHashMap();static{MOCK_USERS.put(admin,123456);MOCK_USERS.put(user,password);}// 临时存储已登录的令牌有状态方案privatestaticfinalSetStringTOKEN_STOREConcurrentHashMap.newKeySet();publicStringlogin(LoginRequestrequest){StringpwdMOCK_USERS.get(request.getUsername());if(pwd!nullpwd.equals(request.getPassword())){StringtokenUUID.randomUUID().toString();TOKEN_STORE.add(token);// 记住这个令牌returntoken;}returnnull;}publicbooleanisValidToken(Stringtoken){returntoken!nullTOKEN_STORE.contains(token);}}1.4 ControllerRestControllerpublicclassUserController{AutowiredprivateUserServiceuserService;PostMapping(/api/login)publicResponseEntity?login(RequestBodyLoginRequestrequest){StringtokenuserService.login(request);if(token!null){returnResponseEntity.ok(Map.of(token,token));}else{returnResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of(msg,用户名或密码错误));}}}1.5 测试POST http://localhost:8080/api/login Content-Type: application/json { username: admin, password: 123456 }成功返回200和一个随机的UUID。虽然跑通了但这个方案有两大问题令牌随机不携带任何用户信息服务端必须维护一个TOKEN_STORE才知道谁是谁。有状态一旦重启应用所有登录状态全丢扩展多实例时还需要共享存储。2. 从UUID到JWT让令牌自带“身份证”我们希望令牌自己能“说话”携带用户名和有效期服务端不用再记——这就是无状态的JWTJson Web Token。2.1 有状态 vs 无状态对比方案状态存储位置优点缺点UUID令牌有状态服务器内存/Redis实现简单扩展性差内存占用JWT无状态客户端本地服务端无需存储自带用户信息防篡改无法主动注销(需配合黑名单)payload仅Base64不加密2.2 添加JWT依赖pom.xml中加入dependencygroupIdio.jsonwebtoken/groupIdartifactIdjjwt-api/artifactIdversion0.11.5/version/dependencydependencygroupIdio.jsonwebtoken/groupIdartifactIdjjwt-impl/artifactIdversion0.11.5/versionscoperuntime/scope/dependencydependencygroupIdio.jsonwebtoken/groupIdartifactIdjjwt-jackson/artifactIdversion0.11.5/versionscoperuntime/scope/dependency2.3 编写JwtUtil工具类publicclassJwtUtil{privatestaticfinalKeyKEYKeys.secretKeyFor(SignatureAlgorithm.HS256);// 随机密钥privatestaticfinallongEXPIRATION_MS3600_000;// 1小时publicstaticStringgenerateToken(Stringusername){DatenownewDate();DateexpirationnewDate(now.getTime()EXPIRATION_MS);returnJwts.builder().setSubject(username)// 主题放用户名.setIssuedAt(now).setExpiration(expiration).signWith(KEY).compact();}publicstaticClaimsparseToken(Stringtoken){returnJwts.parserBuilder().setSigningKey(KEY).build().parseClaimsJws(token).getBody();}}2.4 精简ServiceServicepublicclassUserService{privatestaticfinalMapString,StringMOCK_USERSnewHashMap();static{MOCK_USERS.put(admin,123456);MOCK_USERS.put(user,password);}// 不再需要 TOKEN_STORE !publicStringlogin(LoginRequestrequest){StringpwdMOCK_USERS.get(request.getUsername());if(pwd!nullpwd.equals(request.getPassword())){returnJwtUtil.generateToken(request.getUsername());}returnnull;}publicbooleanisValidJwt(Stringtoken){try{JwtUtil.parseToken(token);returntrue;}catch(Exceptione){returnfalse;}}}Controller也相应调整/api/info接口从请求头提取JWT并解析获取用户名。此时我们会遇到一个重要的HTTP细节Bearer前缀。3. 踩坑Bearer前缀与测试那些事我们测试/api/info时要求请求头写Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...如果你只写了Authorization: 你的token服务器会认为格式错误返回401。Bearer是一种认证方案标识告诉服务器“后面跟的是持有者令牌”。解析时我们用substring(7)跳过了“Bearer ”这7个字符。3.1 另一个坑JWT立即过期测试时我们故意把EXPIRATION_MS改成了10秒想验证过期效果结果发现怎么快都提示过期。排查后发现是过早复制了错误单位比如写了1毫秒。后来改成10_000就正常了。过期时间的单位必须是毫秒。4. 过滤器Filter第一道防线现在我们想统一校验所有需要登录的请求而不是在每个Controller里重复写解析代码。首先想到的就是Servlet Filter。4.1 Filter的作用Filter运行在Servlet容器层在请求进入Spring MVC的DispatcherServlet之前执行可以拦截任何资源。4.2 创建LoginCheckFilterSpring Boot 3.x 版本注意Spring Boot 3.x 使用jakarta.servlet.*2.x 是javax.servlet.*下面的代码基于3.x。ComponentpublicclassLoginCheckFilterimplementsFilter{OverridepublicvoiddoFilter(ServletRequestreq,ServletResponseres,FilterChainchain)throwsIOException,ServletException{HttpServletRequestrequest(HttpServletRequest)req;HttpServletResponseresponse(HttpServletResponse)res;Stringurlrequest.getRequestURL().toString();if(url.endsWith(/api/login)){chain.doFilter(req,res);// 登录接口直接放行return;}StringauthHeaderrequest.getHeader(Authorization);if(!StringUtils.hasText(authHeader)||!authHeader.startsWith(Bearer )){response.setContentType(application/json;charsetUTF-8);response.getWriter().write({\code\:401,\msg\:\未登录\});return;}StringtokenauthHeader.substring(7);try{ClaimsclaimsJwtUtil.parseToken(token);request.setAttribute(username,claims.getSubject());chain.doFilter(req,res);// 校验通过放行}catch(Exceptione){response.setContentType(application/json;charsetUTF-8);response.getWriter().write({\code\:401,\msg\:\Token无效\});}}}这样Controller里的校验代码就可以删掉了直接从request.getAttribute(username)取用户信息。4.3 Filter的尴尬异常无法被Spring全局捕获Filter中一旦校验失败我们只能手动拼接JSON并用response.getWriter()写回。这样不但繁琐而且抛出的异常不会被Spring的RestControllerAdvice捕获因为Filter在Spring MVC的外层。这就引出了更优雅的方案拦截器Interceptor。5. Interceptor登场纳入Spring的异常体系Interceptor是Spring MVC提供的拦截器它位于DispatcherServlet之后、Controller之前所以其抛出的异常可以被Spring的全局异常处理器捕获。5.1 Filter vs Interceptor对比项FilterInterceptor所处层次Servlet容器Spring MVC能否被Spring异常处理❌✅适用场景编码过滤、安全过滤登录校验、日志、权限5.2 自定义未授权异常publicclassUnauthorizedExceptionextendsRuntimeException{privateintcode401;publicUnauthorizedException(Stringmsg){super(msg);}// getter}5.3 编写LoginCheckInterceptorComponentpublicclassLoginCheckInterceptorimplementsHandlerInterceptor{OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){Stringurlrequest.getRequestURL().toString();if(url.endsWith(/api/login)){returntrue;// 放行}StringauthHeaderrequest.getHeader(Authorization);if(!StringUtils.hasText(authHeader)||!authHeader.startsWith(Bearer )){thrownewUnauthorizedException(未登录或Token格式错误);}StringtokenauthHeader.substring(7);try{ClaimsclaimsJwtUtil.parseToken(token);request.setAttribute(username,claims.getSubject());}catch(Exceptione){thrownewUnauthorizedException(Token无效或已过期);}returntrue;// 放行}}5.4 配置拦截器白名单ConfigurationpublicclassWebConfigimplementsWebMvcConfigurer{AutowiredprivateLoginCheckInterceptorloginCheckInterceptor;OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){registry.addInterceptor(loginCheckInterceptor).addPathPatterns(/**).excludePathPatterns(/api/login);// 登录接口不拦截}}这样我们将LoginCheckFilter注释掉完全由拦截器接管JWT校验并且校验失败时抛出的UnauthorizedException会被接下来要写的全局异常处理器兜底。6. 统一异常处理RestControllerAdvice有了自定义异常我们就可以集中管理所有错误响应确保前端收到统一的JSON结构。RestControllerAdvicepublicclassGlobalExceptionHandler{ExceptionHandler(UnauthorizedException.class)publicResponseEntityMapString,ObjecthandleUnauthorized(UnauthorizedExceptione){MapString,ObjectresultnewHashMap();result.put(code,e.getCode());result.put(msg,e.getMessage());returnnewResponseEntity(result,HttpStatus.UNAUTHORIZED);}ExceptionHandler(Exception.class)publicResponseEntityMapString,ObjecthandleOther(Exceptione){MapString,ObjectresultnewHashMap();result.put(code,500);result.put(msg,服务器内部错误e.getMessage());returnnewResponseEntity(result,HttpStatus.INTERNAL_SERVER_ERROR);}}现在再访问不带token的/api/info你会看到响应状态码是401而JSON内容也规范了。我们不再需要手动拼接JSON字符串Interceptor只需抛出异常一切交给全局处理器。7. 总结一张清单回顾所有要点主题关键点基础登录接收RequestBody用HashMap模拟用户返回UUID令牌JWT无状态令牌jjwt依赖生成/解析JWTsetSubject(username)存储用户标识Bearer前缀HTTP认证方案标识提取时需substring(7)去除FilterServlet层拦截手动response.getWriter()异常无法被Spring全局捕获InterceptorSpring MVC层拦截可抛出异常交RestControllerAdvice处理统一异常处理RestControllerAdviceExceptionHandler定义统一JSON错误响应包版本适配Spring Boot 3.x 用jakarta.servlet.*2.x 用javax.servlet.*最后的话我们从一段简单的登录接口出发经历了UUID的临时方案演化到JWT无状态认证再通过Filter和Interceptor的对比实践最终用全局异常处理收尾。现在你不但会写登录更理解了背后分层与拦截器的设计思想。建议你把代码自己敲一遍改一改白名单尝试加入密码加密BCrypt这会是你成为后端熟手的重要一步。欢迎在评论区分享你的练习心得
搞懂Spring Boot登录认证:从UUID到JWT,一次完整的架构推演
文章目录从UUID到JWT再到Filter/InterceptorSpring Boot登录认证进阶之路1. 基础登录模拟数据 UUID令牌1.1 项目结构1.2 请求DTO1.3 Service——模拟用户与令牌管理1.4 Controller1.5 测试2. 从UUID到JWT让令牌自带“身份证”2.1 有状态 vs 无状态对比2.2 添加JWT依赖2.3 编写JwtUtil工具类2.4 精简Service3. 踩坑Bearer前缀与测试那些事3.1 另一个坑JWT立即过期4. 过滤器Filter第一道防线4.1 Filter的作用4.2 创建LoginCheckFilterSpring Boot 3.x 版本4.3 Filter的尴尬异常无法被Spring全局捕获5. Interceptor登场纳入Spring的异常体系5.1 Filter vs Interceptor5.2 自定义未授权异常5.3 编写LoginCheckInterceptor5.4 配置拦截器白名单6. 统一异常处理RestControllerAdvice7. 总结一张清单回顾所有要点最后的话从UUID到JWT再到Filter/InterceptorSpring Boot登录认证进阶之路这篇文章要带你从零实现一个Spring Boot登录接口并一步步将它从“临时UUID令牌”演变成无状态的JWT再通过Filter → Interceptor → 统一异常处理最终得到一个规范、可维护的认证架构。我们不依赖前端只使用IDEA内置的HTTP Client做所有测试。所有代码都会给出你可以复制即用。1. 基础登录模拟数据 UUID令牌我们先从最简单的入手接收用户名密码验证后返回一个临时令牌。所有用户数据先用HashMap硬编码在内存里令牌就用UUID随机生成。1.1 项目结构src/main/java/com/example/demo ├── DemoApplication.java // 启动类 ├── config │ └── WebConfig.java // 配置拦截器、跨域等 ├── controller │ └── UserController.java // 登录、用户接口 ├── dto │ └── LoginRequest.java // 登录请求体 ├── exception │ ├── GlobalExceptionHandler.java // 全局异常处理 │ └── UnauthorizedException.java // 自定义未授权异常 ├── filter │ └── LoginCheckFilter.java // 登录校验过滤器可选 ├── interceptor │ └── LoginCheckInterceptor.java // 登录校验拦截器 ├── service │ └── UserService.java // 用户服务验证逻辑 └── util └── JwtUtil.java // JWT 工具类1.2 请求DTO// LoginRequest.javapublicclassLoginRequest{privateStringusername;privateStringpassword;// 必须有无参构造Spring才能把JSON转成对象publicLoginRequest(){}// getter/setter 略}注意如果只有全参构造而没有无参构造Spring反序列化时会直接报400这是一个新手非常容易踩的坑。1.3 Service——模拟用户与令牌管理ServicepublicclassUserService{// 模拟数据库中的用户privatestaticfinalMapString,StringMOCK_USERSnewHashMap();static{MOCK_USERS.put(admin,123456);MOCK_USERS.put(user,password);}// 临时存储已登录的令牌有状态方案privatestaticfinalSetStringTOKEN_STOREConcurrentHashMap.newKeySet();publicStringlogin(LoginRequestrequest){StringpwdMOCK_USERS.get(request.getUsername());if(pwd!nullpwd.equals(request.getPassword())){StringtokenUUID.randomUUID().toString();TOKEN_STORE.add(token);// 记住这个令牌returntoken;}returnnull;}publicbooleanisValidToken(Stringtoken){returntoken!nullTOKEN_STORE.contains(token);}}1.4 ControllerRestControllerpublicclassUserController{AutowiredprivateUserServiceuserService;PostMapping(/api/login)publicResponseEntity?login(RequestBodyLoginRequestrequest){StringtokenuserService.login(request);if(token!null){returnResponseEntity.ok(Map.of(token,token));}else{returnResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of(msg,用户名或密码错误));}}}1.5 测试POST http://localhost:8080/api/login Content-Type: application/json { username: admin, password: 123456 }成功返回200和一个随机的UUID。虽然跑通了但这个方案有两大问题令牌随机不携带任何用户信息服务端必须维护一个TOKEN_STORE才知道谁是谁。有状态一旦重启应用所有登录状态全丢扩展多实例时还需要共享存储。2. 从UUID到JWT让令牌自带“身份证”我们希望令牌自己能“说话”携带用户名和有效期服务端不用再记——这就是无状态的JWTJson Web Token。2.1 有状态 vs 无状态对比方案状态存储位置优点缺点UUID令牌有状态服务器内存/Redis实现简单扩展性差内存占用JWT无状态客户端本地服务端无需存储自带用户信息防篡改无法主动注销(需配合黑名单)payload仅Base64不加密2.2 添加JWT依赖pom.xml中加入dependencygroupIdio.jsonwebtoken/groupIdartifactIdjjwt-api/artifactIdversion0.11.5/version/dependencydependencygroupIdio.jsonwebtoken/groupIdartifactIdjjwt-impl/artifactIdversion0.11.5/versionscoperuntime/scope/dependencydependencygroupIdio.jsonwebtoken/groupIdartifactIdjjwt-jackson/artifactIdversion0.11.5/versionscoperuntime/scope/dependency2.3 编写JwtUtil工具类publicclassJwtUtil{privatestaticfinalKeyKEYKeys.secretKeyFor(SignatureAlgorithm.HS256);// 随机密钥privatestaticfinallongEXPIRATION_MS3600_000;// 1小时publicstaticStringgenerateToken(Stringusername){DatenownewDate();DateexpirationnewDate(now.getTime()EXPIRATION_MS);returnJwts.builder().setSubject(username)// 主题放用户名.setIssuedAt(now).setExpiration(expiration).signWith(KEY).compact();}publicstaticClaimsparseToken(Stringtoken){returnJwts.parserBuilder().setSigningKey(KEY).build().parseClaimsJws(token).getBody();}}2.4 精简ServiceServicepublicclassUserService{privatestaticfinalMapString,StringMOCK_USERSnewHashMap();static{MOCK_USERS.put(admin,123456);MOCK_USERS.put(user,password);}// 不再需要 TOKEN_STORE !publicStringlogin(LoginRequestrequest){StringpwdMOCK_USERS.get(request.getUsername());if(pwd!nullpwd.equals(request.getPassword())){returnJwtUtil.generateToken(request.getUsername());}returnnull;}publicbooleanisValidJwt(Stringtoken){try{JwtUtil.parseToken(token);returntrue;}catch(Exceptione){returnfalse;}}}Controller也相应调整/api/info接口从请求头提取JWT并解析获取用户名。此时我们会遇到一个重要的HTTP细节Bearer前缀。3. 踩坑Bearer前缀与测试那些事我们测试/api/info时要求请求头写Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...如果你只写了Authorization: 你的token服务器会认为格式错误返回401。Bearer是一种认证方案标识告诉服务器“后面跟的是持有者令牌”。解析时我们用substring(7)跳过了“Bearer ”这7个字符。3.1 另一个坑JWT立即过期测试时我们故意把EXPIRATION_MS改成了10秒想验证过期效果结果发现怎么快都提示过期。排查后发现是过早复制了错误单位比如写了1毫秒。后来改成10_000就正常了。过期时间的单位必须是毫秒。4. 过滤器Filter第一道防线现在我们想统一校验所有需要登录的请求而不是在每个Controller里重复写解析代码。首先想到的就是Servlet Filter。4.1 Filter的作用Filter运行在Servlet容器层在请求进入Spring MVC的DispatcherServlet之前执行可以拦截任何资源。4.2 创建LoginCheckFilterSpring Boot 3.x 版本注意Spring Boot 3.x 使用jakarta.servlet.*2.x 是javax.servlet.*下面的代码基于3.x。ComponentpublicclassLoginCheckFilterimplementsFilter{OverridepublicvoiddoFilter(ServletRequestreq,ServletResponseres,FilterChainchain)throwsIOException,ServletException{HttpServletRequestrequest(HttpServletRequest)req;HttpServletResponseresponse(HttpServletResponse)res;Stringurlrequest.getRequestURL().toString();if(url.endsWith(/api/login)){chain.doFilter(req,res);// 登录接口直接放行return;}StringauthHeaderrequest.getHeader(Authorization);if(!StringUtils.hasText(authHeader)||!authHeader.startsWith(Bearer )){response.setContentType(application/json;charsetUTF-8);response.getWriter().write({\code\:401,\msg\:\未登录\});return;}StringtokenauthHeader.substring(7);try{ClaimsclaimsJwtUtil.parseToken(token);request.setAttribute(username,claims.getSubject());chain.doFilter(req,res);// 校验通过放行}catch(Exceptione){response.setContentType(application/json;charsetUTF-8);response.getWriter().write({\code\:401,\msg\:\Token无效\});}}}这样Controller里的校验代码就可以删掉了直接从request.getAttribute(username)取用户信息。4.3 Filter的尴尬异常无法被Spring全局捕获Filter中一旦校验失败我们只能手动拼接JSON并用response.getWriter()写回。这样不但繁琐而且抛出的异常不会被Spring的RestControllerAdvice捕获因为Filter在Spring MVC的外层。这就引出了更优雅的方案拦截器Interceptor。5. Interceptor登场纳入Spring的异常体系Interceptor是Spring MVC提供的拦截器它位于DispatcherServlet之后、Controller之前所以其抛出的异常可以被Spring的全局异常处理器捕获。5.1 Filter vs Interceptor对比项FilterInterceptor所处层次Servlet容器Spring MVC能否被Spring异常处理❌✅适用场景编码过滤、安全过滤登录校验、日志、权限5.2 自定义未授权异常publicclassUnauthorizedExceptionextendsRuntimeException{privateintcode401;publicUnauthorizedException(Stringmsg){super(msg);}// getter}5.3 编写LoginCheckInterceptorComponentpublicclassLoginCheckInterceptorimplementsHandlerInterceptor{OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){Stringurlrequest.getRequestURL().toString();if(url.endsWith(/api/login)){returntrue;// 放行}StringauthHeaderrequest.getHeader(Authorization);if(!StringUtils.hasText(authHeader)||!authHeader.startsWith(Bearer )){thrownewUnauthorizedException(未登录或Token格式错误);}StringtokenauthHeader.substring(7);try{ClaimsclaimsJwtUtil.parseToken(token);request.setAttribute(username,claims.getSubject());}catch(Exceptione){thrownewUnauthorizedException(Token无效或已过期);}returntrue;// 放行}}5.4 配置拦截器白名单ConfigurationpublicclassWebConfigimplementsWebMvcConfigurer{AutowiredprivateLoginCheckInterceptorloginCheckInterceptor;OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){registry.addInterceptor(loginCheckInterceptor).addPathPatterns(/**).excludePathPatterns(/api/login);// 登录接口不拦截}}这样我们将LoginCheckFilter注释掉完全由拦截器接管JWT校验并且校验失败时抛出的UnauthorizedException会被接下来要写的全局异常处理器兜底。6. 统一异常处理RestControllerAdvice有了自定义异常我们就可以集中管理所有错误响应确保前端收到统一的JSON结构。RestControllerAdvicepublicclassGlobalExceptionHandler{ExceptionHandler(UnauthorizedException.class)publicResponseEntityMapString,ObjecthandleUnauthorized(UnauthorizedExceptione){MapString,ObjectresultnewHashMap();result.put(code,e.getCode());result.put(msg,e.getMessage());returnnewResponseEntity(result,HttpStatus.UNAUTHORIZED);}ExceptionHandler(Exception.class)publicResponseEntityMapString,ObjecthandleOther(Exceptione){MapString,ObjectresultnewHashMap();result.put(code,500);result.put(msg,服务器内部错误e.getMessage());returnnewResponseEntity(result,HttpStatus.INTERNAL_SERVER_ERROR);}}现在再访问不带token的/api/info你会看到响应状态码是401而JSON内容也规范了。我们不再需要手动拼接JSON字符串Interceptor只需抛出异常一切交给全局处理器。7. 总结一张清单回顾所有要点主题关键点基础登录接收RequestBody用HashMap模拟用户返回UUID令牌JWT无状态令牌jjwt依赖生成/解析JWTsetSubject(username)存储用户标识Bearer前缀HTTP认证方案标识提取时需substring(7)去除FilterServlet层拦截手动response.getWriter()异常无法被Spring全局捕获InterceptorSpring MVC层拦截可抛出异常交RestControllerAdvice处理统一异常处理RestControllerAdviceExceptionHandler定义统一JSON错误响应包版本适配Spring Boot 3.x 用jakarta.servlet.*2.x 用javax.servlet.*最后的话我们从一段简单的登录接口出发经历了UUID的临时方案演化到JWT无状态认证再通过Filter和Interceptor的对比实践最终用全局异常处理收尾。现在你不但会写登录更理解了背后分层与拦截器的设计思想。建议你把代码自己敲一遍改一改白名单尝试加入密码加密BCrypt这会是你成为后端熟手的重要一步。欢迎在评论区分享你的练习心得