2026-04/20~26技术问题整理
- [一.MyBatis-Plus 分页查询](#一.MyBatis-Plus 分页查询)
- [二. 模拟真实场景下请求的执行](#二. 模拟真实场景下请求的执行)
-
- [1. 前置基础-生产级公共模块](#1. 前置基础-生产级公共模块)
-
- [1.1 公共模块定位](#1.1 公共模块定位)
- [1.2 公共模块结构](#1.2 公共模块结构)
- [1.4 公共依赖 pom.xml](#1.4 公共依赖 pom.xml)
- [1.5 全局常量类(AuthConstant)](#1.5 全局常量类(AuthConstant))
-
- [1. 用途](#1. 用途)
- [2. 设计原因](#2. 设计原因)
- [1.6 公共实体:LoginUser(登录用户)](#1.6 公共实体:LoginUser(登录用户))
-
- [1. 用途](#1. 用途)
- [2. 设计原因](#2. 设计原因)
- [1.7 用户上下文 UserContext](#1.7 用户上下文 UserContext)
-
- [1. 用途](#1. 用途)
- [2. 设计原因](#2. 设计原因)
- [1.8 生产级 Redis 工具类(RedisUtil)](#1.8 生产级 Redis 工具类(RedisUtil))
-
- [1. 用途](#1. 用途)
- [2. 设计原因](#2. 设计原因)
- [1.9 生产级 JWT 工具类(JwtUtil)](#1.9 生产级 JWT 工具类(JwtUtil))
-
- [1. 用途](#1. 用途)
- [2. 设计原因](#2. 设计原因)
- [1.10 Feign 全局拦截器(核心:服务间透传)](#1.10 Feign 全局拦截器(核心:服务间透传))
-
- [1. 用途](#1. 用途)
- [2. 设计原因](#2. 设计原因)
- [1.11 统一返回体(Result)](#1.11 统一返回体(Result))
-
- [1. 用途](#1. 用途)
- [2. 设计原因](#2. 设计原因)
- [2. 网关 Gateway 模块-生产级详解](#2. 网关 Gateway 模块-生产级详解)
-
- [1. 网关模块-核心定位 & 核心特点](#1. 网关模块-核心定位 & 核心特点)
-
- [1.1 核心定位](#1.1 核心定位)
- [1.2 关键生产特性(必懂)](#1.2 关键生产特性(必懂))
- [1.3 网关模块目录结构](#1.3 网关模块目录结构)
- [2. 网关模块-专属依赖 pom.xml](#2. 网关模块-专属依赖 pom.xml)
-
- [1. 关键说明](#1. 关键说明)
- [3. 网关模块-核心组件](#3. 网关模块-核心组件)
-
- [1. 自定义配置类 JwtProperties](#1. 自定义配置类 JwtProperties)
-
- [1. 作用](#1. 作用)
- [2. 全局核心过滤器 GlobalAuthFilter](#2. 全局核心过滤器 GlobalAuthFilter)
-
- [1. 核心作用](#1. 核心作用)
- [2. 设计原理](#2. 设计原理)
- [3. 网关全局配置 GatewayConfig](#3. 网关全局配置 GatewayConfig)
- [1. 作用](#1. 作用)
- [4. 网关 application.yml 配置文件](#4. 网关 application.yml 配置文件)
- [2. 场景 1:用户登录认证(前端 → 网关 → user 服务)](#2. 场景 1:用户登录认证(前端 → 网关 → user 服务))
-
- [1. 参与服务](#1. 参与服务)
- [2. 全流程步骤](#2. 全流程步骤)
- [3. 全流程逐步骤拆解](#3. 全流程逐步骤拆解)
-
- [1. 模拟前端请求](#1. 模拟前端请求)
- [2. Gateway 网关处理](#2. Gateway 网关处理)
- [3. user 服务 → SpringSecurity 完整认证链路](#3. user 服务 → SpringSecurity 完整认证链路)
- [4. 认证成功 → 生成 JWT 令牌](#4. 认证成功 → 生成 JWT 令牌)
- [5. 生产核心:加载权限 + Redis 缓存](#5. 生产核心:加载权限 + Redis 缓存)
- [6. 返回结果→前端存储](#6. 返回结果→前端存储)
- [4. 核心代码实现](#4. 核心代码实现)
-
- [1. gateway 全局过滤器(白名单放行)](#1. gateway 全局过滤器(白名单放行))
- [2. User 服务 SpringSecurity 完整配置(SecurityConfig.java)](#2. User 服务 SpringSecurity 完整配置(SecurityConfig.java))
- [3.User 服务 自定义用户认证类(UserDetailsServiceImpl.java)](#3.User 服务 自定义用户认证类(UserDetailsServiceImpl.java))
- [4. User 服务 登录核心业务类(AuthService.java)](#4. User 服务 登录核心业务类(AuthService.java))
- [5. User 服务 登录 Controller(完整)](#5. User 服务 登录 Controller(完整))
- [6. Mapper 层 SQL](#6. Mapper 层 SQL)
- [3. 场景2:登录后前端获取动态角色菜单](#3. 场景2:登录后前端获取动态角色菜单)
-
- [1. 核心流程:](#1. 核心流程:)
- [2. 全流程 极致分步拆解(10 步・无遗漏・带原理)](#2. 全流程 极致分步拆解(10 步・无遗漏・带原理))
-
- [1. :前端发起菜单请求](#1. :前端发起菜单请求)
- [2. Gateway 网关接收请求](#2. Gateway 网关接收请求)
- [3. Portal 服务接收请求](#3. Portal 服务接收请求)
- [4. Feign 拦截器自动透传请求头](#4. Feign 拦截器自动透传请求头)
- [5. User 服务接收 Feign 请求](#5. User 服务接收 Feign 请求)
- [6. Redis 缓存读取判断](#6. Redis 缓存读取判断)
- [7. User 服务返回菜单数据](#7. User 服务返回菜单数据)
- [8. Portal 服务转发结果](#8. Portal 服务转发结果)
- [3. 全模块完整代码](#3. 全模块完整代码)
-
- [1. 公共模块 Feign 拦截器](#1. 公共模块 Feign 拦截器)
- [2. Portal 服务 Feign 客户端(UserFeign.java)](#2. Portal 服务 Feign 客户端(UserFeign.java))
- [3. Portal 服务 菜单接口 Controller(PortalController.java)](#3. Portal 服务 菜单接口 Controller(PortalController.java))
- [4. User 服务 菜单接口补充(UserController.java 新增方法)](#4. User 服务 菜单接口补充(UserController.java 新增方法))
- [4. 场景3:外部接口调用A服务,A服务通过feign调用B服务,A的ThreadLocal竟然拿到了token信息](#4. 场景3:外部接口调用A服务,A服务通过feign调用B服务,A的ThreadLocal竟然拿到了token信息)
-
- [1. 为什么会出现这个诡异现象?](#1. 为什么会出现这个诡异现象?)
-
- [1. 根本原因:Tomcat 线程复用 + ThreadLocal 未主动清空](#1. 根本原因:Tomcat 线程复用 + ThreadLocal 未主动清空)
- [2. 完整链路还原](#2. 完整链路还原)
- [3. 高危影响](#3. 高危影响)
- [4. 场景生活化理解](#4. 场景生活化理解)
- [5. 结合前端发送请求到后端的真实场景,理解线程和 ThreadLocal](#5. 结合前端发送请求到后端的真实场景,理解线程和 ThreadLocal)
-
- [1. 通俗解释](#1. 通俗解释)
-
- [1. 线程(后端处理前端请求的核心)](#1. 线程(后端处理前端请求的核心))
- [2. ThreadLocal(线程的私有小口袋)](#2. ThreadLocal(线程的私有小口袋))
- [2. 真实业务场景(前端→后端请求必用)](#2. 真实业务场景(前端→后端请求必用))
- [3. 核心铁律](#3. 核心铁律)
- [4. 完整代码示例](#4. 完整代码示例)
-
- [1. 核心工具类:ThreadLocal 存储用户](#1. 核心工具类:ThreadLocal 存储用户)
- [2. 模拟后端三层架构(Controller→Service→Dao)](#2. 模拟后端三层架构(Controller→Service→Dao))
- [3. 模拟多线程(前端并发请求)](#3. 模拟多线程(前端并发请求))
- [4. 运行结果(数据完全隔离)](#4. 运行结果(数据完全隔离))
- [5. 关键踩坑提醒](#5. 关键踩坑提醒)
一.MyBatis-Plus 分页查询
1.问题
手写分页查询 SQL,入参为 Page 对象 + QueryWrapper 对象,如何实现通用分页?
2.答案
2.1 核心依赖
java
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
2.2 Mapper 接口
java
IPage<User> selectUserPage(@Param("page") IPage<User> page, @Param("ew") QueryWrapper<User> queryWrapper);
2.3 XML SQL
xml
<select id="selectUserPage" resultType="com.xxx.entity.User">
SELECT id,username FROM user
<where>${ew.sqlSegment}</where>
</select>
2.4 分页插件配置(必须)
java
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
2.5 关键知识点总结
2.5.1 为什么用 ${ew.sqlSegment}?
QueryWrapper 会把你写的条件(like/eq/in)自动拼接成 SQL 片段,${ew.sqlSegment} 直接把这段 SQL 插入到 中,MP 已处理 SQL 注入,安全可靠。
2.5.2 @Param("page") 和 @Param("ew") 能改吗?
-
page:可以改,但不建议
-
ew:必须叫 ew,这是 MP 解析条件构造器的固定别名
-
分页插件自动处理 LIMIT,无需手写
-
支持单表 / 连表分页查询
二. 模拟真实场景下请求的执行
1. 前置基础-生产级公共模块
所有服务依赖common公共模块,先写完整代码
1.1 公共模块定位
所有微服务的基础依赖,抽离通用代码、统一规范、解决微服务核心痛点:
1. 上下文传递(Feign / 异步线程不丢失)
2. 统一返回格式
3. 统一缓存规范
4. 统一 JWT 鉴权
5. 统一异常处理
6. 服务间请求头透传
1.2 公共模块结构
java
common/
├── pom.xml # 公共依赖
├── constant/ # 全局常量
│ └── AuthConstant.java # 认证/缓存/请求头常量
├── context/ # 用户上下文
│ └── UserContext.java # 线程安全用户上下文(核心)
├── pojo/ # 公共实体
│ ├── LoginUser.java # 登录用户实体
│ └── Result.java # 统一返回体
├── util/ # 工具类
│ ├── RedisUtil.java # 生产级Redis工具
│ ├── JwtUtil.java # 生产级JWT工具
│ └── JsonUtil.java # JSON序列化工具
├── interceptor/ # 拦截器
│ └── FeignHeaderInterceptor.java # Feign透传拦截器
└── exception/ # 全局异常
└── GlobalExceptionHandler.java
1.4 公共依赖 pom.xml
xml
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- 异步上下文(解决Feign/线程池丢失) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.3</version>
</dependency>
<!-- Feign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<scope>provided</scope>
</dependency>
<!-- 工具包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
1.5 全局常量类(AuthConstant)
1. 用途
统一管理请求头、缓存 Key、白名单,避免硬编码,生产必备规范
2. 设计原因
微服务多,统一常量防止拼写错误、方便维护
java
/**
* 认证相关常量(所有服务共用)
* 生产规范:所有固定字符串统一维护,禁止硬编码
*/
public interface AuthConstant {
// ====================== 网关透传请求头(核心!所有服务依赖) ======================
/** 用户ID请求头 */
String HEADER_USER_ID = "x-user-id";
/** 角色编码请求头 */
String HEADER_ROLE_CODE = "x-role-code";
/** 用户名请求头 */
String HEADER_USERNAME = "x-username";
/** Token请求头 */
String HEADER_TOKEN = "Authorization";
// ====================== Redis缓存Key前缀(统一规范) ======================
/** 用户菜单缓存Key */
String REDIS_MENU_KEY = "user:menu:";
/** 角色权限缓存Key */
String REDIS_PERM_KEY = "role:permission:";
/** 第三方鉴权缓存Key */
String REDIS_THIRD_AUTH_KEY = "third:auth:";
// ====================== 白名单(网关/业务服务共用) ======================
List<String> WHITE_URL_LIST = Arrays.asList(
"/user/login", // 登录接口
"/webservice/external",// 第三方公开接口
"/actuator/**", // 监控
"/doc.html", // 接口文档
"/v3/api-docs/**"
);
}
1.6 公共实体:LoginUser(登录用户)
1. 用途
存储当前登录用户信息,所有服务共享
2. 设计原因
微服务全链路传递用户信息,必须实现序列化(Redis/Feign 传输)
java
import lombok.Data;
import java.io.Serializable;
/**
* 全服务共享的登录用户实体
* 1. 存储用户核心信息
* 2. 支持序列化(Redis缓存/Feign传递)
* 3. 所有业务服务从上下文获取该对象
*/
@Data
public class LoginUser implements Serializable {
private static final long serialVersionUID = 1L;
/** 用户ID(唯一标识) */
private Long userId;
/** 用户名 */
private String username;
/** 角色编码:admin-管理员 / user-普通用户 */
private String roleCode;
/** 租户ID(多租户系统必备) */
private Long tenantId;
}
1.7 用户上下文 UserContext
1. 用途
线程安全存储当前用户信息,替代 Spring SecurityContext
2. 设计原因
-
普通ThreadLocal:Feign 调用、异步线程、线程池会丢失用户信息
-
生产必须用Alibaba TransmittableThreadLocal:支持父子线程 / 线程池 / Feign 传递
-
请求结束必须清空:防止内存泄漏、用户串号
java
import com.alibaba.ttl.TransmittableThreadLocal;
import cn.hutool.core.util.StrUtil;
/**
* 【微服务核心组件】全局用户上下文
* 解决痛点:
* 1. 普通ThreadLocal在Feign/异步线程中丢失用户信息
* 2. 全服务统一获取当前登录用户
* 3. 线程安全,无并发问题
*/
public class UserContext {
/**
* 生产专用:TransmittableThreadLocal
* 支持:父子线程、线程池、Feign调用 上下文传递
*/
private static final TransmittableThreadLocal<LoginUser> USER_THREAD_LOCAL = new TransmittableThreadLocal<>();
/**
* 存储用户信息
*/
public static void set(LoginUser user) {
if (user != null) {
USER_THREAD_LOCAL.set(user);
}
}
/**
* 获取当前登录用户(所有业务代码通用)
*/
public static LoginUser get() {
return USER_THREAD_LOCAL.get();
}
/**
* 获取当前用户ID(快捷方法)
*/
public static Long getUserId() {
LoginUser user = get();
return user == null ? null : user.getUserId();
}
/**
* 清空上下文
* 【生产强制要求】:请求结束必须调用,防止内存泄漏/用户串号
*/
public static void clear() {
USER_THREAD_LOCAL.remove();
}
/**
* 判断是否登录
*/
public static boolean isLogin() {
return get() != null && StrUtil.isNotBlank(get().getRoleCode());
}
}
1.8 生产级 Redis 工具类(RedisUtil)
1. 用途
统一 Redis 操作,支持对象序列化、过期、泛型、异常捕获
2. 设计原因
-
原生 RedisTemplate 无泛型、序列化麻烦
-
生产必须捕获异常,防止 Redis 故障导致服务雪崩
java
import cn.hutool.json.JSONUtil;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* 生产级Redis工具类
* 1. 自动序列化/反序列化对象
* 2. 统一异常处理(Redis宕机不影响业务)
* 3. 支持过期时间、泛型获取
* 4. 所有服务缓存菜单/权限使用
*/
@Component
public class RedisUtil {
@Resource
private StringRedisTemplate stringRedisTemplate;
// ====================== 存储对象 ======================
public <T> void set(String key, T value) {
try {
String json = JSONUtil.toJsonStr(value);
stringRedisTemplate.opsForValue().set(key, json);
} catch (Exception e) {
// 生产:Redis异常只打印日志,不抛错,保证服务可用
e.printStackTrace();
}
}
// ====================== 存储对象(带过期) ======================
public <T> void set(String key, T value, long timeout, TimeUnit unit) {
try {
String json = JSONUtil.toJsonStr(value);
stringRedisTemplate.opsForValue().set(key, json, timeout, unit);
} catch (Exception e) {
e.printStackTrace();
}
}
// ====================== 获取对象(泛型) ======================
public <T> T get(String key, Class<T> clazz) {
try {
String json = stringRedisTemplate.opsForValue().get(key);
return JSONUtil.toBean(json, clazz);
} catch (Exception e) {
return null;
}
}
// ====================== 删除缓存 ======================
public void delete(String key) {
try {
stringRedisTemplate.delete(key);
} catch (Exception e) {
e.printStackTrace();
}
}
// ====================== 判断Key是否存在 ======================
public Boolean hasKey(String key) {
try {
return stringRedisTemplate.hasKey(key);
} catch (Exception e) {
return false;
}
}
}
1.9 生产级 JWT 工具类(JwtUtil)
1. 用途
user 服务生成 Token + gateway 校验 Token,双服务共用
2. 设计原因
-
JWT 是微服务无状态鉴权标准
-
生产必须:验签、过期、异常处理、配置化密钥
-
网关只解析不生成,用户服务只生成不解析
java
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.Map;
/**
* 生产级JWT工具类
* 职责分工:
* 1. user服务:生成Token(登录成功后)
* 2. gateway服务:解析/校验Token(所有请求入口)
* 3. 其他服务:不操作JWT,只拿网关透传的请求头
*/
@Component
public class JwtUtil {
/**
* JWT密钥(生产从nacos配置中心读取,禁止硬编码)
*/
@Value("${jwt.secret}")
private String secret;
/**
* Token过期时间(默认2小时)
*/
@Value("${jwt.expire:7200}")
private Long expire;
// ====================== 生成Token(user服务专用) ======================
public String generateToken(LoginUser user) {
return Jwts.builder()
// 主题:用户ID
.setSubject(user.getUserId().toString())
// 自定义载荷:存储用户核心信息(网关解析用)
.claim("username", user.getUsername())
.claim("roleCode", user.getRoleCode())
.claim("tenantId", user.getTenantId())
// 过期时间
.setExpiration(new Date(System.currentTimeMillis() + expire * 1000))
// 签名算法+密钥
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
// ====================== 解析Token(gateway服务专用) ======================
public Claims parseToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token.replace("Bearer ", ""))
.getBody();
} catch (ExpiredJwtException e) {
throw new RuntimeException("Token已过期");
} catch (SignatureException e) {
throw new RuntimeException("Token签名非法");
} catch (Exception e) {
throw new RuntimeException("Token解析失败");
}
}
}
1.10 Feign 全局拦截器(核心:服务间透传)
1. 用途
自动把网关的用户请求头传递给下游服务
2. 设计原因
微服务 A 调用微服务 B,请求头会丢失,必须手动透传
java
import cn.hutool.core.util.StrUtil;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import static com.common.constant.AuthConstant.*;
/**
* 【微服务核心】Feign全局请求头拦截器
* 解决痛点:
* 服务间Feign调用时,网关传递的 x-user-id 等请求头会丢失
* 作用:自动把上游请求头,透传给下游所有服务
* 生效范围:product/portal/inspect 所有Feign调用
*/
@Component
public class FeignHeaderInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 获取当前请求的上下文
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return;
}
HttpServletRequest request = attributes.getRequest();
// 从原请求中,获取网关透传的用户信息
String userId = request.getHeader(HEADER_USER_ID);
String roleCode = request.getHeader(HEADER_ROLE_CODE);
String username = request.getHeader(HEADER_USERNAME);
// 透传给下游服务(Feign请求头)
if (StrUtil.isNotBlank(userId)) {
template.header(HEADER_USER_ID, userId);
}
if (StrUtil.isNotBlank(roleCode)) {
template.header(HEADER_ROLE_CODE, roleCode);
}
if (StrUtil.isNotBlank(username)) {
template.header(HEADER_USERNAME, username);
}
}
}
1.11 统一返回体(Result)
1. 用途
所有服务统一返回格式,前端统一解析
2. 设计原因
微服务多接口,返回格式必须一致
java
import lombok.Data;
import java.io.Serializable;
/**
* 全局统一返回结果
* 所有Controller/Feign接口必须返回该对象
*/
@Data
public class Result<T> implements Serializable {
private static final long serialVersionUID = 1L;
/** 成功状态码 */
public static final int SUCCESS = 200;
/** 失败状态码 */
public static final int ERROR = 500;
/** 未授权 */
public static final int UNAUTHORIZED = 401;
/** 无权限 */
public static final int FORBIDDEN = 403;
/** 状态码 */
private int code;
/** 提示信息 */
private String msg;
/** 数据 */
private T data;
// 成功
public static <T> Result<T> success() {
return success(null);
}
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(SUCCESS);
result.setMsg("操作成功");
result.setData(data);
return result;
}
// 失败
public static <T> Result<T> error(String msg) {
Result<T> result = new Result<>();
result.setCode(ERROR);
result.setMsg(msg);
return result;
}
// 未授权
public static <T> Result<T> unauthorized() {
Result<T> result = new Result<>();
result.setCode(UNAUTHORIZED);
result.setMsg("未登录或Token过期");
return result;
}
}
2. 网关 Gateway 模块-生产级详解
1. 网关模块-核心定位 & 核心特点
1.1 核心定位
-
微服务唯一统一入口,所有前端、第三方、服务请求必须经过网关;
-
只负责:路由转发、JWT 鉴权、请求头清洗、白名单放行、跨域、限流;
-
不处理任何业务逻辑、不访问数据库、不使用 SpringSecurity、不使用 Servlet。
1.2 关键生产特性(必懂)
-
架构:Spring Cloud Gateway → WebFlux 响应式
-
无 Servlet 容器:没有 HttpServletRequest、没有 ServletContext
-
无 ThreadLocal 原生支持:不能用普通 ThreadLocal
-
不依赖 SpringSecurity:网关不做认证授权,只做Token 合法性校验
-
只依赖公共模块 common 工具(JwtUtil、常量),不写冗余代码
1.3 网关模块目录结构
java
gateway/
├── pom.xml # 网关专属依赖
├── src/main/java/com/gateway/
│ ├── config/
│ │ ├── GatewayConfig.java # 全局路由、跨域、网关配置
│ │ └── JwtProperties.java # JWT 配置参数(配置化)
│ ├── filter/
│ │ └── GlobalAuthFilter.java # 【核心】全局JWT鉴权过滤器
│ ├── util/ # 复用common工具,无重复代码
│ └── GatewayApplication.java # 启动类
└── src/main/resources/
└── application.yml # 路由、JWT、网关配置
2. 网关模块-专属依赖 pom.xml
1. 关键说明
-
禁用 spring-web(Servlet),必须用 spring-webflux
-
必须引入 common 公共模块(复用常量、JwtUtil)
-
禁用 SpringSecurity 依赖(网关不需要)
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.gateway</groupId>
<artifactId>gateway</artifactId>
<version>1.0.0</version>
<dependencies>
<!-- 核心:Spring Cloud Gateway 网关 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- 关键:WebFlux 响应式(网关专属,替代Servlet) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- 引入公共模块(复用:常量、JwtUtil、通用工具) -->
<dependency>
<groupId>com.common</groupId>
<artifactId>common</artifactId>
<version>1.0.0</version>
</dependency>
<!-- 注册中心(Nacos/Eureka 按需) -->
<dependency>
<groupId>com.al.cloud</groupId>
<artifactId>nacos-gateway-starter</artifactId>
</dependency>
<!-- 工具、Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependency>
</project>
3. 网关模块-核心组件
1. 自定义配置类 JwtProperties
1. 作用
-
网关 JWT 密钥、过期时间配置化,不写死代码
-
生产环境统一由 Nacos 配置中心管理
-
与 common 模块 JwtUtil 配置统一
java
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 网关JWT配置类
* 生产用途:
* 1. 配置绑定,避免硬编码密钥
* 2. 与公共模块JwtUtil参数统一
* 3. 支持Nacos动态配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
/**
* JWT 加密密钥
*/
private String secret;
/**
* Token 过期时间(秒)
*/
private Long expire;
}
2. 全局核心过滤器 GlobalAuthFilter
1. 核心作用
-
拦截所有请求
-
白名单接口(登录、第三方)直接放行
-
校验 JWT 有效性:过期、篡改、非法
-
解析 JWT 载荷:userId、roleCode、username
-
重构请求头,向下游所有服务透传统一用户头
-
拦截非法请求,返回 401 未授权
2. 设计原理
-
网关是 WebFlux,使用 GlobalFilter 响应式过滤器
-
不使用 Servlet 接口,使用 ServerWebExchange
-
不修改源请求,使用 mutate() 重构请求头
-
异常统一捕获,防止网关崩溃
java
import com.common.constant.AuthConstant;
import com.common.util.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.gateway.GlobalFilter;
import reactor.core.publisher.Mono;
/**
* 【网关核心】全局JWT鉴权过滤器
* 作用:
* 1. 全局拦截所有请求
* 2. 白名单接口放行
* 3. 校验JWT合法性
* 4. 解析用户信息,透传下游服务统一请求头
* 5. 拦截非法、过期、篡改Token
*
* 设计优先级:Order=-100 最先执行
*/
@Configuration
@RequiredArgsConstructor
@Order(-100)
public class GlobalAuthFilter implements GlobalFilter {
private final JwtUtil jwtUtil;
private final JwtProperties jwtProperties;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 获取当前请求路径
String requestPath = exchange.getRequest().getPath().value();
// 2. 核心:白名单接口直接放行(登录、第三方、监控)
if (isWhitePath(requestPath)) {
return chain.filter(exchange);
}
// 3. 获取请求头中的Token
ServerHttpRequest request = exchange.getRequest();
String token = request.getHeaders().getFirst(AuthConstant.HEADER_TOKEN);
// 4. 无Token → 直接401
if (token == null || token.isBlank()) {
return handleUnauthorized(exchange);
}
try {
// 5. 去除Bearer前缀,解析JWT
String realToken = token.replace("Bearer ", "");
Claims claims = jwtUtil.parseToken(realToken);
// 6. 解析核心用户信息
String userId = claims.getSubject();
String roleCode = claims.get("roleCode", String.class);
String username = claims.get("username", String.class);
// 7. 【核心】重构请求头,向下游所有服务透传统一标识
ServerHttpRequest newRequest = request.mutate()
.header(AuthConstant.HEADER_USER_ID, userId)
.header(AuthConstant.HEADER_ROLE_CODE, roleCode)
.header(AuthConstant.HEADER_USERNAME, username)
.build();
// 8. 转发修改后的请求到下游服务
return chain.filter(exchange.mutate().request(newRequest).build());
} catch (Exception e) {
// 9. Token过期、篡改、无效 → 401
return handleUnauthorized(exchange);
}
}
/**
* 判断是否为白名单路径
*/
private boolean isWhitePath(String path) {
return AuthConstant.WHITE_URL_LIST.stream()
.anyMatch(white -> path.startsWith(white.replace("**", "")));
}
/**
* 统一返回:未授权401
*/
private Mono<Void> handleUnauthorized(ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
}
3. 网关全局配置 GatewayConfig
1. 作用
-
全局跨域配置(网关统一处理,下游服务无需配置)
-
路由规则自动加载
-
网关全局参数配置
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.CompleteCorsConfigurationSource;
/**
* 网关全局配置
* 1. 全局跨域(WebFlux响应式)
* 2. 网关通用配置
*/
@Configuration
public class GatewayConfig {
/**
* 网关统一跨域配置
* 生产关键点:
* 1. 网关统一处理跨域,下游所有服务关闭跨域
* 2. 解决前端跨域冲突
*/
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.setAllowAllOrigin(true);
corsConfig.setAllowAllMethod(true);
corsConfig.setAllowAllHeader(true);
corsConfig.setAllowCredentials(true);
CompleteCorsConfigurationSource source = new CompleteCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig);
return new CorsWebFilter(source);
}
}
4. 网关 application.yml 配置文件
yaml
# Spring 核心配置节点
spring:
cloud:
gateway:
# ===================== 全局跨域配置(网关层统一处理) =====================
globalcors:
# 跨域规则配置集合(可配置多套规则,此处匹配所有路径)
cors-configurations:
# 匹配所有请求路径:/** 表示任意层级的URL(如 /user/1、/order/list 等)
'[/**]':
# 允许的跨域源地址:* 表示允许所有域名(生产环境建议指定具体域名,如https://www.xxx.com)
allowed-origins: "*"
# 允许的HTTP请求方法:* 覆盖GET/POST/PUT/DELETE/OPTIONS等所有方法
allowed-methods: "*"
# 允许的请求头:* 允许前端携带任意自定义头(如Token、Content-Type、Authorization等)
allowed-headers: "*"
# 是否允许跨域请求携带Cookie/Token等凭证信息(true为允许,前端传凭证时必须开启)
allow-credentials: true
# ===================== 路由规则配置(核心转发逻辑) =====================
routes:
# 路由唯一ID:自定义,建议与微服务名一致(仅用于标识,无业务逻辑)
- id: user-service
# 转发目标地址:lb:// 表示启用Spring Cloud负载均衡(Load Balancer)
# user-service 是注册中心(如Nacos/Eureka)中的微服务名称
uri: lb://user-service
# 路由断言(Predicate):判断请求是否匹配当前路由的条件
predicates:
# 路径断言:匹配所有以 /user/ 开头的请求(如 /user/login、/user/info/123)被转发到user-service服务.
- Path=/user/**
# 路由过滤器(Filter):对请求/响应进行预处理/后处理
filters:
# 1. 基于Redis令牌桶的限流过滤器
- name: RequestRateLimiter
args:
# 令牌桶每秒填充速率:允许的平均QPS(每秒处理10个请求)
redis-rate-limiter.replenishRate: 10
# 令牌桶最大容量:允许的峰值QPS(突发请求最多处理20个)
# 说明:桶内令牌用完后,请求会被限流,直到令牌补充
redis-rate-limiter.burstCapacity: 20
# 限流键解析器:引用自定义的ipKeyResolver Bean(基于IP维度限流)
# 需自行实现该Bean(示例见下文)
key-resolver: "#{@ipKeyResolver}"
2. 场景 1:用户登录认证(前端 → 网关 → user 服务)
1. 参与服务
前端 → Gateway 网关 → User 用户服务
2. 全流程步骤
-
前端发送登录请求到网关
-
gateway:白名单放行,路由转发到user-service
-
user 服务通过SpringSecurity 完整认证链校验身份
-
校验通过:封装用户信息→生成 JWT 令牌
-
读取用户角色、菜单、接口权限→永久缓存 Redis(低频数据)
-
返回 JWT 给前端,前端本地存储,后续所有请求携带 Token
-
异常拦截:账号错误、账号禁用、权限缺失统一报错
3. 全流程逐步骤拆解
1. 模拟前端请求
请求地址:http://localhost:8080/user/login
请求方式:POST
请求体:
bash
{"username":"admin","password":"123456"}
2. Gateway 网关处理
-
网关是WebFlux 响应式,非 Servlet,无 Security;
-
读取全局白名单列表 AuthConstant.WHITE_URL_LIST;
-
匹配 /user/login → 直接放行,不校验 JWT、不解析令牌;
-
读取路由配置,负载转发到 user-service;
-
网关不处理任何账号密码校验,不生成任何用户信息;
-
网关不创建任何上下文(无 ThreadLocal)。
-
生产原理:登录阶段用户无 Token,网关一旦校验 Token,直接 401 报错,所以登录接口必须全局放行
3. user 服务 → SpringSecurity 完整认证链路
-
请求进入 user 服务 SpringSecurity 过滤器链;
-
触发自定义 UserDetailsService;
-
数据库查询用户信息:账号、密码、状态、角色;
-
校验:账号是否存在、账号是否禁用、密码是否匹配;
-
校验成功:构建 Authentication 对象,存入SecurityContext;
-
校验失败:直接抛出异常,返回前端错误提示
4. 认证成功 → 生成 JWT 令牌
-
读取当前用户 ID、用户名、角色编码;
-
调用common模块JwtUtil,使用统一密钥生成 JWT;
-
JWT 载荷存储核心必要信息:userId、roleCode、username;
-
不存储敏感数据,保证无状态。
5. 生产核心:加载权限 + Redis 缓存
关键点:菜单、角色权限是低频数据,绝不每次请求查库
-
根据roleCode查询数据库→用户菜单列表、角色接口权限列表;
-
调用common模块RedisUtil;
-
写入永久缓存(无过期,权限修改后手动清空):
1. 校验 Redis 缓存是否存在: .缓存 Key:role:menu:{roleCode}、role:permission:{roleCode} 2. 无缓存:查询数据库 → 写入 Redis永久缓存 3. 存在缓存:直接复用,不查询数据库 4. 生产设计逻辑: . 菜单、角色权限为低频修改数据 . 同角色所有用户共享一份缓存,极致优化性能 . 权限修改后,人工清空对应角色缓存即可 . 下次登录 / 访问自动重建缓存。
6. 返回结果→前端存储
-
返回统一Result格式;
-
携带JWT 令牌;
-
前端接收后,存入localStorage/sessionStorage;
-
后续所有请求,自动携带 Authorization 头。
4. 核心代码实现
1. gateway 全局过滤器(白名单放行)
java
package com.gateway.filter;
import com.common.constant.AuthConstant;
import com.common.util.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* 网关全局过滤器 【完整无删减】
* 核心:登录白名单放行、JWT校验、下游请求头透传
* 网关:WebFlux架构、无Servlet、无Security
*/
@Component
@Order(-100)
@RequiredArgsConstructor
public class GlobalAuthFilter implements GlobalFilter {
private final JwtUtil jwtUtil;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 获取请求路径
String requestPath = exchange.getRequest().getPath().value();
// 2. 核心:登录、白名单接口 直接放行 【登录核心逻辑】
boolean isWhite = AuthConstant.WHITE_URL_LIST.stream()
.anyMatch(pattern -> requestPath.startsWith(pattern.replace("**", "")));
if (isWhite) {
return chain.filter(exchange);
}
// 3. 非白名单:获取Token
ServerHttpRequest request = exchange.getRequest();
String token = request.getHeaders().getFirst(AuthConstant.HEADER_TOKEN);
if (token == null || token.isBlank()) {
return reject(exchange);
}
// 4. JWT校验 解析
try {
String realToken = token.replace("Bearer ", "");
Claims claims = jwtUtil.parseToken(realToken);
// 5. 解析用户核心信息
String userId = claims.getSubject();
String roleCode = claims.get("roleCode", String.class);
String username = claims.get("username", String.class);
// 6. 重构请求头 向下游所有服务透传
ServerHttpRequest newRequest = request.mutate()
.header(AuthConstant.HEADER_USER_ID, userId)
.header(AuthConstant.HEADER_ROLE_CODE, roleCode)
.header(AuthConstant.HEADER_USERNAME, username)
.build();
// 7. 放行请求
return chain.filter(exchange.mutate().request(newRequest).build());
} catch (Exception e) {
// 8. Token过期、篡改、非法 统一拦截
return reject(exchange);
}
}
/**
* 统一返回401 未授权
*/
private Mono<Void> reject(ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
}
2. User 服务 SpringSecurity 完整配置(SecurityConfig.java)
java
package com.user.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
/**
* User服务 专属Security配置
* 仅登录认证使用 业务服务禁用
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
private final UserDetailsService userDetailsService;
public SecurityConfig(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 关闭CSRF 微服务无状态必备
.csrf().disable()
// 关闭表单登录 无需页面登录
.formLogin().disable()
// 权限配置
.authorizeRequests()
// 登录接口 永久放行
.antMatchers("/user/login").permitAll()
// 其余所有接口 必须认证
.anyRequest().authenticated();
// 绑定自定义用户认证逻辑
http.userDetailsService(userDetailsService);
return http.build();
}
/**
* 密码加密算法 BCrypt 生产标配
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 认证管理器 登录认证核心
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
3.User 服务 自定义用户认证类(UserDetailsServiceImpl.java)
java
package com.user.service.impl;
import com.user.mapper.SysUserMapper;
import com.user.entity.SysUser;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* SpringSecurity 认证核心类 【完整版】
* 校验账号、状态、角色
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final SysUserMapper sysUserMapper;
public UserDetailsServiceImpl(SysUserMapper sysUserMapper) {
this.sysUserMapper = null;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. 根据用户名查询数据库
SysUser user = sysUserMapper.selectByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户名不存在");
}
// 2. 生产必备:校验账号状态 0=禁用
if (user.getStatus() == 0) {
throw new RuntimeException("账号已被禁用,请联系管理员");
}
// 3. 封装角色信息 前缀ROLE_
List<GrantedAuthority> authorityList = new ArrayList<>();
authorityList.add(new SimpleGrantedAuthority("ROLE_" + user.getRoleCode()));
// 4. 返回Security认证实体
return new User(
user.getUsername(),
user.getPassword(),
true,
true,
true,
true,
authorityList
);
}
}
4. User 服务 登录核心业务类(AuthService.java)
java
package com.user.service;
import com.common.constant.AuthConstant;
import com.common.entity.LoginUser;
import com.common.util.JwtUtil;
import com.common.util.RedisUtil;
import com.user.entity.SysUser;
import com.user.mapper.SysMenuMapper;
import com.user.mapper.SysUserMapper;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 登录业务层 【完整·修正RBAC·无简化】
* 核心:角色维度缓存 杜绝用户绑定菜单
*/
@Service
public class AuthService {
private final AuthenticationManager authenticationManager;
private final SysUserMapper sysUserMapper;
private final SysMenuMapper sysMenuMapper;
private final JwtUtil jwtUtil;
private final RedisUtil redisUtil;
public AuthService(AuthenticationManager authenticationManager,
SysUserMapper sysUserMapper,
SysMenuMapper sysMenuMapper,
JwtUtil jwtUtil,
RedisUtil redisUtil) {
this.authenticationManager = authenticationManager;
this.sysUserMapper = sysUserMapper;
this.sysMenuMapper = sysMenuMapper;
this.jwtUtil = jwtUtil;
this.redisUtil = redisUtil;
}
/**
* 生产级完整登录逻辑
*/
public LoginUser login(String username, String password) {
// 1. 构建认证令牌
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(username, password);
// 2. 启动SpringSecurity认证链路 核心校验账号密码
Authentication authentication = authenticationManager.authenticate(authToken);
// 3. 查询完整用户信息
SysUser sysUser = sysUserMapper.selectByUsername(username);
// 4. 提取核心角色编码 【核心】
String roleCode = sysUser.getRoleCode();
// 5. 读取角色菜单+权限 【角色维度 非用户维度】
String menuKey = AuthConstant.ROLE_MENU_KEY + roleCode;
String permKey = AuthConstant.ROLE_PERMISSION_KEY + roleCode;
// 6. 读取Redis缓存 优先缓存 减少数据库
List<?> menuList = redisUtil.get(menuKey, List.class);
List<String> permList = redisUtil.get(permKey, List.class);
// 7. 缓存不存在 查询数据库并写入永久缓存
if (menuList == null) {
menuList = sysMenuMapper.getMenuByRoleCode(roleCode);
redisUtil.set(menuKey, menuList);
}
if (permList == null) {
permList = sysMenuMapper.getPermByRoleCode(roleCode);
redisUtil.set(permKey, permList);
}
// 8. 封装全局登录实体
LoginUser loginUser = new LoginUser();
loginUser.setUserId(sysUser.getId());
loginUser.setUsername(sysUser.getUsername());
loginUser.setRoleCode(roleCode);
return loginUser;
}
}
5. User 服务 登录 Controller(完整)
java
package com.user.controller;
import com.common.entity.Result;
import com.common.entity.LoginUser;
import com.common.util.JwtUtil;
import com.user.dto.LoginDTO;
import com.user.service.AuthService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 登录接口 完整版
*/
@RestController
@RequestMapping("/user")
public class UserController {
private final AuthService authService;
private final JwtUtil jwtUtil;
public UserController(AuthService authService, JwtUtil jwtUtil) {
this.authService = authService;
}
@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO dto) {
try {
// 1. Security认证+角色权限缓存
LoginUser loginUser = authService.login(dto.getUsername(), dto.getPassword());
// 2. 生成JWT令牌
String token = jwtUtil.generateToken(loginUser);
// 3. 结果返回
return Result.success(token);
} catch (Exception e) {
// 4. 全局异常捕获
return Result.error(e.getMessage());
}
}
}
6. Mapper 层 SQL
xml
<!-- 根据角色编码 查询菜单 -->
<select id="getMenuByRoleCode" resultType="com.user.entity.SysMenu">
SELECT DISTINCT m.id,m.menu_name,m.parent_id,m.path
FROM sys_role r
LEFT JOIN sys_role_menu rm ON r.id = rm.role_id
LEFT JOIN sys_menu m ON rm.menu_id = m.id
WHERE r.role_code = #{roleCode}
</select>
<!-- 根据角色编码 查询接口权限 -->
<select id="getPermByRoleCode" resultType="java.lang.String">
SELECT DISTINCT m.path
FROM sys_role r
LEFT JOIN sys_role_menu rm ON r.id = rm.role_id
LEFT JOIN sys_menu m ON rm.menu_id = m.id
WHERE r.role_code = #{roleCode}
</select>
3. 场景2:登录后前端获取动态角色菜单
1. 核心流程:
-
前端携带 JWT Token,请求门户服务获取菜单
-
网关校验 Token 合法性,解析用户角色编码、用户 ID,透传请求头
-
Portal 服务接收请求,通过 Feign 调用 User 服务
-
Feign 拦截器自动透传网关传递的角色编码请求头
-
User 服务读取 Redis「角色维度菜单缓存」,无需查询数据库
-
菜单数据经 Portal 服务原路返回前端
-
前端根据菜单数据,动态渲染侧边栏(不同角色显示不同菜单)
2. 全流程 极致分步拆解(10 步・无遗漏・带原理)
1. :前端发起菜单请求
-
请求方式:GET
-
请求头:Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...(登录返回的JWT Token)
-
无请求体
-
原理:登录后前端已存储 Token,所有后续请求必须携带 Token,证明身份
2. Gateway 网关接收请求
1.1 提取请求头中的Authorization Token,去除Bearer 前缀
1.2 调用公共模块JwtUtil,校验 Token 合法性(签名、过期时间)
1.3 校验通过:解析 JWT 载荷中的userId、roleCode、username
1.4 重构请求头,向下游 Portal 服务透传 3 个核心信息:
x-user-id:用户 ID(备用,非菜单查询用)
x-role-code:角色编码(核心,菜单查询唯一依据)
x-username:用户名(备用)
1.5 基于路由配置,负载转发请求至portal-service
1.6 核心原理:网关仅校验 Token、透传信息,不处理菜单业务、不查缓存 / 数据库
3. Portal 服务接收请求
-
Portal 服务接收网关透传的请求头,提取x-role-code(核心参数)
-
调用 Feign 客户端,发起对 User 服务的请求,获取角色菜单
-
核心原理:Portal 服务是门户层,仅做请求转发、数据透传,不存储菜单、不查库
4. Feign 拦截器自动透传请求头
-
公共模块FeignHeaderInterceptor触发(所有 Feign 调用自动生效)
-
读取当前请求(Portal 服务接收的请求)中的x-user-id、x-role-code请求头
-
自动将这两个请求头,添加到 Feign 调用的请求中,传递给 User 服务
-
核心原理:无需手动传递请求头,Feign 拦截器全局生效,避免服务间调用丢失用户信息
5. User 服务接收 Feign 请求
-
User 服务接收 Feign 调用,提取请求头中的x-role-code(角色编码)
-
拼接 Redis 缓存 Key:role:menu:{roleCode}(角色维度,与场景一缓存 Key 一致)
-
调用公共模块RedisUtil,读取 Redis 缓存中的菜单列表
-
核心原理:User 服务是唯一维护菜单缓存的服务,其他服务不直接操作菜单缓存
6. Redis 缓存读取判断
-
若 Redis 中存在该角色的菜单缓存(role:menu:admin),直接读取缓存数据,不查库
-
若缓存不存在(如角色权限刚修改、缓存被清空),则调用SysMenuMapper,根据角色编码查询数据库菜单列表,写入 Redis 永久缓存后,再返回数据
-
核心原理:菜单是低频修改数据,永久缓存减少数据库压力,同角色所有用户共享 1 份缓存
7. User 服务返回菜单数据
-
封装公共模块Result统一返回体,携带菜单列表数据
-
将结果返回给 Portal 服务的 Feign 客户端
8. Portal 服务转发结果
-
Portal 服务接收 User 服务返回的菜单数据,不做任何修改
-
封装统一Result格式,原路返回给前端
3. 全模块完整代码
1. 公共模块 Feign 拦截器
java
package com.common.interceptor;
import com.common.constant.AuthConstant;
import cn.hutool.core.util.StrUtil;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* 公共Feign全局拦截器 【完整版】
* 核心:服务间Feign调用时,自动透传网关传递的用户请求头
* 生效范围:Portal、Product、Inspect所有Feign调用
*/
@Component
public class FeignHeaderInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 1. 获取当前请求上下文(仅MVC服务有效,网关WebFlux无此上下文)
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return; // 非MVC请求(如定时任务),无需透传
}
HttpServletRequest request = attributes.getRequest();
// 2. 提取网关透传的核心请求头,自动透传给下游服务
String userId = request.getHeader(AuthConstant.HEADER_USER_ID);
String roleCode = request.getHeader(AuthConstant.HEADER_ROLE_CODE);
String username = request.getHeader(AuthConstant.HEADER_USERNAME);
// 3. 透传请求头(非空才透传,避免空值)
if (StrUtil.isNotBlank(userId)) {
template.header(AuthConstant.HEADER_USER_ID, userId);
}
if (StrUtil.isNotBlank(roleCode)) {
template.header(AuthConstant.HEADER_ROLE_CODE, roleCode);
}
if (StrUtil.isNotBlank(username)) {
template.header(AuthConstant.HEADER_USERNAME, username);
}
}
}
2. Portal 服务 Feign 客户端(UserFeign.java)
java
package com.portal.feign;
import com.common.constant.AuthConstant;
import com.common.entity.Result;
import com.user.entity.SysMenu;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
/**
* Portal服务 调用User服务的Feign客户端 【完整版】
* 核心:获取角色菜单
*/
@FeignClient(name = "user-service") // 对应User服务注册名
public interface UserFeign {
/**
* 调用User服务,获取角色对应的菜单(只读Redis缓存)
* @param roleCode 网关透传的角色编码(核心参数)
* @return 角色菜单列表
*/
@GetMapping("/user/getRoleMenu")
Result<List<SysMenu>> getRoleMenu(@RequestHeader(AuthConstant.HEADER_ROLE_CODE) String roleCode);
}
3. Portal 服务 菜单接口 Controller(PortalController.java)
java
package com.portal.controller;
import com.common.constant.AuthConstant;
import com.common.entity.Result;
import com.portal.feign.UserFeign;
import com.user.entity.SysMenu;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Portal门户服务 Controller 【完整版】
* 核心:接收前端菜单请求,Feign调用User服务获取菜单
*/
@RestController
@RequestMapping("/portal")
public class PortalController {
private final UserFeign userFeign;
// 构造注入(生产禁用@Autowired,推荐构造注入)
public PortalController(UserFeign userFeign) {
this.userFeign = userFeign;
}
/**
* 前端获取动态角色菜单
* @param roleCode 网关透传的角色编码(核心,用于查询角色菜单)
* @return 菜单列表,用于前端渲染侧边栏
*/
@GetMapping("/getMenu")
public Result<List<SysMenu>> getMenu(@RequestHeader(AuthConstant.HEADER_ROLE_CODE) String roleCode) {
try {
// 1. Feign调用User服务,获取角色菜单(自动透传请求头)
Result<List<SysMenu>> menuResult = userFeign.getRoleMenu(roleCode);
// 2. 直接返回结果(Portal不处理业务,仅透传)
return menuResult;
} catch (Exception e) {
// 3. 生产级异常处理:捕获Feign调用异常、业务异常
return Result.error("获取菜单失败:" + e.getMessage());
}
}
}
4. User 服务 菜单接口补充(UserController.java 新增方法)
java
package com.user.controller;
import com.common.constant.AuthConstant;
import com.common.entity.Result;
import com.common.util.RedisUtil;
import com.user.entity.SysMenu;
import com.user.mapper.SysMenuMapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* User服务 Controller 【新增菜单接口,完整版】
*/
@RestController
@RequestMapping("/user")
public class UserController {
// 省略场景一中已有的注入(AuthService、JwtUtil)
private final RedisUtil redisUtil;
private final SysMenuMapper sysMenuMapper;
public UserController(AuthService authService, JwtUtil jwtUtil, RedisUtil redisUtil, SysMenuMapper sysMenuMapper) {
this.authService = authService;
this.jwtUtil = jwtUtil;
this.redisUtil = redisUtil;
this.sysMenuMapper = sysMenuMapper;
}
// 省略场景一中已有的login方法...
/**
* 供Portal服务Feign调用,获取角色菜单(只读Redis,优先缓存)
* @param roleCode 角色编码(Feign透传,来自网关)
* @return 角色菜单列表
*/
@GetMapping("/getRoleMenu")
public Result<List<SysMenu>> getRoleMenu(@RequestHeader(AuthConstant.HEADER_ROLE_CODE) String roleCode) {
try {
// 1. 拼接角色菜单缓存Key(角色维度,生产标准)
String menuCacheKey = AuthConstant.ROLE_MENU_KEY + roleCode;
// 2. 优先读取Redis缓存
List<SysMenu> menuList = redisUtil.get(menuCacheKey, List.class);
// 3. 缓存不存在:查询数据库,写入缓存后返回
if (menuList == null || menuList.isEmpty()) {
menuList = sysMenuMapper.getMenuByRoleCode(roleCode);
redisUtil.set(menuCacheKey, menuList); // 永久缓存
}
// 4. 返回结果
return Result.success(menuList);
} catch (Exception e) {
return Result.error("获取角色菜单失败:" + e.getMessage());
}
}
}
4. 场景3:外部接口调用A服务,A服务通过feign调用B服务,A的ThreadLocal竟然拿到了token信息
1. 为什么会出现这个诡异现象?
1. 根本原因:Tomcat 线程复用 + ThreadLocal 未主动清空
-
Web 容器(Tomcat、Undertow)是线程池模型
处理完前端带 Token 的登录用户请求后,线程不会销毁,放回线程池复用。 -
你的用户上下文(UserContext)基于 普通 ThreadLocal 存储
请求结束时,没有执行 remove () 清理 -
下一次
.外部无 Token 的第三方接口请求,刚好命中同一个复用线程 .ThreadLocal 还残留上一个用户的 Token / 租户 / 用户 ID .导致:无登录请求 → 拿到旧用户上下文 → Feign 自动携带旧 Token 调 B 服务
2. 完整链路还原
-
用户登录请求(有 Token)
→ 进入服务 A → 拦截器解析 Token,存入 ThreadLocal<UserContext> → 接口执行完毕 ❌ 未清理 ThreadLocal,数据残留在当前工作线程 -
线程放回线程池
-
外部第三方无 Token 请求访问服务 A
→ Tomcat 分配到同一个旧线程 → UserContext.get () 读到上一个用户的残留 Token → Feign 拦截器自动把旧 Token 塞进请求头 → 服务 B 错误识别为登录用户
3. 高危影响
- 权限错乱:第三方匿名请求,拿着内部用户身份操作数据
- 数据越权:跨用户、跨租户数据泄露 / 篡改
- 线上严重 Bug:偶现、难以复现(因为依赖线程池随机复用)
- 排查极难:时好时坏,并发量越大越容易出现
4. 场景生活化理解
-
线程 = 杯子
-
线程池=柜子
-
ThreadLocal 数据 = 杯子里的水
-
请求 = 喝水的人
第一个人喝完水没倒水,杯子放回柜子;
第二个人来,随手拿了同一个杯子,直接喝到上一个人的剩水。
5. 结合前端发送请求到后端的真实场景,理解线程和 ThreadLocal
1. 通俗解释
1. 线程(后端处理前端请求的核心)
-
把后端服务器(Tomcat) 比作一家餐厅:
.前端发送 HTTP 请求 = 顾客点单
.线程 = 餐厅的服务员
.线程池 = 固定的服务员队伍(不用反复招聘)
-
处理流程
前端发请求 → 服务器从线程池分配一个空闲服务员(线程) → 这个服务员全程专属处理你的请求(Controller→Service→Dao)→ 返回结果给前端 → 服务员回到线程池待命。
-
关键:一个前端请求,对应一个独立线程,多请求并发就是多线程同时干活,互不干扰。
2. ThreadLocal(线程的私有小口袋)
问题:服务员处理订单时,需要记住用户 ID、登录信息,如果从 Controller 到 Service 到 Dao 都手动传参,代码会非常冗余。
ThreadLocal = 线程的【私有口袋】
- 每个线程有自己的独立口袋,别的线程绝对拿不到
- 处理请求时,把用户信息放进口袋,全链路随便取,不用传参
- 请求处理完,清空口袋,等待下一个任务
- 核心作用:多线程下数据隔离、无锁安全、全链路无参传递数据。
2. 真实业务场景(前端→后端请求必用)
这是 ThreadLocal 最经典的使用场景:
- 前端登录后,请求携带 Token 发送到后端
- 后端拦截器解析出用户 ID / 登录信息
- 存入 当前请求对应的线程 的 ThreadLocal
- Controller/Service/Dao 直接获取数据,不用在所有方法里传用户 ID
- 请求结束,清空 ThreadLocal
3. 核心铁律
- 线程隔离:A 线程的数据,B 线程访问不到(绝对安全)
- 非共享:不用加锁,性能极高
- 必须手动移除:线程池会复用线程,不删除会导致数据污染 / 内存泄漏!
4. 完整代码示例
我们模拟:3 个前端并发请求 → 后端多线程处理 → ThreadLocal 存储用户信息 → 全链路获取数据。
1. 核心工具类:ThreadLocal 存储用户
java
/**
* ThreadLocal 工具类:存放当前请求的用户ID
* 全链路可用,线程私有
*/
public class UserContext {
// 初始化 ThreadLocal:存储字符串类型的用户ID
private static final ThreadLocal<String> USER_LOCAL = new ThreadLocal<>();
// 存入用户ID
public static void set(String userId) {
USER_LOCAL.set(userId);
}
// 获取用户ID
public static String get() {
return USER_LOCAL.get();
}
// 清空数据:必须调用!!!
public static void remove() {
USER_LOCAL.remove();
}
}
2. 模拟后端三层架构(Controller→Service→Dao)
java
// 模拟 Dao 层:直接从 ThreadLocal 取用户
class UserDao {
public void query() {
String userId = UserContext.get();
System.out.println("Dao层查询用户:" + userId + " | 线程:" + Thread.currentThread().getName());
}
}
// 模拟 Service 层
class UserService {
private final UserDao dao = new UserDao();
public void doService() {
String userId = UserContext.get();
System.out.println("Service层处理请求:" + userId + " | 线程:" + Thread.currentThread().getName());
dao.query();
}
}
// 模拟 Controller:接收前端请求
class UserController {
private final UserService service = new UserService();
// 接收前端传入的用户ID
public void handleRequest(String userId) {
try {
// 1. 存入ThreadLocal
UserContext.set(userId);
System.out.println("Controller接收请求:" + userId + " | 线程:" + Thread.currentThread().getName());
// 2. 调用Service,无需传参!
service.doService();
} finally {
// 3. 必须清空!防止线程复用导致数据污染
UserContext.remove();
}
}
}
3. 模拟多线程(前端并发请求)
java
public class ThreadLocalDemo {
public static void main(String[] args) {
UserController controller = new UserController();
// 模拟3个前端并发请求,用3个独立线程处理
new Thread(() -> controller.handleRequest("用户1001"), "请求线程-1").start();
new Thread(() -> controller.handleRequest("用户1002"), "请求线程-2").start();
new Thread(() -> controller.handleRequest("用户1003"), "请求线程-3").start();
}
}
4. 运行结果(数据完全隔离)
java
Controller接收请求:用户1001 | 线程:请求线程-1
Controller接收请求:用户1002 | 线程:请求线程-2
Service层处理请求:用户1001 | 线程:请求线程-1
Service层处理请求:用户1002 | 线程:请求线程-2
Dao层查询用户:用户1001 | 线程:请求线程-1
Dao层查询用户:用户1002 | 线程:请求线程-2
Controller接收请求:用户1003 | 线程:请求线程-3
Service层处理请求:用户1003 | 线程:请求线程-3
Dao层查询用户:用户1003 | 线程:请求线程-3
5. 关键踩坑提醒
后端使用线程池,线程会被反复复用!
如果不调用 UserContext.remove():
- 线程 1 处理完用户 1001,没有清空数据
- 线程池复用线程 1 处理新请求(用户 1004)
- 新请求会读到旧的用户 1001 → 业务 bug(数据污染)
- ✅ 规范:必须在 finally 中调用 remove ()