feat(gateway): 新增网关服务及权限认证功能

- 新增网关服务模块 han-note-gateway,包含基础配置和启动类
- 实现全局过滤器 AddUserId2HeaderFilter,自动将用户ID添加到请求头(目前有问题)
- 配置 Sa-Token 权限认证,支持 JWT 格式的 Token 解析和鉴权
- 新增全局异常处理器 GlobalExceptionHandler,统一处理未登录和权限不足异常
- 实现 StpInterfaceImpl 接口,从 Redis 获取用户角色和权限信息- 配置 RedisTemplate 支持 JSON 序列化,用于存储用户角色和权限数据
- 在 auth 服务中增加登出接口,支持用户退出登录(待完成)
- 引入 Nacos 配置中心和注册中心依赖,支持配置动态刷新和服务发现
- 更新 Redis Key 构造方式,使用 userId 和 roleKey 替代 email 和 roleId
- 新增告警模块,支持邮件和短信告警方式的配置与切换
-优化角色权限同步逻辑,使用角色 Key 替代角色 ID 存储权限信息
- 添加 bootstrap.yml 配置文件,支持从 Nacos 读取配置
This commit is contained in:
Hanserwei
2025-10-02 21:46:05 +08:00
parent eb9f887ac3
commit 4c6a08438a
27 changed files with 668 additions and 26 deletions

View File

@@ -0,0 +1,11 @@
package com.hanserwei.hannote.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class HannoteGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(HannoteGatewayApplication.class, args);
}
}

View File

@@ -0,0 +1,50 @@
package com.hanserwei.hannote.gateway.auth;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import cn.dev33.satoken.reactor.filter.SaReactorFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 鉴权方法:每次访问进入
.setAuth(obj -> {
// 登录校验
SaRouter.match("/**") // 拦截所有路由
.notMatch("/auth/user/login") // 排除登录接口
.notMatch("/auth/verification/code/send") // 排除验证码发送接口
.check(r -> StpUtil.checkLogin()) // 校验是否登录
;
// 权限认证 -- 不同模块, 校验不同权限
SaRouter.match("/auth/user/logout", r -> StpUtil.checkPermission("app:note:publish"));
// SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
// SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
// SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
// SaRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
// 更多匹配 ... */
}).setError(e -> {
// return SaResult.error(e.getMessage());
// 手动抛出异常,抛给全局异常处理器
if (e instanceof NotLoginException) { // 未登录异常
throw new NotLoginException(e.getMessage(), null, null);
} else if (e instanceof NotPermissionException || e instanceof NotRoleException) { // 权限不足,或不具备角色,统一抛出权限不足异常
throw new NotPermissionException(e.getMessage());
} else { // 其他异常,则抛出一个运行时异常
throw new RuntimeException(e.getMessage());
}
});
}
}

View File

@@ -0,0 +1,85 @@
package com.hanserwei.hannote.gateway.auth;
import cn.dev33.satoken.stp.StpInterface;
import cn.hutool.core.collection.CollUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import com.hanserwei.hannote.gateway.constants.RedisKeyConstants;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 自定义权限验证接口扩展
*/
@Component
@Slf4j
public class StpInterfaceImpl implements StpInterface {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private ObjectMapper objectMapper;
@SneakyThrows
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 返回此 loginId 拥有的权限列表
// 构建 用户-角色 Redis Key
String userRolesKey = RedisKeyConstants.buildUserRoleKey(Long.valueOf(loginId.toString()));
// 根据用户 ID ,从 Redis 中获取该用户的角色集合
String useRolesValue = redisTemplate.opsForValue().get(userRolesKey);
if (StringUtils.isBlank(useRolesValue)) {
return null;
}
List<String> userRoleKeys = objectMapper.readValue(useRolesValue, new TypeReference<>() {
});
if (CollUtil.isNotEmpty(userRoleKeys)) {
// 构建角色权限 Redis Key
List<String> rolePermissionKeys = userRoleKeys.stream().map(RedisKeyConstants::buildRolePermissionsKey).toList();
// 根据角色权限 Redis Key 批量获取角色权限集合
List<String> rolePermissionsValues = redisTemplate.opsForValue().multiGet(rolePermissionKeys);
if (CollUtil.isNotEmpty(rolePermissionsValues)) {
List<String> permissions = Lists.newArrayList();
// 遍历所有角色的权限集合,统一添加到 permissions 集合中
rolePermissionsValues.forEach(jsonValue -> {
try {
// 将 JSON 字符串转换为 List<String> 权限集合
List<String> rolePermissions = objectMapper.readValue(jsonValue, new TypeReference<>() {
});
permissions.addAll(rolePermissions);
} catch (JsonProcessingException e) {
log.error("==> JSON 解析错误: ", e);
}
});
// 返回此用户所拥有的权限
return permissions;
}
}
return null;
}
@Override
@SneakyThrows
public List<String> getRoleList(Object loginId, String loginType) {
// 返回此 loginId 拥有的角色列表
// 构建 用户-角色 Redis Key
String userRolesKey = RedisKeyConstants.buildUserRoleKey(Long.valueOf(loginId.toString()));
// 根据用户 ID ,从 Redis 中获取该用户的角色集合
String useRolesValue = redisTemplate.opsForValue().get(userRolesKey);
if (StringUtils.isBlank(useRolesValue)) {
return null;
}
return objectMapper.readValue(useRolesValue, new TypeReference<>() {
});
}
}

View File

@@ -0,0 +1,31 @@
package com.hanserwei.hannote.gateway.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置 RedisTemplate 的连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 使用 StringRedisSerializer 来序列化和反序列化 redis 的 key 值,确保 key 是可读的字符串
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// 使用 Jackson2JsonRedisSerializer 来序列化和反序列化 redis 的 value 值, 确保存储的是 JSON 格式
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setValueSerializer(serializer);
redisTemplate.setHashValueSerializer(serializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}

View File

@@ -0,0 +1,33 @@
package com.hanserwei.hannote.gateway.constants;
public class RedisKeyConstants {
/**
* 用户对应角色集合 KEY 前缀
*/
private static final String USER_ROLES_KEY_PREFIX = "user:roles:";
/**
* 角色对应的权限集合 KEY 前缀
*/
private static final String ROLE_PERMISSIONS_KEY_PREFIX = "role:permissions:";
/**
* 构建角色对应的权限集合 KEY
* @param roleKey 角色Key
* @return 角色权限集合key
*/
public static String buildRolePermissionsKey(String roleKey) {
return ROLE_PERMISSIONS_KEY_PREFIX + roleKey;
}
/**
* 构建用户-角色 KEY
* @param userId 用户ID
* @return 用户角色key
*/
public static String buildUserRoleKey(Long userId) {
return USER_ROLES_KEY_PREFIX + userId;
}
}

View File

@@ -0,0 +1,24 @@
package com.hanserwei.hannote.gateway.enums;
import com.hanserwei.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 通用异常状态码 -----------
SYSTEM_ERROR("500", "系统繁忙,请稍后再试"),
UNAUTHORIZED("401", "权限不足"),
// ----------- 业务异常状态码 -----------
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMsg;
}

View File

@@ -0,0 +1,64 @@
package com.hanserwei.hannote.gateway.exception;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.gateway.enums.ResponseCodeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
@Resource
private ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
// 获取响应对象
ServerHttpResponse response = exchange.getResponse();
log.error("==> 全局异常捕获: ", ex);
// 响参
Response<?> result;
// 根据捕获的异常类型,设置不同的响应状态码和响应消息
if (ex instanceof NotLoginException) { // Sa-Token 异常
// 权限认证失败时,设置 401 状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 构建响应结果
result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(), "未携带Token令牌");
} else if (ex instanceof NotPermissionException) {
// 权限认证失败时,设置 401 状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 构建响应结果
result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(), ResponseCodeEnum.UNAUTHORIZED.getErrorMsg());
} else { // 其他异常,则统一提示 “系统繁忙” 错误
result = Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
}
// 设置响应头的内容类型为 application/json;charset=UTF-8表示响应体为 JSON 格式
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
// 设置 body 响应体
return response.writeWith(Mono.fromSupplier(() -> { // 使用 Mono.fromSupplier 创建响应体
DataBufferFactory bufferFactory = response.bufferFactory();
try {
// 使用 ObjectMapper 将 result 对象转换为 JSON 字节数组
return bufferFactory.wrap(objectMapper.writeValueAsBytes(result));
} catch (Exception e) {
// 如果转换过程中出现异常,则返回空字节数组
return bufferFactory.wrap(new byte[0]);
}
}));
}
}

View File

@@ -0,0 +1,39 @@
package com.hanserwei.hannote.gateway.filter;
import cn.dev33.satoken.stp.StpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Slf4j
@Component
public class AddUserId2HeaderFilter implements GlobalFilter {
/**
* 请求头中,用户 ID 的键
*/
private static final String HEADER_USER_ID = "userId";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("==================> TokenConvertFilter");
// 用户 ID
Long userId = null;
try {
// 获取当前登录用户的 ID
userId = StpUtil.getLoginIdAsLong();
} catch (Exception e) {
log.error("==> 用户未登录, 获取用户 ID 失败: ", e);
// 若没有登录,则直接放行
return chain.filter(exchange);
}
log.info("## 当前登录的用户 ID: {}", userId);
Long finalUserId = userId;
ServerWebExchange newExchange = exchange.mutate()
.request(builder -> builder.header(HEADER_USER_ID, String.valueOf(finalUserId))) // 将用户 ID 设置到请求头中
.build();
return chain.filter(newExchange);
}
}

View File

@@ -0,0 +1,43 @@
server:
port: 8000 # 指定启动端口
spring:
cloud:
gateway:
routes:
- id: auth
uri: lb://han-note-auth
predicates:
- Path=/auth/**
filters:
- StripPrefix=1
data:
redis:
database: 5 # Redis 数据库索引(默认为 0
host: 127.0.0.1 # Redis 服务器地址
port: 6379 # Redis 服务器连接端口
password: redis # Redis 服务器连接密码(默认为空)
timeout: 5s # 读超时时间
connect-timeout: 5s # 链接超时时间
lettuce:
pool:
max-active: 200 # 连接池最大连接数
max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
min-idle: 0 # 连接池中的最小空闲连接
max-idle: 10 # 连接池中的最大空闲连接
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: Authorization
# token前缀
token-prefix: Bearer
# token 有效期(单位:秒) 默认30天-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token
is-share: true
# token 风格默认可取值uuid、simple-uuid、random-32、random-64、random-128、tik
token-style: random-128
# 是否输出操作日志
is-log: true

View File

@@ -0,0 +1,12 @@
spring:
application:
name: han-note-gateway # 应用名称
profiles:
active: dev
cloud:
nacos:
discovery:
enabled: true # 启用服务发现
group: DEFAULT_GROUP # 所属组
namespace: han-note # 命名空间
server-addr: 127.0.0.1:8848 # NaCos 服务器地址