今天重新整理了一下前后端的交互逻辑,来看一下。
Spring Security + 全局异常状态管理器
1. 业务码
我的业务码只有如下这些。
200 OK:业务成功。400 Bad Request:前端参数错误(如邮箱格式不对、缺少必填项)。405错误也会包装成400错误返回401 Unauthorized:未登录、Token过期或无效。一旦出现这个,前端就会清理登录状态。403 Forbidden:已登录,但没有权限(如普通用户尝试访问管理员接口)。404 Not Found:接口路径不存在。500 Internal Server Error:后端代码抛出未捕获异常(崩溃、数据库连接断开)。
由于配置了Spring Security,通过JWTFilter向认证上下文中写入用户信息,如果不写入,只能访问完全开放的端点,如果是保护端点,进不到控制器中,会先报401错误。
如果访问完全开放的端点,除了403错误,剩下的错误都会从Spring Web中产生,交给全局拦截器。
只有一个特殊的情况就是403响应,因为@PreAuthorize是由Spring Security提供的,如果权限错误,其抛出的错误是org.springframework.security.authorization.AuthorizationDeniedException,这个是一个Runtime Exception,父类是AccessDeniedException。
针对403响应,有两个办法:
一是全局处理器里直接拦截,全局处理器只能拦截Spring Web中的东西,而恰好AuthorizationDeniedException 是在Spring Web中产生的,所以也能拦截。
二是拦截这个具体错误之后,可以原样将其抛出,这样这个错误还会被自定义的Spring Security的AccessDeniedHandler捕获并处理,在这个错误处理器中也可以返回403错误。
2. 异常处理器
根据上边的分析,分别在Spring Security和Spring Web中配置异常处理器。
Spring Security 异常处理器
先看401错误的处理器,这个只能配置在Spring Security里,因为发生这个错误的时候,请求还没有进到Spring Web里。
package cc.conyli.vegalion.common.config.security;
import cc.conyli.vegalion.common.result.Result;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
public MyAuthenticationEntryPoint(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void commence(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull AuthenticationException authException) throws ServletException {
try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
log.info("Security自定义错误处理器:401错误 JWT未通过认证,变成匿名访问");
String json = objectMapper.writeValueAsString(Result.result("登录状态已失效,请重新登录"));
response.getWriter().write(json);
} catch (Exception e) {
throw new ServletException(e.getMessage());
}
}
}只要没有通过JWT认证,又尝试访问被保护的端点就会触发这个异常处理,将响应设置为401,然后返回固定的一段信息。
前端发现401响应会清除登录状态,并显示后端设置的消息提示。
然后是403错误的处理器:
package cc.conyli.vegalion.common.config.security;
import cc.conyli.vegalion.common.result.Result;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
@Slf4j
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
public MyAccessDeniedHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void handle(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull AccessDeniedException accessDeniedException) throws IOException {
log.info("Security自定义错误处理器:403错误 for {} {}", request.getRequestURI(), accessDeniedException.getMessage());
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
String json = objectMapper.writeValueAsString(Result.result("无访问权限,请联系管理员"));
response.getWriter().write(json);
}
}这个处理器仅仅是写了,但没有实际发挥作用,因为我采用了上述的方法一,在Spring Web的全局异常处理器里拦截了对应异常。
如果原样抛出的话,这个异常处理器就会启动。
Spring Web 全局异常状态处理器
这个拦截器就拦截全部Spring Web中发生的错误,并一一返回对应的响应:
package cc.conyli.vegalion.common.advice;
import cc.conyli.vegalion.common.exception.CustomException;
import cc.conyli.vegalion.common.result.Result;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/***
* 全局异常处理器
* 对于AuthorizationDeniedException错误交给Spring Security的异常处理器进行处理,最后返回401错误
* 对于业务异常CustomException返回400错误
* 对于404和不支持的方法都返回404错误
* 对于其他运行时错误,返回500错误
* 返回的状态码一共有
* 400 业务逻辑有误,数据库查询错误等等,都使用该错误,之前的BindingResult出现问题的话,也返回该异常。
* 401 登录失败,仅仅用于登录的时候,401才清除登录状态,其他的都只展示信息
* 403 无访问权限
* 404 找不到页面(这个也不太可能发生)
* 500 服务器错误
* 前端 AXIOS 全部拦截即可,然后返回响应,由具体业务逻辑控制
* 除了Spring Security验证不通过的401错误之外,其他地方一律不返回401错误,仅仅让Axios拦截401错误
*
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 自定义异常
@ExceptionHandler(CustomException.class)
public ResponseEntity<Result<?>> handle400(CustomException e, HttpServletRequest request) {
log.warn("请求地址 [{} {}], 业务异常: {}", request.getMethod(), request.getRequestURI(), e.getMessage());
log.warn(e.getClass().toString());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Result.result(e.getMessage()));
}
// AuthorizationDeniedException 继承了 AccessDeniedException
// AccessDeniedException在自定义Spring security 错误处理器中处理
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Result<?>> handle403and500(RuntimeException e, HttpServletRequest request) {
if (e instanceof AccessDeniedException) {
log.warn("请求地址 [{} {}], 发生403权限错误,全局异常处理器截获: ", request.getRequestURI(), e.getMessage());
log.warn(e.getClass().toString());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Result.result("无访问权限,如需访问请联系管理员"));
}
log.error("请求地址 [{} {}], 发生错误:", request.getRequestURI(), e.getMessage());
log.warn(e.getClass().toString());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.result("服务器发生错误,请联系管理员"));
}
// 404错误
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<Result<?>> handle404(NoResourceFoundException e, HttpServletRequest request) {
log.info("请求地址 [{} {}], 发生404错误: ", request.getRequestURI(), e.getMessage());
log.warn(e.getClass().toString());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Result.result(request.getRequestURI() + " 该资源不可用"));
}
// 对于405错误,也返回400响应
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<Result<?>> handle405(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
log.info("请求地址 [{} {}], 发生405错误: ", request.getRequestURI(), e.getMessage());
log.warn(e.getClass().toString());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Result.result(request.getRequestURI() + " 不支持所用的HTTP请求方法"));
}
}
可以看到,为了应对业务上出现的异常,专门设置了一个CustomException类,直接在请求处理过程中抛出,就可以触发这个处理器返回400响应,对于最后的405错误,也返回400响应。
404错误其实不太会有,因为前端响应都是自己控制,而后端除了本地回环地址不会对外提供服务,但也给处理上了。
关键是其中的处理RuntimeException的处理器,这其中专门针对AccessDeniedException返回403响应,剩下的普通运行时异常返回500响应。
3. 后端返回的所有响应一览
经过这两层异常处理器的包装之后,正常情况下后端返回的所有响应就符合了最开始的设想:
200-正常返回400-自行在代码中抛出CustomException后由Spring Web全局处理器处理。405错误也被包装成400错误。401-由Spring Security异常处理器处理并返回401错误403-属于运行时异常的一个分支,由Spring Web全局处理器处理404-由Spring Web全局处理器处理500-不属于AccessDeniedException的其他运行时错误,由Spring Web全局处理器处理
前端错误处理
前端的思路是这样的:
200-直接打开包装,返回response.data,对应后端的统一包装类Result其他后端返回的意图之内的
400-500错误,由于全部都带有预设的响应体,因此进行判断之后,直接展示消息。并返回错误给使用axios的useQuery,useMutation等钩子,让其在其中捕获错误。Axios还会发生属于AxiosError类型,但没有带有响应体的错误,比如超时和网络错误,但会有特殊的code属性,根据code属性展示信息,也返回错误不属于
AxiosError类型的错误,那就是JS运行时候发生的错误,这种也按照错误返回。
根据这个逻辑编写的axios实例如下:
import axios, { AxiosError } from 'axios';
import useAuthStore from '@/store/authStore';
import { enqueueSnackbar } from 'notistack';
// 通用返回响应的外层类型,对应后端的Result类
// 具体类型T对应后端的Result类中的T泛型
export interface GeneralResponse<T = any> {
message: string;
content: T;
}
// 基础URL
const BASE_URL = import.meta.env.VITE_MAIN_URL;
// 创建通用的axios实例
const axiosGeneralInstance = axios.create({
baseURL: BASE_URL,
timeout: 10000
});
// 请求拦截器:使用Zustand管理的token组装报头
axiosGeneralInstance.interceptors.request.use(
(config) => {
console.log('请求拦截器中统一使用token组装报头');
const token = useAuthStore.getState().token;
console.log('拦截器中使用的token', token);
if (token) {
config.headers.Authorization = token;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器
// 纯 200 响应直接返回响应体数据
// 其他响应 - 如果是AxiosError,除了网络或其他错误之外,剩下都应该有响应体,有响应体的就是正常的后端错误响应,弹窗然后返回错误
// 其他响应 - 如果是AxiosError,无响应体,是网络或超时错误,检查一下code,根据更详细的内容进行弹窗然后返回错误
// 其他响应 - 非AxiosError,直接返回错误
// 所有useQuery和useMutation中加上错误检测
axiosGeneralInstance.interceptors.response.use(
(response) => {
console.log('响应拦截器成功拦截200响应,直接返回数据');
console.log(response.data)
return response.data;
},
(error: AxiosError<GeneralResponse> | unknown) => {
console.log('响应拦截器成功拦截非200响应,错误是:');
console.log(error);
// 先判断是否是axios错误
if (axios.isAxiosError(error)) {
// 然后判断是否存在响应
// 存在响应的情况下,说明是后端发来的正常错误响应,如果是401就清除登录状态,所有错误都弹出错误信息之后返回错误
if (error.response) {
console.log("是Axios错误,有响应体:")
console.log(error.response.data)
if (error.response.status === 401) {
useAuthStore.getState().clearAuth();
}
enqueueSnackbar(error.response.data.message, { variant: 'error' });
return Promise.reject(error);
} else {
// 不存在响应的情况下,很可能是Axios的网络错误或者其他错误
if (error.code === 'ECONNABORTED') {
enqueueSnackbar('请求超时', { variant: 'error' });
} else if (error.code === 'ERR_NETWORK') {
enqueueSnackbar('网络错误,请检查网络连接', { variant: 'error' });
} else {
console.log('Axios 错误不存在响应体,可能是网络错误或者其他错误,其code是:');
console.log(error.code);
enqueueSnackbar('AxiosError:无响应体', { variant: 'error' });
}
return Promise.reject(error);
}
} else {
// 不是Axios错误,那么是其他错误,返回一个自定义的错误
console.log('不是AXIOS错误,返回一个自定义错误对象');
enqueueSnackbar('非Axios错误:无响应体', { variant: 'error' });
return Promise.reject(error);
}
}
);
export default axiosGeneralInstance; // 定义好的泛型响应体
当然,这里的未确认AxiosError和其他JS错误,我故意留了两个特殊的消息用于在开发过程中调试。生产环境里肯定要改成更正式的名称。
这样修改之后,所有的useQuery或者useMutation函数中就简单很多,后端发过来的错误消息直接就在前端弹窗展示。其实我这里还是遵守了HTTP的规则,使用了应该使用的所有HTTP状态码。
还顺便修改全部的访问函数,把具体数据类型都单独抽到一个文件里,axios函数类型统一用GeneralResponse包裹具体数据类型。全部修改完之后,React渲染的代码量就大幅减少了。