一次 HTTP 请求在若依中的完整旅程
本文基于若依3.9.2、SpringBoot3版本。

一次 HTTP 请求在若依中的完整旅程
本文以 GET /system/user/list(用户管理列表查询)为例,跟踪这条请求从进入 Servlet 容器到返回 JSON 的完整旅程,建立对若依后端的整体认知。
一条请求的全景
前端发出请求:
GET /system/user/list?pageNum=1&pageSize=10
Header: Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...
从进入到响应返回,完整链路如下:
请求进入
│
▼
CorsFilter 跨域处理,添加 CORS 响应头
│
▼
LogoutFilter 仅拦截 /logout,其余请求直接放行
│
▼
JwtAuthenticationTokenFilter 解析 JWT、查 Redis、写 SecurityContext
│
▼
AuthorizationFilter URL 级授权:已认证请求放行,否则 401
│
▼
DispatcherServlet Spring MVC 分发,路由到 Controller
│
▼
RepeatSubmitInterceptor 拦截器层:@RepeatSubmit 方法防重复,未标注直接放行
│
▼
@PreAuthorize("@ss.hasPermi(...)") 方法级权限校验
│
▼
SysUserController.list() 业务 Controller
├─ initBinder() 日期参数转换
├─ startPage() 开启分页(PageHelper 写 ThreadLocal)
├─ userService.selectUserList() Service 业务调用
│ └─ SysUserMapper.selectUserList() MyBatis 执行
│ └─ PageInterceptor 拦截 → 加 LIMIT、执行 count
└─ getDataTable() 封装 TableDataInfo 返回
│
▼
GlobalExceptionHandler 异常兜底(如中途抛 ServiceException)
│
▼
JSON 响应返回前端
下面逐个环节拆解。
起点:跨域过滤器 CorsFilter
请求进入 Servlet 容器后,最先执行的是 CorsFilter(Spring MVC 提供的跨域过滤器),为响应添加 CORS 头,让浏览器允许跨域访问。若依在 ResourcesConfig 里配置它:
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setMaxAge(1800L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
它必须排在认证过滤器之前:预检请求不带 Token,如果认证过滤器先跑,未携带令牌的预检会被拒绝,跨域直接失败。所以 SecurityConfig 里通过 addFilterBefore(corsFilter, LogoutFilter.class) 把它插到过滤链最前。
身份还原:JwtAuthenticationTokenFilter
跨域处理完,请求进入 JwtAuthenticationTokenFilter。这是若依 JWT + Redis 无状态认证模型的核心一环,但它不负责"能不能过",只负责"根据令牌还原登录用户身份"。
源码结构很简单,继承 Spring 的 OncePerRequestFilter 模板方法:
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) {
tokenService.verifyToken(loginUser);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}
五步流程:
- 取令牌:从
Authorization请求头取出令牌,剥离Bearer前缀。 - 解析 JWT:用配置的密钥校验签名(HS512),取出 body 中的
login_user_key——一个 UUID。 - 查 Redis:用
login_tokens:{uuid}作 key 从 Redis 取出完整的LoginUser对象(含权限、角色、过期时间)。 - 续期:剩余有效期不足 20 分钟时调用
refreshToken刷新 Redis 缓存,实现滑动过期(活跃用户不会被登出)。 - 写上下文:构造
UsernamePasswordAuthenticationToken写入SecurityContextHolder,后续同线程内的业务代码就能通过SecurityUtils.getLoginUser()拿到当前用户。
关键点:JWT 里只放了一个 UUID,真正的用户信息都在 Redis 里。这是"双 Token 机制"——JWT 是客户端凭证的传输格式,Redis 是服务端会话存储。这样做的好处是登出时删 Redis key 即可让令牌立即失效,权限变更后可刷新在线用户的权限缓存;代价是每次请求多一次 Redis 读取。
最后那句 chain.doFilter(request, response) 是无条件放行。这里需要注意下:过滤器只负责"还原身份",不负责"拦截请求"——令牌无效时不写认证信息、以匿名身份继续走过滤链,那"未登录"和"登录过期"在哪里被拦?
判定与响应分开:
- 判定在本过滤器:令牌没带、签名非法、Redis 缓存已过期,三种情况
getLoginUser都返回 null,不写认证信息。 - 响应在后续的授权过滤器:请求以匿名身份继续走,到 Spring Security 的
AuthorizationFilter检查受保护接口(.anyRequest().authenticated())时发现上下文无认证信息,触发若依的AuthenticationEntryPointImpl返回 JSON 格式的 401:
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
int code = HttpStatus.UNAUTHORIZED;
String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
}
注意 HTTP 状态码仍是 200,业务码 401 通过 JSON body 的 code 字段传递——前后端分离项目的常见约定。未登录与登录过期最终收敛为同一个结果:本过滤器判定身份失败 → 授权阶段输出 401,区别仅在判定失败的原因。
这种"无条件放行"的设计让匿名可访问的接口(登录、验证码、静态资源)不会被误伤——它们不携带令牌但必须放行,拒绝逻辑由授权阶段结合 URL 规则统一决策。
分发:DispatcherServlet
请求穿过 Spring Security 过滤链后,进入 Spring MVC 的 DispatcherServlet,由它按 URL 路由到 SysUserController.list() 方法。路由到目标方法后、实际调用之前,会先执行若依注册的拦截器链。
若依在 ResourcesConfig 中注册了 RepeatSubmitInterceptor,拦截 /** 所有请求做防重复提交检查:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
}
它只对方法上标了 @RepeatSubmit 注解的接口生效——preHandle 中反射读注解,没标注就直接 return true 放行。本例的 list() 没有该注解,所以拦截器跑一下判断就放过了,不会做实际校验。
权限校验:@PreAuthorize
路由找到目标方法后,调用之前先过方法级权限校验。SysUserController.list() 上有:
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
startPage();
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
}
@PreAuthorize 是 Spring Security 的方法级权限注解(由 SecurityConfig 上的 @EnableMethodSecurity 开启)。表达式 @ss.hasPermi('system:user:list') 通过 SpEL 引用名为 ss 的 Spring Bean,调用它的 hasPermi 方法——这个 Bean 就是若依的 PermissionService:
@Service("ss")
public class PermissionService {
public boolean hasPermi(String permission) {
if (StringUtils.isEmpty(permission)) return false;
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) return false;
PermissionContextHolder.setContext(permission);
return hasPermissions(loginUser.getPermissions(), permission);
}
private boolean hasPermissions(Set<String> permissions, String permission) {
return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
}
}
ss 这个 Bean 名取自 SpringSecurity 首字母。逻辑很直接:从 SecurityContextHolder 取出上一步过滤器写入的 LoginUser,读它的 permissions 集合,判断是否包含目标权限字符串。
若依的权限采用三段式约定:模块:功能:操作,如 system:user:list。超管的权限集合里有一个特殊值 *:*:*(Constants.ALL_PERMISSION),匹配到它直接放行。权限不通过时抛 AccessDeniedException,由全局异常处理器统一返回 403。
进入业务:Controller
权限校验通过,方法正式执行。SysUserController 继承 BaseController,复用了一组通用方法:
@RestController
@RequestMapping("/system/user")
public class SysUserController extends BaseController {
@Autowired
private ISysUserService userService;
...
}
进入 list() 之前,Spring MVC 会调用 BaseController 的 initBinder 方法注册日期属性编辑器,把前端传来的日期字符串(如 2026-01-01)自动转成 Date 对象。这针对的是 GET 请求表单/query 参数绑定路径;@RequestBody 的 JSON 反序列化走 Jackson,不经过这里。
然后业务体执行三步:startPage() → selectUserList() → getDataTable()。
开启分页:startPage()
startPage() 是 BaseController 的方法,委托 PageUtils:
protected void startPage() {
PageUtils.startPage();
}
PageUtils.startPage() 从请求取分页参数(pageNum、pageSize、排序字段),对排序字段做 SQL 注入过滤(只允许字母数字下划线等),然后调 PageHelper.startPage(pageNum, pageSize, orderBy)。
PageHelper 的分页能力基于 MyBatis 拦截器机制:它把分页参数封装成 Page 对象,存入当前线程的 ThreadLocal——所以分页参数不需要在方法间传递,它藏在当前线程上。这一步只是"埋点",还没真正执行 SQL。
业务执行:Service
Controller 调用 userService.selectUserList(user),进入 Service 层。Service 是业务编排层,负责事务管理、数据权限拼装、参数校验等。
若依在 Service 层用了几个关键的 AOP 切面:
- 事务:
@Transactional保证方法内多个写操作要么全成功、要么全回滚。 - 数据权限:
@DataScope注解让查询 SQL 自动拼接部门过滤条件,5 种范围(全部/自定义/本部门/本部门及下级/仅本人)对应不同 SQL 片段。 - 操作日志:
@Log注解通过 AOP 反射读取注解信息,异步写入日志表。
本例的 SysUserServiceImpl.selectUserList 上正标了 @DataScope:
@DataScope(deptAlias = "d", userAlias = "u")
public List<SysUser> selectUserList(SysUser user) {
return userMapper.selectUserList(user);
}
DataScopeAspect 在方法执行前(@Before 前置通知)拦截,根据当前登录用户所配角色的数据权限范围,往 user.params 里塞入拼好的 SQL 片段,最终被 Mapper XML 的 <if> 标签拼进查询条件——普通管理员只能看到本部门用户,超管(isAdmin() 判断,userId=1 硬编码约定)或角色的 data_scope=1(全部)则不过滤数据、能查全部。所以同一段 selectUserList(user),不同登录用户拿到的是不同范围的数据。Service 最终调用 SysUserMapper.selectUserList(user)。
数据访问:Mapper + PageHelper
SysUserMapper 是 MyBatis 接口,对应的 SQL 写在 resources/mapper/system/SysUserMapper.xml 里。接口 + XML 映射是 MyBatis 的标准用法,支持 <if>、<where>、<foreach> 等动态 SQL 标签按条件拼接。
SQL 执行时,前面 startPage() 埋下的分页参数此时派上用场。PageHelper 的 PageInterceptor 拦截这次查询,从 ThreadLocal 取出分页参数,先执行一次 count 拿到总记录数,再给原 SQL 拼上 MySQL 的 LIMIT 执行分页查询,执行完自动清理 ThreadLocal——这就是分页"只对下一次查询生效、用完即弃"的原因。
所以 selectUserList(user) 返回的 List<SysUser> 本质是 PageHelper 的 Page 对象,里面除了数据还藏着 total。
结果封装:getDataTable()
Controller 拿到查询结果后,调 BaseController.getDataTable() 封装:
protected TableDataInfo getDataTable(List<?> list) {
TableDataInfo rspData = new TableDataInfo();
rspData.setCode(HttpStatus.SUCCESS);
rspData.setMsg("查询成功");
rspData.setRows(list);
rspData.setTotal(new PageInfo(list).getTotal());
return rspData;
}
new PageInfo(list).getTotal() 从 PageHelper 的 Page 对象里取出总记录数。返回的 TableDataInfo 结构:
{
"code": 200,
"msg": "查询成功",
"rows": [
{ "userId": 1, "userName": "admin" },
{ "userId": 2, "userName": "ry" }
],
"total": 2
}
前端列表组件按 rows 渲染当前页数据,按 total 渲染分页条。这与单对象返回用的 AjaxResult({code, msg, data})是两套并行结构:分页列表用 TableDataInfo,单对象或增删改结果用 AjaxResult。
异常兜底:GlobalExceptionHandler
如果链路中任何一步抛出异常,GlobalExceptionHandler 兜底。它用 @RestControllerAdvice 标注,按异常类型分类处理:
- 业务异常
ServiceException:返回业务异常自带的 code(无 code 时默认 500)+ 错误消息。 - 权限异常
AccessDeniedException:返回 403 + "没有权限,请联系管理员授权"。 - 参数校验异常
BindException/MethodArgumentNotValidException:返回 500 + 第一个字段错误信息。 - 其他未捕获异常:返回 500 + 异常 message。
注意参数校验异常返回的 code 是 500 而非 400——若依的 AjaxResult.error(message) 默认走 HttpStatus.ERROR(500),校验失败也被归到"操作失败"统一处理,没有用 HttpStatus.BAD_REQUEST(400)。
业务层抛 ServiceException 即可,无需自己 try-catch。比如 SysUserService 里检查用户名重复:
if (StringUtils.isNotEmpty(userService.checkUserNameUnique(sysUser.getUserName()))) {
throw new ServiceException("新增用户'" + sysUser.getUserName() + "'失败,登录账号已存在");
}
业务层只管抛异常,统一封装由框架完成。

浙公网安备 33010602011771号