过滤器中处理异常响应返回
适用模块:
ai-springboot-new(心理健康助手后端·新版)涉及包:
util技术栈:Spring Boot 4.1.1 + Spring Security 6.x + Auth0 java-jwt 4.4.0 + Hutool 5.x
关联文档:《JWT 认证与 Token 创建方法实现》、《创建获取用户接口与 JWT 认证器》、《引入 Spring Security 安全框架与授权规则配置》
1. 背景与目标
在《创建获取用户接口与 JWT 认证器》中,JwtAuthticationFilter 已经挂载进 Spring Security 过滤链,但 doFilterInternal 只会打印日志------既不提取 Token,也不拒绝非法请求,属于"管道通了但没通水"的状态。
本次改动补齐了拒绝分支 的最小闭环:当请求未携带 Token 时,不再放行到后续链路,而是当场返回统一的 401 JSON 响应 。同时抽出可复用的响应工具类,并把 Token 提取逻辑收敛到 JwtTokenUtil。
| 目标 | 实现方式 | 本次状态 |
|---|---|---|
| 过滤器内统一输出错误响应 | 新增 ResponseUtil.writeError(response, resultCode) |
✅ 已落地 |
ResultCode → HTTP 状态码映射 |
switch 表达式按枚举分派 401 / 403 / 400 |
✅ 已落地 |
| Token 提取集中管理 | JwtTokenUtil.extractTokenFromRequest(request) |
⚠️ 骨架已建,解析逻辑未实现 |
| 未携带 Token 时拒绝请求 | else 分支清理上下文 + 写回 401 |
⚠️ 已写响应,但未中断链路 |
说明:本次解决的是"过滤器里怎么把错误返回给前端 "这一问题。之所以必须单独处理,是因为过滤器执行在
DispatcherServlet之前,@RestControllerAdvice的全局异常处理器根本拦截不到 这一层抛出的异常或拒绝行为,只能手工操作HttpServletResponse。
2. 本次改动文件一览
| 文件路径 | 类型 | 说明 |
|---|---|---|
src/main/java/com/example/aispringbootnew/util/ResponseUtil.java |
新增 | 过滤器专用响应工具类,按 ResultCode 写回统一 JSON 错误体(本次核心) |
src/main/java/com/example/aispringbootnew/util/JwtTokenUtil.java |
修改 | 新增 extractTokenFromRequest,统一从请求头提取 Token |
src/main/java/com/example/aispringbootnew/util/JwtAuthticationFilter.java |
修改 | 新增 Token 提取与拒绝分支,接入 ResponseUtil |
3. 核心实现一:ResponseUtil ------ 过滤器专用响应工具
3.1 为什么不能复用全局异常处理器
Spring MVC 的异常处理机制存在一个处理边界:
HTTP 请求
│
▼
Servlet 过滤器链(JwtAuthticationFilter 在这一层)
│ ← 这里抛出的异常 / 主动拒绝,@RestControllerAdvice 完全看不到
▼
DispatcherServlet
│
▼
HandlerMapping → Controller
│ ← 只有从这里开始抛出的异常,才会被 @RestControllerAdvice 捕获
▼
HandlerExceptionResolver(@RestControllerAdvice 的生效位置)
@RestControllerAdvice 本质上是一个 HandlerExceptionResolver,它的注册与触发都发生在 DispatcherServlet 内部。过滤器阶段请求尚未进入 MVC 容器 ,此时既没有 HandlerMethod,也没有 ModelAndView 概念,异常处理链路无从谈起。
因此过滤器要拒绝请求,只有两条路:
| 方式 | 做法 | 适用性 |
|---|---|---|
| 抛异常交给上游 | 抛出异常让 Servlet 容器返回错误页 | ❌ 返回格式与 Result 不统一,前端无法解析 |
| 手写响应 | 直接操作 HttpServletResponse 写入 JSON |
✅ 本次采用 |
3.2 完整实现
14:36:src/main/java/com/example/aispringbootnew/util/ResponseUtil.java
public class ResponseUtil {
public static void writeError(HttpServletResponse response, ResultCode resultCode) {
// 根据不同结果码返回不同的响应
int status = switch (resultCode) {
case UNAUTHORIZED, ACCESS_UNAUTHORIZED, TOKEN_INVALID, TOKEN_BLOCKED, TOKEN_EXPIRED ->
HttpStatus.UNAUTHORIZED.value();
case TOKEN_ACCESS_FORBIDDEN -> HttpStatus.FORBIDDEN.value();
default -> HttpStatus.BAD_REQUEST.value();
};
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
try (PrintWriter writer = response.getWriter()) {
String jsonResponse = JSONUtil.toJsonStr(Result.error(resultCode.getCode(), resultCode.getMsg(), null));
writer.write(jsonResponse);
writer.flush(); // 确保将相应内容写入到客户端浏览器
} catch (IOException e) {
System.out.println("写入响应失败:" + e.getMessage());
}
}
}
3.3 分步骤拆解
① 结果码 → HTTP 状态码映射
17:22:src/main/java/com/example/aispringbootnew/util/ResponseUtil.java
int status = switch (resultCode) {
case UNAUTHORIZED, ACCESS_UNAUTHORIZED, TOKEN_INVALID, TOKEN_BLOCKED, TOKEN_EXPIRED ->
HttpStatus.UNAUTHORIZED.value();
case TOKEN_ACCESS_FORBIDDEN -> HttpStatus.FORBIDDEN.value();
default -> HttpStatus.BAD_REQUEST.value();
};
这里关键是理解两层状态码的差异,它们服务于不同消费者:
| 层级 | 载体 | 取值示例 | 消费者 |
|---|---|---|---|
| HTTP 状态码 | response.setStatus(...) |
401 / 403 / 400 |
浏览器、网关、前端拦截器、监控 |
| 业务状态码 | Result.code |
A0301 / A0230 / A0231 |
前端业务逻辑 |
映射分组的设计意图:
| 分组 | 对应 ResultCode |
HTTP | 语义 |
|---|---|---|---|
| 未认证 | UNAUTHORIZED、ACCESS_UNAUTHORIZED、TOKEN_INVALID、TOKEN_BLOCKED、TOKEN_EXPIRED |
401 |
"你没有有效身份,去登录/换 Token" |
| 无权限 | TOKEN_ACCESS_FORBIDDEN |
403 |
"身份有效,但被禁止访问" |
| 兜底 | 其余所有枚举 | 400 |
避免未知结果码造出非法 HTTP 状态 |
注意
TOKEN_INVALID/TOKEN_EXPIRED/TOKEN_BLOCKED在ResultCode中的code都是"A0230",属于"同一类但细分原因"的设计:HTTP 层统一 401,前端靠msg区分具体原因。另需注意
switch表达式(Java 14+ 箭头语法)要求枚举常量名完整列出 ,且default不可省略------一旦ResultCode新增枚举项,会静默落入400分支,不会编译报错。
② 设置响应头
24:26:src/main/java/com/example/aispringbootnew/util/ResponseUtil.java
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
三步缺一不可:
setStatus:必须显式设置 。若只写响应体不设状态码,HTTP 状态仍是默认的200,前端axios拦截器不会走error分支,等于"业务失败了但看起来像成功"。setContentType:声明application/json,否则浏览器/前端可能按text/html或text/plain解析。setCharacterEncoding:UTF-8 必须显式指定 。默认编码为 ISO-8859-1,而msg全为中文(如"访问未授权"),不设置必然乱码。
③ 序列化并写回
28:34:src/main/java/com/example/aispringbootnew/util/ResponseUtil.java
try (PrintWriter writer = response.getWriter()) {
String jsonResponse = JSONUtil.toJsonStr(Result.error(resultCode.getCode(), resultCode.getMsg(), null));
writer.write(jsonResponse);
writer.flush(); // 确保将相应内容写入到客户端浏览器
} catch (IOException e) {
System.out.println("写入响应失败:" + e.getMessage());
}
- 复用
Result.error(code, msg, data),保证过滤器返回的错误体与 Controller 返回的成功体结构完全一致,前端只需一套解析逻辑; JSONUtil来自 Hutool,与Result序列化在 MVC 中使用的 Jackson 结果等价,避免手动拼接字符串;try-with-resources自动关闭PrintWriter------ 关闭时会隐式flush,因此这里手动flush是"双保险";- 捕获
IOException后仅打印日志,没有继续抛出:响应阶段已无更上层的兜底手段,抛出只会污染异常栈。
3.4 返回体示例
以 ACCESS_UNAUTHORIZED(code = "A0301",msg = "访问未授权")为例:
http
HTTP/1.1 401 Unauthorized
Content-Type: application/json;charset=UTF-8
json
{
"code": "A0301",
"msg": "访问未授权",
"data": null
}
对比白名单接口的正常响应:
json
{ "code": "200", "msg": "操作成功", "data": null }
两者仅 code / msg 不同,data 字段始终存在------前端可统一按 res.data.code === '200' 判定成败。
4. 核心实现二:JwtTokenUtil.extractTokenFromRequest
4.1 实现代码
54:66:src/main/java/com/example/aispringbootnew/util/JwtTokenUtil.java
// 提取token
public static String extractTokenFromRequest(HttpServletRequest request) {
if (request == null) {
return null;
}
String tokenHeader = request.getHeader("token");
if (tokenHeader == null) {
}
return null;
}
4.2 设计意图
| 设计点 | 说明 |
|---|---|
方法定位为 static |
与同类的 generateToken 保持一致,使用方(过滤器)无需注入即可调用 |
| 入参判空 | request == null 直接返回 null,把判空责任收敛在工具类内,调用方只管"有没有拿到 Token" |
| 返回值约定 | 返回 null 统一表示"无可用的 Token",由调用方决定如何响应 |
4.3 与旧版实现的对照
旧项目 ai-spingboot 中该方法的完整实现为:
java
String tokenHeader = request.getHeader("token");
if (StringUtils.hasText(tokenHeader)) {
return tokenHeader;
}
return null;
对比可知本次移植只搬了骨架、漏了两处:
| 差异 | 新版现状 | 旧版做法 | 影响 |
|---|---|---|---|
if 分支为空 |
if (tokenHeader == null) { } |
if (StringUtils.hasText(tokenHeader)) { return tokenHeader; } |
命中条件后不做任何事,直接落到 return null |
恒返回 null |
无论有无 Token 都返回 null |
有值即返回 | 过滤器永远走"未携带 Token"分支 |
详见第 7 节问题 ②。
5. 核心实现三:JwtAuthticationFilter 拒绝分支
5.1 完整实现
23:49:src/main/java/com/example/aispringbootnew/util/JwtAuthticationFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 获取请求的URL和方法
String requestURI = request.getRequestURI();
String requestMethod = request.getMethod();
System.out.printf("requestURI: %s\n", requestURI);
System.out.printf("requestMethod: %s\n", requestMethod);
// 提取 JWT token
String token = JwtTokenUtil.extractTokenFromRequest(request);
if (StringUtils.hasText(token)) {
} else {
// 清理上下文
clearSecurityContext();
ResponseUtil.writeError(response, ResultCode.ACCESS_UNAUTHORIZED);
}
// 继续过滤器链
filterChain.doFilter(request, response);
}
// 清理上下文
private void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
5.2 三分支结构对照
改动后 doFilterInternal 的骨架与旧版项目结构一致,即"有 Token 走验签、无 Token 立即拒绝":
doFilterInternal
│
├─ token = extractTokenFromRequest(request)
│
├─ token 非空(StringUtils.hasText)
│ └─ ① 验签 ② 回查用户 ③ 写入 SecurityContext ← 本次仍留空,待后续实现
│
└─ token 为空
├─ clearSecurityContext() ← 清理脏上下文
├─ ResponseUtil.writeError(401) ← 写回统一错误体
└─ ❌ 缺少 return ← 见第 7 节问题 ①
filterChain.doFilter(request, response) ← 无条件放行
5.3 为什么要先 clearSecurityContext()
SecurityContextHolder 默认使用 ThreadLocal 存储认证信息(SecurityContextHolder.MODE_THREADLOCAL)。Tomcat 使用线程池 复用工作线程,若某个请求异常退出而上下文未清理,残留的 Authentication 可能被下一个复用该线程的请求读到,造成身份串号。
本项目 SecurityConfig 中已配置 SessionCreationPolicy.STATELESS,Spring Security 的 SecurityContextPersistenceFilter 会在请求结束时常规清理;但在过滤器主动拒绝 的分支里显式调用 SecurityContextHolder.clearContext(),是"明确表达此处不应存在任何身份"的低成本防御,也便于后续在同一分支追加更多拒绝条件时复用。
5.4 一次无 Token 请求的时序(当前实际行为)
客户端 GET /api/user/current(不带 token 头)
│
▼
shouldNotFilter → /api/user/current 不在 PUBLIC_PATHS → false(进入过滤器)
│
▼
doFilterInternal
├─ extractTokenFromRequest → null
├─ StringUtils.hasText(null) → false → else 分支
│ ├─ SecurityContextHolder.clearContext()
│ └─ ResponseUtil.writeError(response, ACCESS_UNAUTHORIZED)
│ → response.setStatus(401)
│ → response.getWriter().write("{...}") 且已 flush
│
└─ ⚠️ 未 return → 继续执行 filterChain.doFilter(request, response)
│
▼
AuthorizationFilter 执行 anyRequest().authenticated()
└─ SecurityContext 无身份 → 异常 → 尝试写入 401/403 响应
└─ 响应已提交(committed),写入抛 IllegalStateException
这正是第 7 节问题 ① 的成因:写回了错误响应,却没有中断链条。
6. 关键原理:过滤器里失败后必须 return
过滤器与 Controller 的失败处理有本质区别:
| 维度 | Controller / Service | Filter |
|---|---|---|
| 失败表达 | throw new BusinessException(...) |
手写 response |
| 中断方式 | 异常向上抛,MVC 停止后续处理 | 必须显式 return,否则继续走链 |
| 统一处理 | @RestControllerAdvice 自动兜底 |
无兜底,全靠自己 |
filterChain.doFilter(request, response) 的语义是"把请求交给链上的下一个过滤器/最终 Servlet "。它是一次普通方法调用,不是 return 语句的替代品。因此在拒绝分支里:
java
} else {
clearSecurityContext();
ResponseUtil.writeError(response, ResultCode.ACCESS_UNAUTHORIZED);
return; // ← 关键:中断当前过滤器,不再向下传递
}
缺少 return 时,ResponseUtil 已经把 PrintWriter 写出并 flush,HTTP 响应进入 committed 状态。此后链上的 AuthorizationFilter 因身份缺失再想写响应,会因响应已提交而抛 IllegalStateException,日志中出现 getWriter() has already been called 或 Cannot call sendError() after the response has been committed 之类的报错;在部分容器下还可能表现为响应体被截断或状态码不稳定。
旧项目
ai-spingboot的同一分支写的是:
javaclearSecurityContext(); ResponseUtil.writeError(response, ResultCode.ACCESS_UNAUTHORIZED); return; // ← 有 return本次移植时漏掉了
return,需补回。
7. 现存问题与风险
按严重程度排列,前两项直接影响功能可用性。
① 拒绝分支缺少 return,错误响应会被后续链路覆盖(致命)
36:42:src/main/java/com/example/aispringbootnew/util/JwtAuthticationFilter.java
} else {
// 清理上下文
clearSecurityContext();
ResponseUtil.writeError(response, ResultCode.ACCESS_UNAUTHORIZED);
}
// 继续过滤器链
filterChain.doFilter(request, response);
else 分支写回 401 后没有 return,filterChain.doFilter(...) 仍会执行。后果:
- 请求继续进入受保护接口的处理链路,拒绝形同虚设;
- 响应已于
ResponseUtil中 flush、进入 committed 状态,后续AuthorizationFilter再写响应会抛IllegalStateException,表现为 500 或响应体异常。
修复:
java
} else {
clearSecurityContext();
ResponseUtil.writeError(response, ResultCode.ACCESS_UNAUTHORIZED);
return; // 中断过滤器链,避免响应被覆盖
}
② extractTokenFromRequest 恒返回 null,所有受保护请求必被拒(致命)
54:66:src/main/java/com/example/aispringbootnew/util/JwtTokenUtil.java
public static String extractTokenFromRequest(HttpServletRequest request) {
if (request == null) {
return null;
}
String tokenHeader = request.getHeader("token");
if (tokenHeader == null) {
}
return null;
}
两个缺陷叠加:
if (tokenHeader == null) { }是空分支,写了一行注释都没有,实际什么都没做;- 方法末尾无条件
return null,即使读到了token头也不会返回。
结果是 StringUtils.hasText(token) 恒为 false,过滤器永远走拒绝分支。修复(对齐旧版实现):
java
String tokenHeader = request.getHeader("token");
if (StringUtils.hasText(tokenHeader)) {
return tokenHeader;
}
return null;
注意需在
JwtTokenUtil中补上import org.springframework.util.StringUtils;。
③ 请求头名硬编码,未消费 JwtConfig(中)
60:60:src/main/java/com/example/aispringbootnew/util/JwtTokenUtil.java
String tokenHeader = request.getHeader("token");
application.yml 中已配置 jwt.header: Authorization 与 jwt.token-prefix: "Bearer ",JwtConfig 也已声明 header / tokenPrefix 字段,但此处直接硬编码了 "token":
| 问题 | 后果 |
|---|---|
| 头名硬编码 | 前端按 Authorization: Bearer xxx 发送时取不到,前后端约定失效 |
| 未剥离前缀 | 直接返回 Bearer xxx 会导致后续 JWT 解析 Invalid character |
建议改为读取配置(同类已有 getJwtConfig() 私有方法可复用):
java
JwtConfig jwtConfig = getJwtConfig();
String header = request.getHeader(jwtConfig.getHeader()); // Authorization
if (StringUtils.hasText(header) && header.startsWith(jwtConfig.getTokenPrefix())) {
return header.substring(jwtConfig.getTokenPrefix().length());
}
return null;
注意
JwtConfig本身还缺@ConfigurationProperties(prefix = "jwt"),配置项当前未绑定,需一并修复(详见《JWT 认证与 Token 创建方法实现》第 9 节问题 ①)。
④ 有 Token 的分支为空,验签链路未闭环(中)
33:34:src/main/java/com/example/aispringbootnew/util/JwtAuthticationFilter.java
if (StringUtils.hasText(token)) {
}
即便修好问题 ②,拿到 Token 也只是"什么都不做",SecurityContext 依然空,anyRequest().authenticated() 仍会拒绝。需补齐旧版的完整流程:
java
JwtTokenUtil.TokenVerificationResult validationResult = JwtTokenUtil.validateToken(token);
if (validationResult != null && validationResult.isValid()) {
UserLoginResponseDTO.UserDetailResponseDTO user = userService.getUserById(validationResult.getUserId());
if (user != null && UserStatus.NORMAL.getCode().equals(user.getStatus())) {
List<SimpleGrantedAuthority> authorities = Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + validationResult.getRoleType()));
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(validationResult.getUsername(), null, authorities));
} else {
clearSecurityContext();
ResponseUtil.writeError(response, ResultCode.TOKEN_ACCESS_FORBIDDEN);
return;
}
} else {
clearSecurityContext();
ResponseUtil.writeError(response, ResultCode.TOKEN_INVALID);
return;
}
配套还需在 JwtTokenUtil 中实现 validateToken / verifyToken,并在 JwtAuthticationFilter 中注入 UserService(@Resource)。
另外注意:
JwtAuthticationFilter是由SecurityConfig中new JwtAuthticationFilter()手工创建的 Bean,可以 享受 Spring 注入;但若在SecurityFilterChain中直接new而非调用jwtAuthticationFilter(),@Resource将不会生效。
⑤ 调试输出未替换为日志(低)
ResponseUtil 中的 System.out.println("写入响应失败:" + e.getMessage()) 与过滤器中的 System.out.printf 均应替换为 SLF4J 日志,并建议在写响应失败时至少记录异常对象 而非仅 getMessage():
java
private static final Logger log = LoggerFactory.getLogger(ResponseUtil.class);
// ...
} catch (IOException e) {
log.error("写入过滤器错误响应失败", e);
}
⑥ 缺少 Content-Length / 响应体重复写入防护(低)
writeError 未校验 response.isCommitted()。若在已提交的响应上再次调用(例如某个上层过滤器已经写过响应),getWriter() 会直接抛 IllegalStateException。可在方法开头加一道防御:
java
if (response.isCommitted()) {
return;
}
8. 后续演进建议
- 修复第 7 节问题 ① :在拒绝分支补
return,这是让"拒绝"真正生效的最小改动,优先级最高。 - 修复问题 ② :补全
extractTokenFromRequest的if分支与返回值,使其真正返回请求头中的 Token。 - 修复问题 ③ :改为读取
jwt.header/jwt.tokenPrefix,并同步修复JwtConfig缺少prefix的问题,让配置真正生效。 - 完成问题 ④ 的验签链路 :
JwtTokenUtil补validateToken/verifyToken,过滤器if (hasText(token))分支完成"验签 → 回查用户状态 → 写入SecurityContext",失败分支同样ResponseUtil.writeError(...) + return。 - 统一错误响应出口 :将
ResponseUtil作为过滤器中唯一的响应出口 ,所有拒绝场景(未携带、验签失败、用户禁用、无权限)都走它,避免格式分叉;也可考虑同时设置Result与 HTTP 状态码的映射表,便于后续扩展。 - 日志与监控 :把
System.out全面替换为日志框架;对 401/403 拒绝次数打点,便于发现token 过期集中或撞库尝试。 ResponseUtil泛化 :当前仅支持错误响应,可扩展writeSuccess(response, data),使其成为独立于 MVC 的通用响应工具,供其他非 MVC 场景(如定时任务回调、SSE)复用。
9. 涉及文件清单
| 文件路径 | 类型 | 说明 |
|---|---|---|
src/main/java/com/example/aispringbootnew/util/ResponseUtil.java |
新增 | 过滤器专用响应工具,ResultCode → HTTP 状态码映射 + 统一 JSON 写回(本次核心) |
src/main/java/com/example/aispringbootnew/util/JwtTokenUtil.java |
修改 | 新增 extractTokenFromRequest,当前为待补齐骨架 |
src/main/java/com/example/aispringbootnew/util/JwtAuthticationFilter.java |
修改 | 新增 Token 提取与拒绝分支,接入 ResponseUtil(缺 return) |
src/main/java/com/example/aispringbootnew/common/Result.java |
已有 | 统一返回结构,ResponseUtil 复用其 error(code, msg, data) |
src/main/java/com/example/aispringbootnew/common/ResultCode.java |
已有 | 状态码枚举,ResponseUtil 的映射输入 |
src/main/java/com/example/aispringbootnew/config/JwtConfig.java |
已有 | 提供 header / tokenPrefix(尚缺 prefix,且过滤器未消费) |
src/main/java/com/example/aispringbootnew/config/SecurityConfig.java |
已有 | 注册并挂载 JwtAuthticationFilter,配置白名单与兜底授权 |