一次 HTTP 请求在若依中的完整旅程

[复制链接]
发表于 2026-8-20 07:16:31 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
本文基于若依3.9.2、SpringBoot3版本。

一次 HTTP 请求在若依中的完整旅程

本文以 GET /system/user/list(用户管理列表查询)为例,跟踪这条请求从进入 Servlet 容器到返回 JSON 的完整旅程,建立对若依后端的整体认知。
一条请求的全景

前端发出请求:
  1. GET /system/user/list?pageNum=1&pageSize=10
  2. Header: Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...
复制代码
从进入到响应返回,完整链路如下:
  1. 请求进入
  2.   │
  3.   ▼
  4. CorsFilter                          跨域处理,添加 CORS 响应头
  5.   │
  6.   ▼
  7. LogoutFilter                        仅拦截 /logout,其余请求直接放行
  8.   │
  9.   ▼
  10. JwtAuthenticationTokenFilter       解析 JWT、查 Redis、写 SecurityContext
  11.   │
  12.   ▼
  13. AuthorizationFilter                 URL 级授权:已认证请求放行,否则 401
  14.   │
  15.   ▼
  16. DispatcherServlet                   Spring MVC 分发,路由到 Controller
  17.   │
  18.   ▼
  19. RepeatSubmitInterceptor             拦截器层:@RepeatSubmit 方法防重复,未标注直接放行
  20.   │
  21.   ▼
  22. @PreAuthorize("@ss.hasPermi(...)")  方法级权限校验
  23.   │
  24.   ▼
  25. SysUserController.list()            业务 Controller
  26.   ├─ initBinder()                   日期参数转换
  27.   ├─ startPage()                    开启分页(PageHelper 写 ThreadLocal)
  28.   ├─ userService.selectUserList()   Service 业务调用
  29.   │    └─ SysUserMapper.selectUserList()  MyBatis 执行
  30.   │         └─ PageInterceptor 拦截 → 加 LIMIT、执行 count
  31.   └─ getDataTable()                 封装 TableDataInfo 返回
  32.   │
  33.   ▼
  34. GlobalExceptionHandler             异常兜底(如中途抛 ServiceException)
  35.   │
  36.   ▼
  37. JSON 响应返回前端
复制代码
下面逐个环节拆解。
起点:跨域过滤器 CorsFilter

请求进入 Servlet 容器后,开始执行的是 CorsFilter(Spring MVC 提供的跨域过滤器),为响应添加 CORS 头,让浏览器允许跨域访问。若依在 ResourcesConfig 里设置它:
  1. @Bean
  2. public CorsFilter corsFilter() {
  3.     CorsConfiguration config = new CorsConfiguration();
  4.     config.addAllowedOriginPattern("*");
  5.     config.addAllowedHeader("*");
  6.     config.addAllowedMethod("*");
  7.     config.setMaxAge(1800L);
  8.     UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
  9.     source.registerCorsConfiguration("/**", config);
  10.     return new CorsFilter(source);
  11. }
复制代码
它必须排在认证过滤器之前:预检请求不带 Token,如果认证过滤器先跑,未携带令牌的预检会被拒绝,跨域直接失败。以是 SecurityConfig 里通过 addFilterBefore(corsFilter, LogoutFilter.class) 把它插到过滤链最前。
身份还原:JwtAuthenticationTokenFilter

跨域处理完,请求进入 JwtAuthenticationTokenFilter。这是若依 JWT + Redis 无状态认证模子的核心一环,但它不负责"能不能过",只负责"根据令牌还原登录用户身份"。
源码结构很简单,继承 Spring 的 OncePerRequestFilter 模板方法:
  1. @Component
  2. public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
  3.     @Autowired
  4.     private TokenService tokenService;
  5.     @Override
  6.     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
  7.             throws ServletException, IOException {
  8.         LoginUser loginUser = tokenService.getLoginUser(request);
  9.         if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) {
  10.             tokenService.verifyToken(loginUser);
  11.             UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
  12.             authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
  13.             SecurityContextHolder.getContext().setAuthentication(authenticationToken);
  14.         }
  15.         chain.doFilter(request, response);
  16.     }
  17. }
复制代码
五步流程:

  • 取令牌:从 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:
  1. @Override
  2. public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
  3.     int code = HttpStatus.UNAUTHORIZED;
  4.     String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
  5.     ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
  6. }
复制代码
注意 HTTP 状态码仍是 200,业务码 401 通过 JSON body 的 code 字段传递——前后端分离项目的常见约定。未登录与登录逾期最终收敛为同一个结果:本过滤器判断身份失败 → 授权阶段输出 401,区别仅在判断失败的原因。
这种"无条件放行"的设计让匿名可访问的接口(登录、验证码、静态资源)不会被误伤——它们不携带令牌但必须放行,拒绝逻辑由授权阶段结合 URL 规则统一决议。
分发:DispatcherServlet

请求穿过 Spring Security 过滤链后,进入 Spring MVC 的 DispatcherServlet,由它按 URL 路由到 SysUserController.list() 方法。路由到目标方法后、实际调用之前,会先执行若依注册的拦截器链。
若依在 ResourcesConfig 中注册了 RepeatSubmitInterceptor,拦截 /** 全部请求做防重复提交检查:
  1. @Override
  2. public void addInterceptors(InterceptorRegistry registry) {
  3.     registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
  4. }
复制代码
它只对方法上标了 @RepeatSubmit 注解的接口生效——preHandle 中反射读注解,没标注就直接 return true 放行。本例的 list() 没有该注解,以是拦截器跑一下判断就放过了,不会做实际校验。
权限校验:@PreAuthorize

路由找到目标方法后,调用之前先过方法级权限校验。SysUserController.list() 上有:
  1. @PreAuthorize("@ss.hasPermi('system:user:list')")
  2. @GetMapping("/list")
  3. public TableDataInfo list(SysUser user) {
  4.     startPage();
  5.     List<SysUser> list = userService.selectUserList(user);
  6.     return getDataTable(list);
  7. }
复制代码
@PreAuthorize 是 Spring Security 的方法级权限注解(由 SecurityConfig 上的 @EnableMethodSecurity 开启)。表达式 @ss.hasPermi('system:user:list') 通过 SpEL 引用名为 ss 的 Spring Bean,调用它的 hasPermi 方法——这个 Bean 就是若依的 PermissionService:
  1. @Service("ss")
  2. public class PermissionService {
  3.     public boolean hasPermi(String permission) {
  4.         if (StringUtils.isEmpty(permission)) return false;
  5.         LoginUser loginUser = SecurityUtils.getLoginUser();
  6.         if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) return false;
  7.         PermissionContextHolder.setContext(permission);
  8.         return hasPermissions(loginUser.getPermissions(), permission);
  9.     }
  10.     private boolean hasPermissions(Set<String> permissions, String permission) {
  11.         return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
  12.     }
  13. }
复制代码
ss 这个 Bean 名取自 SpringSecurity 首字母。逻辑很直接:从 SecurityContextHolder 取出上一步过滤器写入的 LoginUser,读它的 permissions 集合,判断是否包含目标权限字符串。
若依的权限采用三段式约定:模块:功能:操作,如 system:user:list。超管的权限集合里有一个特殊值 *:*:*(Constants.ALL_PERMISSION),匹配到它直接放行。权限不通过时抛 AccessDeniedException,由全局非常处理器统一返回 403。
进入业务:Controller

权限校验通过,方法正式执行。SysUserController 继承 BaseController,复用了一组通用方法:
  1. @RestController
  2. @RequestMapping("/system/user")
  3. public class SysUserController extends BaseController {
  4.     @Autowired
  5.     private ISysUserService userService;
  6.     ...
  7. }
复制代码
进入 list() 之前,Spring MVC 会调用 BaseController 的 initBinder 方法注册日期属性编辑器,把前端传来的日期字符串(如 2026-01-01)自动转成 Date 对象。这针对的是 GET 请求表单/query 参数绑定路径;@RequestBody 的 JSON 反序列化走 Jackson,不经过这里。
然后业务体执行三步:startPage() → selectUserList() → getDataTable()。
开启分页:startPage()

startPage() 是 BaseController 的方法,委托 PageUtils:
  1. protected void startPage() {
  2.     PageUtils.startPage();
  3. }
复制代码
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:
  1. @DataScope(deptAlias = "d", userAlias = "u")
  2. public List<SysUser> selectUserList(SysUser user) {
  3.     return userMapper.selectUserList(user);
  4. }
复制代码
DataScopeAspect 在方法执行前(@Before 前置通知)拦截,根据当前登录用户所配角色的数据权限范围,往 user.params 里塞入拼好的 SQL 片段,最终被 Mapper XML 的  标签拼进查询条件——普通管理员只能看到本部门用户,超管(isAdmin() 判断,userId=1 硬编码约定)或角色的 data_scope=1(全部)则不过滤数据、能查全部。以是同一段 selectUserList(user),不同登录用户拿到的是不同范围的数据。Service 最终调用 SysUserMapper.selectUserList(user)。
数据访问:Mapper + PageHelper

SysUserMapper 是 MyBatis 接口,对应的 SQL 写在 resources/mapper/system/SysUserMapper.xml 里。接口 + XML 映射是 MyBatis 的标准用法,支持 、、 等动态 SQL 标签按条件拼接。
SQL 执行时,前面 startPage() 埋下的分页参数此时派上用场。PageHelper 的 PageInterceptor 拦截这次查询,从 ThreadLocal 取出分页参数,先执行一次 count 拿到总记录数,再给原 SQL 拼上 MySQL 的 LIMIT 执行分页查询,执行完自动清算 ThreadLocal——这就是分页"只对下一次查询生效、用完即弃"的原因。
以是 selectUserList(user) 返回的 List 本质是 PageHelper 的 Page 对象,里面除了数据还藏着 total。
结果封装:getDataTable()

Controller 拿到查询结果后,调 BaseController.getDataTable() 封装:
  1. protected TableDataInfo getDataTable(List<?> list) {
  2.     TableDataInfo rspData = new TableDataInfo();
  3.     rspData.setCode(HttpStatus.SUCCESS);
  4.     rspData.setMsg("查询成功");
  5.     rspData.setRows(list);
  6.     rspData.setTotal(new PageInfo(list).getTotal());
  7.     return rspData;
  8. }
复制代码
new PageInfo(list).getTotal() 从 PageHelper 的 Page 对象里取出总记录数。返回的 TableDataInfo 结构:
  1. {
  2.     "code": 200,
  3.     "msg": "查询成功",
  4.     "rows": [
  5.         { "userId": 1, "userName": "admin" },
  6.         { "userId": 2, "userName": "ry" }
  7.     ],
  8.     "total": 2
  9. }
复制代码
前端列表组件按 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 里检查用户名重复:
  1. if (StringUtils.isNotEmpty(userService.checkUserNameUnique(sysUser.getUserName()))) {
  2.     throw new ServiceException("新增用户'" + sysUser.getUserName() + "'失败,登录账号已存在");
  3. }
复制代码
业务层只管抛非常,统一封装由框架完成。
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表