refactor(security): 重构安全配置并优化测试环境
- 移除旧的测试套件和UAT测试文件 - 更新密码编码器配置使用BCrypt strength=12 - 添加用户角色关联表和相关服务 - 优化前端日期显示格式 - 清理无用资源和配置文件 - 增强测试数据管理和清理功能
This commit is contained in:
+9
-1
@@ -1,18 +1,26 @@
|
||||
package cn.novalon.manage.app;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
|
||||
|
||||
@SpringBootApplication
|
||||
@SpringBootApplication(exclude = {ReactiveUserDetailsServiceAutoConfiguration.class})
|
||||
@ConfigurationPropertiesScan(basePackages = "cn.novalon.manage")
|
||||
@ComponentScan(basePackages = "cn.novalon.manage")
|
||||
@EnableR2dbcRepositories(basePackages = {"cn.novalon.manage.db.dao"})
|
||||
public class ManageApplication {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ManageApplication.class);
|
||||
|
||||
public static void main(String[] args) {
|
||||
logger.info("应用程序启动中...");
|
||||
logger.info("包扫描路径: cn.novalon.manage");
|
||||
SpringApplication.run(ManageApplication.class, args);
|
||||
logger.info("应用程序启动完成");
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package cn.novalon.manage.app.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.codec.json.Jackson2JsonDecoder;
|
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Jackson配置类
|
||||
*
|
||||
* 用于统一时间格式化配置
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
private static final String DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
@Bean
|
||||
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
|
||||
ObjectMapper objectMapper = builder.createXmlMapper(false).build();
|
||||
|
||||
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_TIME_FORMAT);
|
||||
|
||||
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter));
|
||||
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(formatter));
|
||||
|
||||
objectMapper.registerModule(javaTimeModule);
|
||||
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Jackson2JsonEncoder jackson2JsonEncoder(ObjectMapper objectMapper) {
|
||||
return new Jackson2JsonEncoder(objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Jackson2JsonDecoder jackson2JsonDecoder(ObjectMapper objectMapper) {
|
||||
return new Jackson2JsonDecoder(objectMapper);
|
||||
}
|
||||
}
|
||||
+2
@@ -65,6 +65,8 @@ public class SystemRouter {
|
||||
.POST("/api/users/restore", userHandler::restoreUsers)
|
||||
.GET("/api/users/check/username", userHandler::checkUsernameExists)
|
||||
.GET("/api/users/check/email", userHandler::checkEmailExists)
|
||||
.POST("/api/users/{id}/roles", userHandler::assignRoles)
|
||||
.GET("/api/users/{id}/roles", userHandler::getUserRoles)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ spring:
|
||||
max-idle-time: 30m
|
||||
max-life-time: 1h
|
||||
acquire-timeout: 5s
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
security:
|
||||
user:
|
||||
name: disabled
|
||||
password: disabled
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
-- 测试数据初始化脚本
|
||||
-- 用于E2E测试和UAT测试的测试数据生成
|
||||
|
||||
-- 1. 清理现有测试数据
|
||||
DELETE FROM sys_user_role WHERE user_id IN (SELECT id FROM sys_user WHERE username LIKE 'test_%' OR username = 'admin');
|
||||
DELETE FROM sys_role_menu WHERE role_id IN (SELECT id FROM sys_role WHERE role_key LIKE 'test_%' OR role_key = 'admin');
|
||||
DELETE FROM sys_login_log WHERE username IN ('admin', 'test_user', 'test_admin');
|
||||
DELETE FROM sys_user WHERE username IN ('admin', 'test_user', 'test_admin');
|
||||
DELETE FROM sys_role WHERE role_key LIKE 'test_%' OR role_key = 'admin';
|
||||
DELETE FROM sys_menu WHERE menu_name LIKE '测试%' OR menu_name = '系统管理';
|
||||
|
||||
-- 2. 插入测试角色
|
||||
INSERT INTO sys_role (role_name, role_key, role_sort, status, create_by, create_time, update_by, update_time, remark) VALUES
|
||||
('超级管理员', 'admin', 1, 1, 'system', NOW(), 'system', NOW(), '系统超级管理员,拥有所有权限'),
|
||||
('普通用户', 'user', 2, 1, 'system', NOW(), 'system', NOW(), '普通用户,拥有基本权限'),
|
||||
('测试管理员', 'test_admin', 3, 1, 'system', NOW(), 'system', NOW(), '测试用管理员角色'),
|
||||
('测试普通用户', 'test_user', 4, 1, 'system', NOW(), 'system', NOW(), '测试用普通用户角色');
|
||||
|
||||
-- 3. 插入测试菜单
|
||||
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) VALUES
|
||||
('系统管理', 0, 1, 'system', NULL, 'M', '0', '0', '', 'system', 'system', NOW(), 'system', NOW(), '系统管理目录'),
|
||||
('用户管理', 1, 1, 'user', 'system/user/index', 'C', '0', '0', 'system:user:list', 'user', 'system', NOW(), 'system', NOW(), '用户管理菜单'),
|
||||
('角色管理', 1, 2, 'role', 'system/role/index', 'C', '0', '0', 'system:role:list', 'role', 'system', NOW(), 'system', NOW(), '角色管理菜单'),
|
||||
('菜单管理', 1, 3, 'menu', 'system/menu/index', 'C', '0', '0', 'system:menu:list', 'menu', 'system', NOW(), 'system', NOW(), '菜单管理菜单'),
|
||||
('审计日志', 0, 2, 'audit', NULL, 'M', '0', '0', '', 'audit', 'system', NOW(), 'system', NOW(), '审计日志目录'),
|
||||
('登录日志', 5, 1, 'loginlog', 'audit/loginlog/index', 'C', '0', '0', 'audit:loginlog:list', 'loginlog', 'system', NOW(), 'system', NOW(), '登录日志菜单'),
|
||||
('系统监控', 0, 3, 'monitor', NULL, 'M', '0', '0', '', 'monitor', 'system', NOW(), 'system', NOW(), '系统监控目录'),
|
||||
('在线用户', 7, 1, 'online', 'monitor/online/index', 'C', '0', '0', 'monitor:online:list', 'online', 'system', NOW(), 'system', NOW(), '在线用户菜单');
|
||||
|
||||
-- 4. 插入测试用户
|
||||
INSERT INTO sys_user (username, password, email, phone, status, create_by, create_time, update_by, update_time, remark) VALUES
|
||||
('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', 'admin@novalon.com', '13800138000', 1, 'system', NOW(), 'system', NOW(), '系统管理员'),
|
||||
('test_user', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', 'testuser@novalon.com', '13800138001', 1, 'system', NOW(), 'system', NOW(), '测试普通用户'),
|
||||
('test_admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', 'testadmin@novalon.com', '13800138002', 1, 'system', NOW(), 'system', NOW(), '测试管理员');
|
||||
|
||||
-- 5. 分配用户角色关系
|
||||
INSERT INTO sys_user_role (user_id, role_id) VALUES
|
||||
((SELECT id FROM sys_user WHERE username = 'admin'), (SELECT id FROM sys_role WHERE role_key = 'admin')),
|
||||
((SELECT id FROM sys_user WHERE username = 'test_user'), (SELECT id FROM sys_role WHERE role_key = 'test_user')),
|
||||
((SELECT id FROM sys_user WHERE username = 'test_admin'), (SELECT id FROM sys_role WHERE role_key = 'test_admin'));
|
||||
|
||||
-- 6. 分配角色菜单关系
|
||||
-- 超级管理员拥有所有菜单权限
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT (SELECT id FROM sys_role WHERE role_key = 'admin'), id FROM sys_menu;
|
||||
|
||||
-- 普通用户只拥有用户查看权限
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
((SELECT id FROM sys_role WHERE role_key = 'user'), (SELECT id FROM sys_menu WHERE menu_name = '系统管理')),
|
||||
((SELECT id FROM sys_role WHERE role_key = 'user'), (SELECT id FROM sys_menu WHERE menu_name = '用户管理'));
|
||||
|
||||
-- 测试管理员拥有系统管理权限
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT (SELECT id FROM sys_role WHERE role_key = 'test_admin'), id FROM sys_menu WHERE menu_name IN ('系统管理', '用户管理', '角色管理', '菜单管理', '审计日志', '登录日志');
|
||||
|
||||
-- 测试普通用户拥有基本查看权限
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
((SELECT id FROM sys_role WHERE role_key = 'test_user'), (SELECT id FROM sys_menu WHERE menu_name = '系统管理')),
|
||||
((SELECT id FROM sys_role WHERE role_key = 'test_user'), (SELECT id FROM sys_menu WHERE menu_name = '用户管理'));
|
||||
|
||||
-- 7. 插入测试登录日志
|
||||
INSERT INTO sys_login_log (username, ipaddr, login_location, browser, os, status, msg, login_time, create_by, create_time) VALUES
|
||||
('admin', '127.0.0.1', '本地', 'Chrome 120.0', 'Mac OS X', 1, '登录成功', NOW(), 'system', NOW()),
|
||||
('test_user', '127.0.0.1', '本地', 'Firefox 121.0', 'Windows 10', 1, '登录成功', NOW(), 'system', NOW()),
|
||||
('test_admin', '127.0.0.1', '本地', 'Safari 17.0', 'Mac OS X', 1, '登录成功', NOW(), 'system', NOW()),
|
||||
('admin', '192.168.1.100', '内网', 'Chrome 119.0', 'Windows 11', 1, '登录成功', NOW() - INTERVAL '1 hour', 'system', NOW() - INTERVAL '1 hour'),
|
||||
('test_user', '192.168.1.101', '内网', 'Edge 120.0', 'Windows 10', 1, '登录成功', NOW() - INTERVAL '2 hours', 'system', NOW() - INTERVAL '2 hours');
|
||||
|
||||
-- 8. 验证测试数据
|
||||
SELECT '测试用户数据' as data_type, COUNT(*) as count FROM sys_user WHERE username IN ('admin', 'test_user', 'test_admin')
|
||||
UNION ALL
|
||||
SELECT '测试角色数据', COUNT(*) FROM sys_role WHERE role_key IN ('admin', 'user', 'test_admin', 'test_user')
|
||||
UNION ALL
|
||||
SELECT '测试菜单数据', COUNT(*) FROM sys_menu WHERE menu_name IN ('系统管理', '用户管理', '角色管理', '菜单管理', '审计日志', '登录日志', '系统监控', '在线用户')
|
||||
UNION ALL
|
||||
SELECT '用户角色关系', COUNT(*) FROM sys_user_role WHERE user_id IN (SELECT id FROM sys_user WHERE username IN ('admin', 'test_user', 'test_admin'))
|
||||
UNION ALL
|
||||
SELECT '角色菜单关系', COUNT(*) FROM sys_role_menu WHERE role_id IN (SELECT id FROM sys_role WHERE role_key IN ('admin', 'user', 'test_admin', 'test_user'))
|
||||
UNION ALL
|
||||
SELECT '登录日志数据', COUNT(*) FROM sys_login_log WHERE username IN ('admin', 'test_user', 'test_admin');
|
||||
|
||||
-- 提交事务
|
||||
COMMIT;
|
||||
@@ -41,6 +41,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.data</groupId>
|
||||
<artifactId>spring-data-r2dbc</artifactId>
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package cn.novalon.manage.db.config;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.springframework.boot.autoconfigure.flyway.FlywayProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
@Configuration
|
||||
public class FlywayConfig {
|
||||
|
||||
@Bean
|
||||
@Profile("!test")
|
||||
public Flyway flyway(DataSource dataSource, FlywayProperties flywayProperties) {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations(flywayProperties.getLocations().toArray(new String[0]))
|
||||
.baselineOnMigrate(true)
|
||||
.baselineVersion("0")
|
||||
.table("flyway_schema_history")
|
||||
.validateOnMigrate(true)
|
||||
.outOfOrder(false)
|
||||
.load();
|
||||
|
||||
flyway.migrate();
|
||||
return flyway;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package cn.novalon.manage.db.converter;
|
||||
|
||||
import cn.novalon.manage.db.entity.UserRoleEntity;
|
||||
import cn.novalon.manage.sys.core.domain.UserRole;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class UserRoleConverter {
|
||||
|
||||
public UserRole toDomain(UserRoleEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
UserRole domain = new UserRole();
|
||||
domain.setId(entity.getId());
|
||||
domain.setUserId(entity.getUserId());
|
||||
domain.setRoleId(entity.getRoleId());
|
||||
domain.setCreatedAt(entity.getCreatedAt());
|
||||
domain.setCreatedBy(entity.getCreatedBy());
|
||||
return domain;
|
||||
}
|
||||
|
||||
public UserRoleEntity toEntity(UserRole domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
UserRoleEntity entity = new UserRoleEntity();
|
||||
entity.setId(domain.getId());
|
||||
entity.setUserId(domain.getUserId());
|
||||
entity.setRoleId(domain.getRoleId());
|
||||
entity.setCreatedAt(domain.getCreatedAt());
|
||||
entity.setCreatedBy(domain.getCreatedBy());
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.novalon.manage.db.dao;
|
||||
|
||||
import cn.novalon.manage.db.entity.UserRoleEntity;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface UserRoleDao extends R2dbcRepository<UserRoleEntity, Long> {
|
||||
|
||||
Flux<UserRoleEntity> findByUserId(Long userId);
|
||||
|
||||
Flux<UserRoleEntity> findByUserId(Long userId, Sort sort);
|
||||
|
||||
Flux<UserRoleEntity> findByRoleId(Long roleId);
|
||||
|
||||
Flux<UserRoleEntity> findByRoleId(Long roleId, Sort sort);
|
||||
|
||||
Mono<Long> countByUserId(Long userId);
|
||||
|
||||
Mono<Long> countByRoleId(Long roleId);
|
||||
|
||||
Mono<Void> deleteByUserId(Long userId);
|
||||
|
||||
Mono<Void> deleteByRoleId(Long roleId);
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package cn.novalon.manage.db.entity;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Table("user_role")
|
||||
public class UserRoleEntity {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column("user_id")
|
||||
private Long userId;
|
||||
|
||||
@Column("role_id")
|
||||
private Long roleId;
|
||||
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column("created_by")
|
||||
private String createdBy;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public Long getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
public void setRoleId(Long roleId) {
|
||||
this.roleId = roleId;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getCreatedBy() {
|
||||
return createdBy;
|
||||
}
|
||||
|
||||
public void setCreatedBy(String createdBy) {
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package cn.novalon.manage.db.repository;
|
||||
|
||||
import cn.novalon.manage.db.converter.UserRoleConverter;
|
||||
import cn.novalon.manage.db.dao.UserRoleDao;
|
||||
import cn.novalon.manage.db.entity.UserRoleEntity;
|
||||
import cn.novalon.manage.sys.core.domain.UserRole;
|
||||
import cn.novalon.manage.sys.core.repository.IUserRoleRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class UserRoleRepository implements IUserRoleRepository {
|
||||
|
||||
private final UserRoleDao userRoleDao;
|
||||
private final UserRoleConverter userRoleConverter;
|
||||
|
||||
public UserRoleRepository(UserRoleDao userRoleDao, UserRoleConverter userRoleConverter) {
|
||||
this.userRoleDao = userRoleDao;
|
||||
this.userRoleConverter = userRoleConverter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<UserRole> save(UserRole userRole) {
|
||||
UserRoleEntity entity = userRoleConverter.toEntity(userRole);
|
||||
return userRoleDao.save(entity)
|
||||
.map(userRoleConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteById(Long id) {
|
||||
return userRoleDao.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteByUserId(Long userId) {
|
||||
return userRoleDao.deleteByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteByRoleId(Long roleId) {
|
||||
return userRoleDao.deleteByRoleId(roleId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<UserRole> findByUserId(Long userId) {
|
||||
return userRoleDao.findByUserId(userId, Sort.by(Sort.Direction.DESC, "created_at"))
|
||||
.map(userRoleConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<UserRole> findByRoleId(Long roleId) {
|
||||
return userRoleDao.findByRoleId(roleId, Sort.by(Sort.Direction.DESC, "created_at"))
|
||||
.map(userRoleConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Long> countByUserId(Long userId) {
|
||||
return userRoleDao.countByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Long> countByRoleId(Long roleId) {
|
||||
return userRoleDao.countByRoleId(roleId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<UserRole> findAll() {
|
||||
return userRoleDao.findAll()
|
||||
.map(userRoleConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<UserRole> findById(Long id) {
|
||||
return userRoleDao.findById(id)
|
||||
.map(userRoleConverter::toDomain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
spring:
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: true
|
||||
baseline-version: 0
|
||||
table: flyway_schema_history
|
||||
validate-on-migrate: true
|
||||
out-of-order: false
|
||||
+6
-4
@@ -9,7 +9,6 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
password VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
role_id BIGINT,
|
||||
status INTEGER DEFAULT 1,
|
||||
create_by VARCHAR(50),
|
||||
update_by VARCHAR(50),
|
||||
@@ -32,8 +31,8 @@ CREATE TABLE IF NOT EXISTS roles (
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- 菜单表
|
||||
CREATE TABLE IF NOT EXISTS menus (
|
||||
-- 菜单表(统一使用sys_menu表名)
|
||||
CREATE TABLE IF NOT EXISTS sys_menu (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
menu_name VARCHAR(50) NOT NULL,
|
||||
parent_id BIGINT DEFAULT 0,
|
||||
@@ -123,7 +122,7 @@ CREATE TABLE IF NOT EXISTS sys_login_log (
|
||||
login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 异常日志表(修复后的结构)
|
||||
-- 异常日志表
|
||||
CREATE TABLE IF NOT EXISTS sys_exception_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(50),
|
||||
@@ -234,3 +233,6 @@ COMMENT ON COLUMN sys_exception_log.exception_msg IS '异常消息';
|
||||
COMMENT ON COLUMN sys_exception_log.exception_stack IS '异常堆栈';
|
||||
COMMENT ON COLUMN sys_exception_log.ip IS 'IP地址';
|
||||
COMMENT ON COLUMN sys_exception_log.create_time IS '创建时间';
|
||||
|
||||
COMMENT ON TABLE sys_menu IS '系统菜单表';
|
||||
COMMENT ON TABLE sys_login_log IS '登录日志表';
|
||||
+3
-3
@@ -9,8 +9,8 @@ ON CONFLICT (role_key) DO NOTHING;
|
||||
|
||||
-- 插入初始管理员用户
|
||||
-- BCrypt哈希值对应明文密码: admin123
|
||||
INSERT INTO users (id, username, password, email, phone, role_id, status, create_by, update_by)
|
||||
VALUES (1, 'admin', '$2b$12$SFefXlGRFMA0fvxIufpWPuIAl0OPLgRDoCZPThCvjpiJGPYS8yNYy', 'admin@novalon.com', '13800138000', 1, 1, 'system', 'system')
|
||||
INSERT INTO users (id, username, password, email, phone, status, create_by, update_by)
|
||||
VALUES (1, 'admin', '$2b$12$SFefXlGRFMA0fvxIufpWPuIAl0OPLgRDoCZPThCvjpiJGPYS8yNYy', 'admin@novalon.com', '13800138000', 1, 'system', 'system')
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
password = EXCLUDED.password,
|
||||
status = EXCLUDED.status;
|
||||
@@ -56,4 +56,4 @@ SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users));
|
||||
SELECT setval('roles_id_seq', (SELECT COALESCE(MAX(id), 1) FROM roles));
|
||||
SELECT setval('sys_dict_type_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_dict_type));
|
||||
SELECT setval('sys_dict_data_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_dict_data));
|
||||
SELECT setval('sys_config_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_config));
|
||||
SELECT setval('sys_config_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_config));
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- 创建用户角色关联表(支持多对多关系)
|
||||
CREATE TABLE IF NOT EXISTS user_role (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
role_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
CONSTRAINT fk_user_role_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_role_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uk_user_role UNIQUE (user_id, role_id)
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_user_role_user_id ON user_role(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_role_role_id ON user_role(role_id);
|
||||
|
||||
-- 表注释
|
||||
COMMENT ON TABLE user_role IS '用户角色关联表';
|
||||
COMMENT ON COLUMN user_role.id IS '主键ID';
|
||||
COMMENT ON COLUMN user_role.user_id IS '用户ID';
|
||||
COMMENT ON COLUMN user_role.role_id IS '角色ID';
|
||||
COMMENT ON COLUMN user_role.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN user_role.created_by IS '创建人';
|
||||
+5
-6
@@ -1,11 +1,10 @@
|
||||
-- Novalon管理系统索引优化脚本
|
||||
-- 版本: V3
|
||||
-- 版本: V5
|
||||
-- 描述: 为表创建必要的索引以提升查询性能
|
||||
|
||||
-- 用户表索引
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role_id ON users(role_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at);
|
||||
|
||||
@@ -15,9 +14,9 @@ CREATE INDEX IF NOT EXISTS idx_roles_status ON roles(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_roles_deleted_at ON roles(deleted_at);
|
||||
|
||||
-- 菜单表索引
|
||||
CREATE INDEX IF NOT EXISTS idx_menus_parent_id ON menus(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_menus_status ON menus(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_menus_deleted_at ON menus(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_menu_parent_id ON sys_menu(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_menu_status ON sys_menu(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_menu_deleted_at ON sys_menu(deleted_at);
|
||||
|
||||
-- 字典类型表索引
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_dict_type_dict_type ON sys_dict_type(dict_type);
|
||||
@@ -76,4 +75,4 @@ CREATE INDEX IF NOT EXISTS idx_sys_file_deleted_at ON sys_file(deleted_at);
|
||||
-- OAuth2客户端表索引
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth2_client_client_id ON oauth2_client(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth2_client_enabled ON oauth2_client(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth2_client_deleted_at ON oauth2_client(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth2_client_deleted_at ON oauth2_client(deleted_at);
|
||||
+3
-8
@@ -1,9 +1,6 @@
|
||||
-- 系统菜单初始化数据
|
||||
-- @author 张翔
|
||||
-- @date 2026-03-24
|
||||
|
||||
-- 清空现有菜单数据
|
||||
DELETE FROM sys_menu WHERE id > 0;
|
||||
-- 版本: V6
|
||||
-- 描述: 初始化系统菜单数据
|
||||
|
||||
-- 一级菜单
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||
@@ -90,6 +87,4 @@ INSERT INTO sys_menu (id, parent_id, menu_name, order_num, menu_type, perms, com
|
||||
(322, 32, '任务新增', 2, 'F', 'monitor:job:add', NULL, 1, NOW(), NOW()),
|
||||
(323, 32, '任务修改', 3, 'F', 'monitor:job:edit', NULL, 1, NOW(), NOW()),
|
||||
(324, 32, '任务删除', 4, 'F', 'monitor:job:remove', NULL, 1, NOW(), NOW()),
|
||||
(325, 32, '任务执行', 5, 'F', 'monitor:job:execute', NULL, 1, NOW(), NOW());
|
||||
|
||||
COMMIT;
|
||||
(325, 32, '任务执行', 5, 'F', 'monitor:job:execute', NULL, 1, NOW(), NOW());
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package cn.novalon.manage.db.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FlywayMigrationScriptTest {
|
||||
|
||||
@Test
|
||||
void testMigrationScriptsExist() throws IOException {
|
||||
Path migrationDir = Paths.get("src/main/resources/db/migration");
|
||||
|
||||
assertTrue(Files.exists(migrationDir), "Migration directory should exist");
|
||||
|
||||
List<Path> sqlFiles = Files.list(migrationDir)
|
||||
.filter(p -> p.toString().endsWith(".sql"))
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
assertFalse(sqlFiles.isEmpty(), "Should have migration scripts");
|
||||
|
||||
System.out.println("Found migration scripts:");
|
||||
sqlFiles.forEach(p -> System.out.println(" - " + p.getFileName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMigrationScriptNaming() throws IOException {
|
||||
Path migrationDir = Paths.get("src/main/resources/db/migration");
|
||||
|
||||
List<Path> sqlFiles = Files.list(migrationDir)
|
||||
.filter(p -> p.toString().endsWith(".sql"))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (Path file : sqlFiles) {
|
||||
String filename = file.getFileName().toString();
|
||||
assertTrue(filename.matches("V\\d+__.*\\.sql"),
|
||||
"Migration script should follow Flyway naming convention: " + filename);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMigrationScriptContent() throws IOException {
|
||||
Path migrationDir = Paths.get("src/main/resources/db/migration");
|
||||
|
||||
List<Path> sqlFiles = Files.list(migrationDir)
|
||||
.filter(p -> p.toString().endsWith(".sql"))
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (Path file : sqlFiles) {
|
||||
String content = Files.readString(file);
|
||||
assertNotNull(content, "Migration script should have content: " + file.getFileName());
|
||||
assertFalse(content.trim().isEmpty(), "Migration script should not be empty: " + file.getFileName());
|
||||
|
||||
if (content.contains("CREATE TABLE")) {
|
||||
assertTrue(content.contains("IF NOT EXISTS"),
|
||||
"CREATE TABLE statements should use IF NOT EXISTS: " + file.getFileName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMigrationScriptVersionOrder() throws IOException {
|
||||
Path migrationDir = Paths.get("src/main/resources/db/migration");
|
||||
|
||||
List<Path> sqlFiles = Files.list(migrationDir)
|
||||
.filter(p -> p.toString().endsWith(".sql"))
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<Integer> versions = sqlFiles.stream()
|
||||
.map(p -> {
|
||||
String filename = p.getFileName().toString();
|
||||
String versionStr = filename.substring(1, filename.indexOf("__"));
|
||||
return Integer.parseInt(versionStr);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (int i = 1; i < versions.size(); i++) {
|
||||
assertTrue(versions.get(i) > versions.get(i - 1),
|
||||
"Migration versions should be in ascending order");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
spring:
|
||||
r2dbc:
|
||||
url: r2dbc:h2:mem:testdb;MODE=PostgreSQL
|
||||
username: sa
|
||||
password:
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: true
|
||||
baseline-version: 0
|
||||
table: flyway_schema_history
|
||||
validate-on-migrate: true
|
||||
out-of-order: false
|
||||
@@ -75,6 +75,16 @@
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
package cn.novalon.manage.gateway.audit;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 审计日志服务
|
||||
*
|
||||
* 文件定义:记录网关请求的审计日志
|
||||
* 涉及业务:安全审计、访问追踪、问题排查
|
||||
*
|
||||
* 审计内容:
|
||||
* 1. 请求信息:方法、路径、查询参数、请求头
|
||||
* 2. 响应信息:状态码、响应时间
|
||||
* 3. 安全事件:认证失败、授权失败、限流触发等
|
||||
* 4. 错误信息:异常类型、错误消息
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Service
|
||||
public class AuditLogService {
|
||||
|
||||
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT_LOG");
|
||||
private static final Logger logger = LoggerFactory.getLogger(AuditLogService.class);
|
||||
|
||||
private final Map<String, AuditEntry> auditEntries = new ConcurrentHashMap<>();
|
||||
|
||||
public void logRequest(ServerHttpRequest request, String userId) {
|
||||
String requestId = generateRequestId(request);
|
||||
|
||||
AuditEntry entry = new AuditEntry();
|
||||
entry.setRequestId(requestId);
|
||||
entry.setMethod(request.getMethod().name());
|
||||
entry.setPath(request.getPath().value());
|
||||
entry.setQuery(request.getURI().getQuery());
|
||||
entry.setUserId(userId);
|
||||
entry.setClientIp(getClientIp(request));
|
||||
entry.setStartTime(Instant.now());
|
||||
entry.setUserAgent(request.getHeaders().getFirst("User-Agent"));
|
||||
|
||||
auditEntries.put(requestId, entry);
|
||||
|
||||
auditLogger.info("[REQUEST] {} {} - User: {}, IP: {}, RequestId: {}",
|
||||
entry.getMethod(),
|
||||
entry.getPath(),
|
||||
entry.getUserId(),
|
||||
entry.getClientIp(),
|
||||
entry.getRequestId());
|
||||
}
|
||||
|
||||
public void logResponse(String requestId, int statusCode, long durationMs) {
|
||||
AuditEntry entry = auditEntries.get(requestId);
|
||||
|
||||
if (entry != null) {
|
||||
entry.setStatusCode(statusCode);
|
||||
entry.setEndTime(Instant.now());
|
||||
entry.setDurationMs(durationMs);
|
||||
|
||||
auditLogger.info("[RESPONSE] {} {} - Status: {}, Duration: {}ms, RequestId: {}",
|
||||
entry.getMethod(),
|
||||
entry.getPath(),
|
||||
entry.getStatusCode(),
|
||||
entry.getDurationMs(),
|
||||
entry.getRequestId());
|
||||
|
||||
auditEntries.remove(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
public void logSecurityEvent(String requestId, String eventType, String details) {
|
||||
AuditEntry entry = auditEntries.get(requestId);
|
||||
|
||||
if (entry != null) {
|
||||
auditLogger.warn("[SECURITY] {} - Event: {}, Details: {}, User: {}, IP: {}, RequestId: {}",
|
||||
entry.getPath(),
|
||||
eventType,
|
||||
details,
|
||||
entry.getUserId(),
|
||||
entry.getClientIp(),
|
||||
entry.getRequestId());
|
||||
} else {
|
||||
auditLogger.warn("[SECURITY] Event: {}, Details: {}, RequestId: {}",
|
||||
eventType,
|
||||
details,
|
||||
requestId);
|
||||
}
|
||||
}
|
||||
|
||||
public void logError(String requestId, String errorType, String errorMessage) {
|
||||
AuditEntry entry = auditEntries.get(requestId);
|
||||
|
||||
if (entry != null) {
|
||||
auditLogger.error("[ERROR] {} {} - Error: {}, Message: {}, User: {}, IP: {}, RequestId: {}",
|
||||
entry.getMethod(),
|
||||
entry.getPath(),
|
||||
errorType,
|
||||
errorMessage,
|
||||
entry.getUserId(),
|
||||
entry.getClientIp(),
|
||||
entry.getRequestId());
|
||||
} else {
|
||||
auditLogger.error("[ERROR] Error: {}, Message: {}, RequestId: {}",
|
||||
errorType,
|
||||
errorMessage,
|
||||
requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private String generateRequestId(ServerHttpRequest request) {
|
||||
String requestId = request.getHeaders().getFirst("X-Request-Id");
|
||||
|
||||
if (requestId == null || requestId.isEmpty()) {
|
||||
requestId = String.format("%s-%d-%s",
|
||||
request.getMethod().name().toLowerCase(),
|
||||
System.currentTimeMillis(),
|
||||
Integer.toHexString(request.hashCode()));
|
||||
}
|
||||
|
||||
return requestId;
|
||||
}
|
||||
|
||||
private String getClientIp(ServerHttpRequest request) {
|
||||
String ip = request.getHeaders().getFirst("X-Forwarded-For");
|
||||
|
||||
if (ip == null || ip.isEmpty()) {
|
||||
ip = request.getHeaders().getFirst("X-Real-IP");
|
||||
}
|
||||
|
||||
if (ip == null || ip.isEmpty()) {
|
||||
ip = request.getRemoteAddress() != null ?
|
||||
request.getRemoteAddress().getAddress().getHostAddress() :
|
||||
"unknown";
|
||||
}
|
||||
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
private static class AuditEntry {
|
||||
private String requestId;
|
||||
private String method;
|
||||
private String path;
|
||||
private String query;
|
||||
private String userId;
|
||||
private String clientIp;
|
||||
private String userAgent;
|
||||
private Instant startTime;
|
||||
private Instant endTime;
|
||||
private int statusCode;
|
||||
private long durationMs;
|
||||
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
public void setRequestId(String requestId) {
|
||||
this.requestId = requestId;
|
||||
}
|
||||
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
public void setMethod(String method) {
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public String getQuery() {
|
||||
return query;
|
||||
}
|
||||
|
||||
public void setQuery(String query) {
|
||||
this.query = query;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getClientIp() {
|
||||
return clientIp;
|
||||
}
|
||||
|
||||
public void setClientIp(String clientIp) {
|
||||
this.clientIp = clientIp;
|
||||
}
|
||||
|
||||
public String getUserAgent() {
|
||||
return userAgent;
|
||||
}
|
||||
|
||||
public void setUserAgent(String userAgent) {
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
public Instant getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(Instant startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public Instant getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(Instant endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public int getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
public void setStatusCode(int statusCode) {
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
|
||||
public long getDurationMs() {
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
public void setDurationMs(long durationMs) {
|
||||
this.durationMs = durationMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package cn.novalon.manage.gateway.cache;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 请求缓存服务
|
||||
*
|
||||
* 文件定义:实现网关请求的缓存机制
|
||||
* 涉及业务:响应缓存、缓存失效、缓存统计
|
||||
*
|
||||
* 核心功能:
|
||||
* 1. 请求响应缓存
|
||||
* 2. 缓存键生成
|
||||
* 3. 缓存失效管理
|
||||
* 4. 缓存统计
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Service
|
||||
public class RequestCacheService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RequestCacheService.class);
|
||||
|
||||
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||
private final Map<String, CacheStats> stats = new ConcurrentHashMap<>();
|
||||
|
||||
private boolean cacheEnabled = true;
|
||||
private Duration defaultTtl = Duration.ofMinutes(5);
|
||||
private int maxCacheSize = 10000;
|
||||
|
||||
public Mono<String> get(ServerHttpRequest request) {
|
||||
if (!cacheEnabled) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
String cacheKey = generateCacheKey(request);
|
||||
CacheEntry entry = cache.get(cacheKey);
|
||||
|
||||
if (entry == null) {
|
||||
recordMiss(cacheKey);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
if (isExpired(entry)) {
|
||||
cache.remove(cacheKey);
|
||||
recordMiss(cacheKey);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
recordHit(cacheKey);
|
||||
logger.debug("Cache hit for key: {}", cacheKey);
|
||||
return Mono.just(entry.getValue());
|
||||
}
|
||||
|
||||
public void put(ServerHttpRequest request, String response) {
|
||||
if (!cacheEnabled || response == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = generateCacheKey(request);
|
||||
|
||||
if (cache.size() >= maxCacheSize) {
|
||||
evictOldestEntries();
|
||||
}
|
||||
|
||||
CacheEntry entry = new CacheEntry(
|
||||
response,
|
||||
System.currentTimeMillis(),
|
||||
defaultTtl.toMillis()
|
||||
);
|
||||
|
||||
cache.put(cacheKey, entry);
|
||||
logger.debug("Cached response for key: {}", cacheKey);
|
||||
}
|
||||
|
||||
public void evict(ServerHttpRequest request) {
|
||||
String cacheKey = generateCacheKey(request);
|
||||
cache.remove(cacheKey);
|
||||
logger.debug("Evicted cache for key: {}", cacheKey);
|
||||
}
|
||||
|
||||
public void evictByPattern(String pattern) {
|
||||
cache.keySet().removeIf(key -> key.matches(pattern));
|
||||
logger.info("Evicted cache entries matching pattern: {}", pattern);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
int size = cache.size();
|
||||
cache.clear();
|
||||
stats.clear();
|
||||
logger.info("Cleared all cache entries. Removed {} entries", size);
|
||||
}
|
||||
|
||||
private String generateCacheKey(ServerHttpRequest request) {
|
||||
String method = request.getMethod().name();
|
||||
String path = request.getPath().value();
|
||||
String query = request.getURI().getQuery();
|
||||
|
||||
StringBuilder keyBuilder = new StringBuilder();
|
||||
keyBuilder.append(method).append(":").append(path);
|
||||
|
||||
if (query != null && !query.isEmpty()) {
|
||||
keyBuilder.append("?").append(query);
|
||||
}
|
||||
|
||||
return keyBuilder.toString();
|
||||
}
|
||||
|
||||
private boolean isExpired(CacheEntry entry) {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
return (currentTime - entry.getCreatedAt()) > entry.getTtl();
|
||||
}
|
||||
|
||||
private void evictOldestEntries() {
|
||||
int entriesToRemove = maxCacheSize / 10;
|
||||
|
||||
cache.entrySet().stream()
|
||||
.sorted((e1, e2) ->
|
||||
Long.compare(e1.getValue().getCreatedAt(),
|
||||
e2.getValue().getCreatedAt()))
|
||||
.limit(entriesToRemove)
|
||||
.map(Map.Entry::getKey)
|
||||
.forEach(cache::remove);
|
||||
|
||||
logger.info("Evicted {} oldest cache entries", entriesToRemove);
|
||||
}
|
||||
|
||||
private void recordHit(String cacheKey) {
|
||||
stats.compute(cacheKey, (key, stat) -> {
|
||||
if (stat == null) {
|
||||
stat = new CacheStats();
|
||||
}
|
||||
stat.incrementHits();
|
||||
return stat;
|
||||
});
|
||||
}
|
||||
|
||||
private void recordMiss(String cacheKey) {
|
||||
stats.compute(cacheKey, (key, stat) -> {
|
||||
if (stat == null) {
|
||||
stat = new CacheStats();
|
||||
}
|
||||
stat.incrementMisses();
|
||||
return stat;
|
||||
});
|
||||
}
|
||||
|
||||
public int getCacheSize() {
|
||||
return cache.size();
|
||||
}
|
||||
|
||||
public long getHitCount() {
|
||||
return stats.values().stream()
|
||||
.mapToLong(CacheStats::getHits)
|
||||
.sum();
|
||||
}
|
||||
|
||||
public long getMissCount() {
|
||||
return stats.values().stream()
|
||||
.mapToLong(CacheStats::getMisses)
|
||||
.sum();
|
||||
}
|
||||
|
||||
public double getHitRate() {
|
||||
long hits = getHitCount();
|
||||
long misses = getMissCount();
|
||||
long total = hits + misses;
|
||||
|
||||
if (total == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (double) hits / total;
|
||||
}
|
||||
|
||||
public void setCacheEnabled(boolean enabled) {
|
||||
this.cacheEnabled = enabled;
|
||||
logger.info("Cache enabled: {}", enabled);
|
||||
}
|
||||
|
||||
public void setDefaultTtl(Duration ttl) {
|
||||
this.defaultTtl = ttl;
|
||||
logger.info("Default TTL set to: {}", ttl);
|
||||
}
|
||||
|
||||
public void setMaxCacheSize(int maxSize) {
|
||||
this.maxCacheSize = maxSize;
|
||||
logger.info("Max cache size set to: {}", maxSize);
|
||||
}
|
||||
|
||||
private static class CacheEntry {
|
||||
private final String value;
|
||||
private final long createdAt;
|
||||
private final long ttl;
|
||||
|
||||
public CacheEntry(String value, long createdAt, long ttl) {
|
||||
this.value = value;
|
||||
this.createdAt = createdAt;
|
||||
this.ttl = ttl;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public long getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public long getTtl() {
|
||||
return ttl;
|
||||
}
|
||||
}
|
||||
|
||||
private static class CacheStats {
|
||||
private long hits = 0;
|
||||
private long misses = 0;
|
||||
|
||||
public void incrementHits() {
|
||||
hits++;
|
||||
}
|
||||
|
||||
public void incrementMisses() {
|
||||
misses++;
|
||||
}
|
||||
|
||||
public long getHits() {
|
||||
return hits;
|
||||
}
|
||||
|
||||
public long getMisses() {
|
||||
return misses;
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package cn.novalon.manage.gateway.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.context.config.annotation.RefreshScope;
|
||||
import org.springframework.cloud.context.refresh.ContextRefresher;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 配置热更新服务
|
||||
*
|
||||
* 文件定义:实现配置的动态更新和管理
|
||||
* 涉及业务:配置刷新、配置监听、配置版本管理
|
||||
*
|
||||
* 核心功能:
|
||||
* 1. 配置热更新
|
||||
* 2. 配置版本管理
|
||||
* 3. 配置变更监听
|
||||
* 4. 配置回滚
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Service
|
||||
@RefreshScope
|
||||
public class ConfigRefreshService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ConfigRefreshService.class);
|
||||
|
||||
private final ContextRefresher contextRefresher;
|
||||
private final Environment environment;
|
||||
private final ConfigurableEnvironment configurableEnvironment;
|
||||
|
||||
private final Map<String, String> configHistory = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> configUpdateTime = new ConcurrentHashMap<>();
|
||||
private final Map<String, ConfigChangeListener> listeners = new ConcurrentHashMap<>();
|
||||
|
||||
private long currentVersion = System.currentTimeMillis();
|
||||
|
||||
public ConfigRefreshService(
|
||||
ContextRefresher contextRefresher,
|
||||
Environment environment,
|
||||
ConfigurableEnvironment configurableEnvironment) {
|
||||
this.contextRefresher = contextRefresher;
|
||||
this.environment = environment;
|
||||
this.configurableEnvironment = configurableEnvironment;
|
||||
|
||||
logger.info("ConfigRefreshService initialized");
|
||||
}
|
||||
|
||||
public void refreshConfig() {
|
||||
logger.info("Refreshing configuration");
|
||||
|
||||
try {
|
||||
Set<String> refreshedKeys = contextRefresher.refresh();
|
||||
|
||||
if (!refreshedKeys.isEmpty()) {
|
||||
currentVersion = System.currentTimeMillis();
|
||||
logger.info("Configuration refreshed. Version: {}, Updated keys: {}",
|
||||
currentVersion, refreshedKeys);
|
||||
|
||||
notifyListeners(refreshedKeys);
|
||||
} else {
|
||||
logger.info("No configuration changes detected");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to refresh configuration", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateConfig(String key, String value) {
|
||||
if (key == null || key.isEmpty()) {
|
||||
logger.warn("Config key is null or empty");
|
||||
return;
|
||||
}
|
||||
|
||||
String oldValue = environment.getProperty(key);
|
||||
|
||||
logger.info("Updating config - Key: {}, Old Value: {}, New Value: {}",
|
||||
key, oldValue, value);
|
||||
|
||||
configHistory.put(key, oldValue);
|
||||
configUpdateTime.put(key, System.currentTimeMillis());
|
||||
|
||||
try {
|
||||
Map<String, Object> newConfig = new HashMap<>();
|
||||
newConfig.put(key, value);
|
||||
|
||||
MapPropertySource propertySource = new MapPropertySource(
|
||||
"dynamicConfig",
|
||||
newConfig);
|
||||
|
||||
configurableEnvironment.getPropertySources()
|
||||
.addFirst(propertySource);
|
||||
|
||||
logger.info("Config updated successfully: {}", key);
|
||||
|
||||
notifyListeners(Set.of(key));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to update config: {}", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
public void batchUpdateConfig(Map<String, String> configs) {
|
||||
if (configs == null || configs.isEmpty()) {
|
||||
logger.warn("No configs to update");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Batch updating {} configs", configs.size());
|
||||
|
||||
configs.forEach((key, value) -> {
|
||||
String oldValue = environment.getProperty(key);
|
||||
configHistory.put(key, oldValue);
|
||||
configUpdateTime.put(key, System.currentTimeMillis());
|
||||
});
|
||||
|
||||
try {
|
||||
Map<String, Object> newConfigs = new HashMap<>(configs);
|
||||
|
||||
MapPropertySource propertySource = new MapPropertySource(
|
||||
"batchDynamicConfig",
|
||||
newConfigs);
|
||||
|
||||
configurableEnvironment.getPropertySources()
|
||||
.addFirst(propertySource);
|
||||
|
||||
logger.info("Batch config update completed");
|
||||
|
||||
notifyListeners(configs.keySet());
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to batch update configs", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getConfig(String key) {
|
||||
if (key == null || key.isEmpty()) {
|
||||
logger.warn("Config key is null or empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
return environment.getProperty(key);
|
||||
}
|
||||
|
||||
public String getConfigWithDefault(String key, String defaultValue) {
|
||||
return environment.getProperty(key, defaultValue);
|
||||
}
|
||||
|
||||
public void rollbackConfig(String key) {
|
||||
if (key == null || key.isEmpty()) {
|
||||
logger.warn("Config key is null or empty");
|
||||
return;
|
||||
}
|
||||
|
||||
String oldValue = configHistory.get(key);
|
||||
|
||||
if (oldValue != null) {
|
||||
logger.info("Rolling back config: {} to value: {}", key, oldValue);
|
||||
updateConfig(key, oldValue);
|
||||
} else {
|
||||
logger.warn("No history found for config: {}", key);
|
||||
}
|
||||
}
|
||||
|
||||
public void registerListener(String key, ConfigChangeListener listener) {
|
||||
if (key == null || key.isEmpty() || listener == null) {
|
||||
logger.warn("Invalid listener registration");
|
||||
return;
|
||||
}
|
||||
|
||||
listeners.put(key, listener);
|
||||
logger.info("Registered listener for config: {}", key);
|
||||
}
|
||||
|
||||
public void unregisterListener(String key) {
|
||||
if (key != null && !key.isEmpty()) {
|
||||
listeners.remove(key);
|
||||
logger.info("Unregistered listener for config: {}", key);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyListeners(Set<String> changedKeys) {
|
||||
changedKeys.forEach(key -> {
|
||||
ConfigChangeListener listener = listeners.get(key);
|
||||
if (listener != null) {
|
||||
try {
|
||||
String newValue = environment.getProperty(key);
|
||||
listener.onConfigChange(key, newValue);
|
||||
logger.debug("Notified listener for config: {}", key);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to notify listener for config: {}", key, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public long getCurrentVersion() {
|
||||
return currentVersion;
|
||||
}
|
||||
|
||||
public Map<String, String> getConfigHistory() {
|
||||
return new HashMap<>(configHistory);
|
||||
}
|
||||
|
||||
public Map<String, Long> getConfigUpdateTime() {
|
||||
return new HashMap<>(configUpdateTime);
|
||||
}
|
||||
|
||||
public void clearHistory() {
|
||||
logger.info("Clearing config history");
|
||||
configHistory.clear();
|
||||
configUpdateTime.clear();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ConfigChangeListener {
|
||||
void onConfigChange(String key, String newValue);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package cn.novalon.manage.gateway.config;
|
||||
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||
import io.netty.handler.timeout.WriteTimeoutHandler;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
import reactor.netty.resources.ConnectionProvider;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 连接池配置
|
||||
*
|
||||
* 文件定义:配置HTTP连接池参数
|
||||
* 涉及业务:连接池管理、超时控制、性能优化
|
||||
*
|
||||
* 配置内容:
|
||||
* 1. 连接池大小
|
||||
* 2. 连接超时
|
||||
* 3. 读写超时
|
||||
* 4. 连接空闲时间
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Configuration
|
||||
public class ConnectionPoolConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ConnectionPoolConfig.class);
|
||||
|
||||
@Bean
|
||||
public HttpClient httpClient() {
|
||||
ConnectionProvider connectionProvider = ConnectionProvider.builder("gateway-pool")
|
||||
.maxConnections(500)
|
||||
.maxIdleTime(Duration.ofSeconds(20))
|
||||
.maxLifeTime(Duration.ofSeconds(60))
|
||||
.pendingAcquireTimeout(Duration.ofSeconds(45))
|
||||
.pendingAcquireMaxCount(1000)
|
||||
.evictInBackground(Duration.ofSeconds(120))
|
||||
.build();
|
||||
|
||||
HttpClient httpClient = HttpClient.create(connectionProvider)
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
|
||||
.option(ChannelOption.SO_KEEPALIVE, true)
|
||||
.option(ChannelOption.TCP_NODELAY, true)
|
||||
.doOnConnected(conn -> {
|
||||
conn.addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS));
|
||||
conn.addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS));
|
||||
})
|
||||
.responseTimeout(Duration.ofSeconds(10));
|
||||
|
||||
logger.info("HTTP client configured with connection pool");
|
||||
logger.info("Max connections: 500");
|
||||
logger.info("Connect timeout: 5000ms");
|
||||
logger.info("Read/Write timeout: 10s");
|
||||
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ReactorClientHttpConnector reactorClientHttpConnector(HttpClient httpClient) {
|
||||
return new ReactorClientHttpConnector(httpClient);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package cn.novalon.manage.gateway.config;
|
||||
|
||||
import cn.novalon.manage.gateway.service.impl.JwtKeyServiceImpl;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class JwtKeyManagementConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtKeyManagementConfig.class);
|
||||
|
||||
@Autowired
|
||||
private JwtKeyServiceImpl jwtKeyService;
|
||||
|
||||
@Bean
|
||||
public JwtKeyServiceImpl jwtKeyService() {
|
||||
JwtKeyServiceImpl service = new JwtKeyServiceImpl();
|
||||
service.initializeKeys();
|
||||
logger.info("JWT key management service initialized");
|
||||
return service;
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 24 * 60 * 60 * 1000, initialDelay = 60 * 1000)
|
||||
public void scheduledKeyRotationCheck() {
|
||||
try {
|
||||
logger.debug("Checking JWT key rotation status");
|
||||
|
||||
if (jwtKeyService.shouldRotateKey()) {
|
||||
logger.info("JWT key rotation triggered");
|
||||
jwtKeyService.rotateKey();
|
||||
} else {
|
||||
logger.debug("JWT key rotation not needed at this time");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error during scheduled JWT key rotation check", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-12
@@ -3,11 +3,15 @@ package cn.novalon.manage.gateway.config;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 限流配置类
|
||||
@@ -16,34 +20,100 @@ import java.time.Duration;
|
||||
* 涉及业务:API访问频率控制,防止滥用和DDoS攻击
|
||||
* 算法:使用Resilience4j的RateLimiter实现令牌桶算法
|
||||
*
|
||||
* 支持多种限流策略:
|
||||
* 1. 全局限流:对所有API请求进行统一限流
|
||||
* 2. IP限流:基于客户端IP地址进行限流
|
||||
* 3. 用户限流:基于用户ID进行限流
|
||||
* 4. API路径限流:基于API路径进行差异化限流
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-13
|
||||
*/
|
||||
@Configuration
|
||||
public class RateLimitConfig {
|
||||
|
||||
@Value("${rate.limit.limit-for-period:100}")
|
||||
private int limitForPeriod;
|
||||
private static final Logger logger = LoggerFactory.getLogger(RateLimitConfig.class);
|
||||
|
||||
@Value("${rate.limit.limit-refresh-period:1s}")
|
||||
private Duration limitRefreshPeriod;
|
||||
@Value("${rate.limit.global.limit-for-period:1000}")
|
||||
private int globalLimitForPeriod;
|
||||
|
||||
@Value("${rate.limit.timeout-duration:0}")
|
||||
private Duration timeoutDuration;
|
||||
@Value("${rate.limit.global.limit-refresh-period:1s}")
|
||||
private Duration globalLimitRefreshPeriod;
|
||||
|
||||
@Value("${rate.limit.global.timeout-duration:0}")
|
||||
private Duration globalTimeoutDuration;
|
||||
|
||||
@Value("${rate.limit.ip.limit-for-period:100}")
|
||||
private int ipLimitForPeriod;
|
||||
|
||||
@Value("${rate.limit.ip.limit-refresh-period:1s}")
|
||||
private Duration ipLimitRefreshPeriod;
|
||||
|
||||
@Value("${rate.limit.ip.timeout-duration:0}")
|
||||
private Duration ipTimeoutDuration;
|
||||
|
||||
@Value("${rate.limit.user.limit-for-period:200}")
|
||||
private int userLimitForPeriod;
|
||||
|
||||
@Value("${rate.limit.user.limit-refresh-period:1s}")
|
||||
private Duration userLimitRefreshPeriod;
|
||||
|
||||
@Value("${rate.limit.user.timeout-duration:0}")
|
||||
private Duration userTimeoutDuration;
|
||||
|
||||
@Value("${rate.limit.enabled:true}")
|
||||
private boolean rateLimitEnabled;
|
||||
|
||||
@Bean
|
||||
public RateLimiterRegistry rateLimiterRegistry() {
|
||||
RateLimiterConfig config = RateLimiterConfig.custom()
|
||||
Map<String, RateLimiterConfig> configs = new HashMap<>();
|
||||
|
||||
configs.put("globalRateLimiter", createRateLimiterConfig(
|
||||
globalLimitForPeriod, globalLimitRefreshPeriod, globalTimeoutDuration));
|
||||
|
||||
configs.put("ipRateLimiter", createRateLimiterConfig(
|
||||
ipLimitForPeriod, ipLimitRefreshPeriod, ipTimeoutDuration));
|
||||
|
||||
configs.put("userRateLimiter", createRateLimiterConfig(
|
||||
userLimitForPeriod, userLimitRefreshPeriod, userTimeoutDuration));
|
||||
|
||||
RateLimiterRegistry registry = RateLimiterRegistry.of(configs);
|
||||
|
||||
logger.info("Rate limiter registry initialized with {} configurations", configs.size());
|
||||
logger.info("Global limit: {}/{}", globalLimitForPeriod, globalLimitRefreshPeriod);
|
||||
logger.info("IP limit: {}/{}", ipLimitForPeriod, ipLimitRefreshPeriod);
|
||||
logger.info("User limit: {}/{}", userLimitForPeriod, userLimitRefreshPeriod);
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RateLimiter globalRateLimiter(RateLimiterRegistry registry) {
|
||||
return registry.rateLimiter("globalRateLimiter");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RateLimiter ipRateLimiter(RateLimiterRegistry registry) {
|
||||
return registry.rateLimiter("ipRateLimiter");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RateLimiter userRateLimiter(RateLimiterRegistry registry) {
|
||||
return registry.rateLimiter("userRateLimiter");
|
||||
}
|
||||
|
||||
private RateLimiterConfig createRateLimiterConfig(
|
||||
int limitForPeriod,
|
||||
Duration limitRefreshPeriod,
|
||||
Duration timeoutDuration) {
|
||||
return RateLimiterConfig.custom()
|
||||
.limitForPeriod(limitForPeriod)
|
||||
.limitRefreshPeriod(limitRefreshPeriod)
|
||||
.timeoutDuration(timeoutDuration)
|
||||
.build();
|
||||
|
||||
return RateLimiterRegistry.of(config);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RateLimiter apiRateLimiter(RateLimiterRegistry registry) {
|
||||
return registry.rateLimiter("apiRateLimiter");
|
||||
public boolean isRateLimitEnabled() {
|
||||
return rateLimitEnabled;
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package cn.novalon.manage.gateway.config;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryConfig;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiter;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiterRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Resilience4j配置类
|
||||
*
|
||||
* 文件定义:配置断路器、重试、超时等容错机制
|
||||
* 涉及业务:网关容错增强,提高系统稳定性和可用性
|
||||
*
|
||||
* 配置内容:
|
||||
* 1. CircuitBreaker:断路器模式,防止级联故障
|
||||
* 2. Retry:重试机制,处理临时故障
|
||||
* 3. TimeLimiter:超时控制,防止长时间阻塞
|
||||
* 4. Fallback:降级策略,提供备用响应
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Configuration
|
||||
public class ResilienceConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ResilienceConfig.class);
|
||||
|
||||
@Value("${resilience.circuit-breaker.enabled:true}")
|
||||
private boolean circuitBreakerEnabled;
|
||||
|
||||
@Value("${resilience.circuit-breaker.failure-rate-threshold:50}")
|
||||
private float failureRateThreshold;
|
||||
|
||||
@Value("${resilience.circuit-breaker.slow-call-rate-threshold:100}")
|
||||
private float slowCallRateThreshold;
|
||||
|
||||
@Value("${resilience.circuit-breaker.slow-call-duration-threshold:2s}")
|
||||
private Duration slowCallDurationThreshold;
|
||||
|
||||
@Value("${resilience.circuit-breaker.permitted-number-of-calls-in-half-open-state:10}")
|
||||
private int permittedNumberOfCallsInHalfOpenState;
|
||||
|
||||
@Value("${resilience.circuit-breaker.sliding-window-type:COUNT_BASED}")
|
||||
private String slidingWindowType;
|
||||
|
||||
@Value("${resilience.circuit-breaker.sliding-window-size:100}")
|
||||
private int slidingWindowSize;
|
||||
|
||||
@Value("${resilience.circuit-breaker.minimum-number-of-calls:10}")
|
||||
private int minimumNumberOfCalls;
|
||||
|
||||
@Value("${resilience.circuit-breaker.wait-duration-in-open-state:10s}")
|
||||
private Duration waitDurationInOpenState;
|
||||
|
||||
@Value("${resilience.retry.enabled:true}")
|
||||
private boolean retryEnabled;
|
||||
|
||||
@Value("${resilience.retry.max-attempts:3}")
|
||||
private int retryMaxAttempts;
|
||||
|
||||
@Value("${resilience.retry.wait-duration:500ms}")
|
||||
private Duration retryWaitDuration;
|
||||
|
||||
@Value("${resilience.retry.exponential-backoff-multiplier:2}")
|
||||
private double exponentialBackoffMultiplier;
|
||||
|
||||
@Value("${resilience.timeout.enabled:true}")
|
||||
private boolean timeoutEnabled;
|
||||
|
||||
@Value("${resilience.timeout.duration:3s}")
|
||||
private Duration timeoutDuration;
|
||||
|
||||
@Bean
|
||||
public CircuitBreakerRegistry circuitBreakerRegistry() {
|
||||
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
|
||||
.failureRateThreshold(failureRateThreshold)
|
||||
.slowCallRateThreshold(slowCallRateThreshold)
|
||||
.slowCallDurationThreshold(slowCallDurationThreshold)
|
||||
.permittedNumberOfCallsInHalfOpenState(permittedNumberOfCallsInHalfOpenState)
|
||||
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.valueOf(slidingWindowType))
|
||||
.slidingWindowSize(slidingWindowSize)
|
||||
.minimumNumberOfCalls(minimumNumberOfCalls)
|
||||
.waitDurationInOpenState(waitDurationInOpenState)
|
||||
.recordExceptions(Exception.class)
|
||||
.ignoreExceptions(IllegalArgumentException.class)
|
||||
.build();
|
||||
|
||||
Map<String, CircuitBreakerConfig> configs = new HashMap<>();
|
||||
configs.put("default", config);
|
||||
|
||||
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(configs);
|
||||
|
||||
logger.info("CircuitBreaker registry initialized with {} configurations", configs.size());
|
||||
logger.info("Failure rate threshold: {}%", failureRateThreshold);
|
||||
logger.info("Slow call duration threshold: {}", slowCallDurationThreshold);
|
||||
logger.info("Sliding window size: {}", slidingWindowSize);
|
||||
logger.info("Wait duration in open state: {}", waitDurationInOpenState);
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CircuitBreaker gatewayCircuitBreaker(CircuitBreakerRegistry registry) {
|
||||
CircuitBreaker circuitBreaker = registry.circuitBreaker("gateway", "default");
|
||||
|
||||
circuitBreaker.getEventPublisher()
|
||||
.onStateTransition(event ->
|
||||
logger.warn("CircuitBreaker state transition: {} -> {} for {}",
|
||||
event.getStateTransition().getFromState(),
|
||||
event.getStateTransition().getToState(),
|
||||
event.getCircuitBreakerName()))
|
||||
.onError(event ->
|
||||
logger.error("CircuitBreaker error: {} - {}",
|
||||
event.getCircuitBreakerName(),
|
||||
event.getThrowable().getMessage()))
|
||||
.onSuccess(event ->
|
||||
logger.debug("CircuitBreaker success: {} - Duration: {}ms",
|
||||
event.getCircuitBreakerName(),
|
||||
event.getElapsedDuration().toMillis()));
|
||||
|
||||
logger.info("Gateway CircuitBreaker created: {}", circuitBreaker.getName());
|
||||
return circuitBreaker;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RetryRegistry retryRegistry() {
|
||||
RetryConfig config = RetryConfig.custom()
|
||||
.maxAttempts(retryMaxAttempts)
|
||||
.waitDuration(retryWaitDuration)
|
||||
.retryExceptions(Exception.class)
|
||||
.ignoreExceptions(IllegalArgumentException.class)
|
||||
.build();
|
||||
|
||||
Map<String, RetryConfig> configs = new HashMap<>();
|
||||
configs.put("default", config);
|
||||
|
||||
RetryRegistry registry = RetryRegistry.of(configs);
|
||||
|
||||
logger.info("Retry registry initialized with {} configurations", configs.size());
|
||||
logger.info("Max attempts: {}", retryMaxAttempts);
|
||||
logger.info("Wait duration: {}", retryWaitDuration);
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Retry gatewayRetry(RetryRegistry registry) {
|
||||
Retry retry = registry.retry("gateway", "default");
|
||||
|
||||
retry.getEventPublisher()
|
||||
.onRetry(event ->
|
||||
logger.warn("Retry attempt {} of {} for {}",
|
||||
event.getNumberOfRetryAttempts(),
|
||||
retryMaxAttempts,
|
||||
event.getName()))
|
||||
.onError(event ->
|
||||
logger.error("Retry failed after {} attempts for {}",
|
||||
event.getNumberOfRetryAttempts(),
|
||||
event.getName()))
|
||||
.onSuccess(event ->
|
||||
logger.debug("Retry succeeded after {} attempts for {}",
|
||||
event.getNumberOfRetryAttempts(),
|
||||
event.getName()));
|
||||
|
||||
logger.info("Gateway Retry created: {}", retry.getName());
|
||||
return retry;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TimeLimiterRegistry timeLimiterRegistry() {
|
||||
TimeLimiterConfig config = TimeLimiterConfig.custom()
|
||||
.timeoutDuration(timeoutDuration)
|
||||
.cancelRunningFuture(true)
|
||||
.build();
|
||||
|
||||
Map<String, TimeLimiterConfig> configs = new HashMap<>();
|
||||
configs.put("default", config);
|
||||
|
||||
TimeLimiterRegistry registry = TimeLimiterRegistry.of(configs);
|
||||
|
||||
logger.info("TimeLimiter registry initialized with {} configurations", configs.size());
|
||||
logger.info("Timeout duration: {}", timeoutDuration);
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TimeLimiter gatewayTimeLimiter(TimeLimiterRegistry registry) {
|
||||
TimeLimiter timeLimiter = registry.timeLimiter("gateway", "default");
|
||||
|
||||
timeLimiter.getEventPublisher()
|
||||
.onTimeout(event ->
|
||||
logger.warn("Timeout occurred for {}",
|
||||
event.getTimeLimiterName()))
|
||||
.onSuccess(event ->
|
||||
logger.debug("TimeLimiter success for {}",
|
||||
event.getTimeLimiterName()));
|
||||
|
||||
logger.info("Gateway TimeLimiter created: {}", timeLimiter.getName());
|
||||
return timeLimiter;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package cn.novalon.manage.gateway.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
@Bean
|
||||
public WebClient.Builder webClientBuilder() {
|
||||
return WebClient.builder();
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package cn.novalon.manage.gateway.discovery;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.client.DefaultServiceInstance;
|
||||
import org.springframework.cloud.client.ServiceInstance;
|
||||
import org.springframework.cloud.client.discovery.DiscoveryClient;
|
||||
import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 服务发现服务
|
||||
*
|
||||
* 文件定义:实现服务实例的发现、监控和管理
|
||||
* 涉及业务:服务实例查询、健康检查、服务状态监控
|
||||
*
|
||||
* 核心功能:
|
||||
* 1. 服务实例查询
|
||||
* 2. 服务健康检查
|
||||
* 3. 服务状态监控
|
||||
* 4. 服务实例缓存
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Service
|
||||
public class ServiceDiscoveryService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ServiceDiscoveryService.class);
|
||||
|
||||
private final ReactiveDiscoveryClient reactiveDiscoveryClient;
|
||||
private final DiscoveryClient discoveryClient;
|
||||
|
||||
private final Map<String, List<ServiceInstance>> serviceCache = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> lastUpdateTime = new ConcurrentHashMap<>();
|
||||
|
||||
private static final long CACHE_TTL_MS = 30000;
|
||||
|
||||
public ServiceDiscoveryService(
|
||||
ReactiveDiscoveryClient reactiveDiscoveryClient,
|
||||
DiscoveryClient discoveryClient) {
|
||||
this.reactiveDiscoveryClient = reactiveDiscoveryClient;
|
||||
this.discoveryClient = discoveryClient;
|
||||
|
||||
initializeServiceCache();
|
||||
}
|
||||
|
||||
private void initializeServiceCache() {
|
||||
logger.info("Initializing service cache");
|
||||
|
||||
discoveryClient.getServices().forEach(serviceId -> {
|
||||
List<ServiceInstance> instances = discoveryClient.getInstances(serviceId);
|
||||
if (!instances.isEmpty()) {
|
||||
serviceCache.put(serviceId, instances);
|
||||
lastUpdateTime.put(serviceId, System.currentTimeMillis());
|
||||
logger.debug("Cached {} instances for service: {}", instances.size(), serviceId);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info("Service cache initialized with {} services", serviceCache.size());
|
||||
}
|
||||
|
||||
public Flux<ServiceInstance> getInstances(String serviceId) {
|
||||
if (serviceId == null || serviceId.isEmpty()) {
|
||||
logger.warn("Service ID is null or empty");
|
||||
return Flux.empty();
|
||||
}
|
||||
|
||||
if (isCacheValid(serviceId)) {
|
||||
List<ServiceInstance> cachedInstances = serviceCache.get(serviceId);
|
||||
if (cachedInstances != null && !cachedInstances.isEmpty()) {
|
||||
logger.debug("Returning {} cached instances for service: {}",
|
||||
cachedInstances.size(), serviceId);
|
||||
return Flux.fromIterable(cachedInstances);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Fetching instances for service: {}", serviceId);
|
||||
|
||||
return reactiveDiscoveryClient.getInstances(serviceId)
|
||||
.doOnNext(instance -> logger.debug("Found instance: {}:{} for service: {}",
|
||||
instance.getHost(), instance.getPort(), serviceId))
|
||||
.collectList()
|
||||
.doOnNext(instances -> {
|
||||
serviceCache.put(serviceId, instances);
|
||||
lastUpdateTime.put(serviceId, System.currentTimeMillis());
|
||||
logger.info("Updated cache with {} instances for service: {}",
|
||||
instances.size(), serviceId);
|
||||
})
|
||||
.flatMapMany(Flux::fromIterable);
|
||||
}
|
||||
|
||||
public Flux<String> getServices() {
|
||||
return reactiveDiscoveryClient.getServices()
|
||||
.doOnNext(serviceId -> logger.debug("Found service: {}", serviceId));
|
||||
}
|
||||
|
||||
public Mono<ServiceInstance> getFirstInstance(String serviceId) {
|
||||
return getInstances(serviceId)
|
||||
.next()
|
||||
.doOnNext(instance -> logger.debug("Returning first instance for service: {}", serviceId));
|
||||
}
|
||||
|
||||
public Mono<ServiceInstance> getInstanceByHost(String serviceId, String host) {
|
||||
if (host == null || host.isEmpty()) {
|
||||
logger.warn("Host is null or empty");
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
return getInstances(serviceId)
|
||||
.filter(instance -> host.equals(instance.getHost()))
|
||||
.next()
|
||||
.doOnNext(instance -> logger.debug("Found instance with host {} for service: {}",
|
||||
host, serviceId));
|
||||
}
|
||||
|
||||
public Mono<ServiceInstance> getInstanceByPort(String serviceId, int port) {
|
||||
if (port <= 0) {
|
||||
logger.warn("Invalid port: {}", port);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
return getInstances(serviceId)
|
||||
.filter(instance -> port == instance.getPort())
|
||||
.next()
|
||||
.doOnNext(instance -> logger.debug("Found instance with port {} for service: {}",
|
||||
port, serviceId));
|
||||
}
|
||||
|
||||
public Mono<Map<String, List<ServiceInstance>>> getAllServicesWithInstances() {
|
||||
return getServices()
|
||||
.flatMap(serviceId ->
|
||||
getInstances(serviceId)
|
||||
.collectList()
|
||||
.map(instances -> Map.entry(serviceId, instances))
|
||||
)
|
||||
.collectMap(Map.Entry::getKey, Map.Entry::getValue);
|
||||
}
|
||||
|
||||
public Mono<Integer> getInstanceCount(String serviceId) {
|
||||
return getInstances(serviceId)
|
||||
.count()
|
||||
.map(Long::intValue);
|
||||
}
|
||||
|
||||
public Mono<Boolean> isServiceAvailable(String serviceId) {
|
||||
return getInstanceCount(serviceId)
|
||||
.map(count -> count > 0)
|
||||
.doOnNext(available -> logger.debug("Service {} availability: {}",
|
||||
serviceId, available));
|
||||
}
|
||||
|
||||
public void refreshServiceCache(String serviceId) {
|
||||
if (serviceId == null || serviceId.isEmpty()) {
|
||||
logger.warn("Service ID is null or empty");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Refreshing cache for service: {}", serviceId);
|
||||
|
||||
reactiveDiscoveryClient.getInstances(serviceId)
|
||||
.collectList()
|
||||
.subscribe(
|
||||
instances -> {
|
||||
serviceCache.put(serviceId, instances);
|
||||
lastUpdateTime.put(serviceId, System.currentTimeMillis());
|
||||
logger.info("Refreshed cache with {} instances for service: {}",
|
||||
instances.size(), serviceId);
|
||||
},
|
||||
error -> logger.error("Failed to refresh cache for service: {}",
|
||||
serviceId, error)
|
||||
);
|
||||
}
|
||||
|
||||
public void refreshAllServices() {
|
||||
logger.info("Refreshing cache for all services");
|
||||
|
||||
reactiveDiscoveryClient.getServices()
|
||||
.flatMap(serviceId ->
|
||||
reactiveDiscoveryClient.getInstances(serviceId)
|
||||
.collectList()
|
||||
.doOnNext(instances -> {
|
||||
serviceCache.put(serviceId, instances);
|
||||
lastUpdateTime.put(serviceId, System.currentTimeMillis());
|
||||
})
|
||||
)
|
||||
.subscribe(
|
||||
instances -> logger.debug("Refreshed {} instances", instances.size()),
|
||||
error -> logger.error("Failed to refresh all services", error),
|
||||
() -> logger.info("All services cache refreshed")
|
||||
);
|
||||
}
|
||||
|
||||
public void clearServiceCache() {
|
||||
logger.info("Clearing service cache");
|
||||
serviceCache.clear();
|
||||
lastUpdateTime.clear();
|
||||
initializeServiceCache();
|
||||
}
|
||||
|
||||
private boolean isCacheValid(String serviceId) {
|
||||
Long lastUpdate = lastUpdateTime.get(serviceId);
|
||||
if (lastUpdate == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long currentTime = System.currentTimeMillis();
|
||||
return (currentTime - lastUpdate) < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
public int getCachedServiceCount() {
|
||||
return serviceCache.size();
|
||||
}
|
||||
|
||||
public int getCachedInstanceCount(String serviceId) {
|
||||
List<ServiceInstance> instances = serviceCache.get(serviceId);
|
||||
return instances != null ? instances.size() : 0;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
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;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 响应压缩过滤器
|
||||
*
|
||||
* 文件定义:实现网关响应的压缩功能
|
||||
* 涉及业务:响应压缩、性能优化、带宽节省
|
||||
*
|
||||
* 核心功能:
|
||||
* 1. 检测客户端支持的压缩算法
|
||||
* 2. 对响应进行压缩
|
||||
* 3. 设置压缩相关响应头
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Component
|
||||
public class CompressionFilter implements GlobalFilter, Ordered {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CompressionFilter.class);
|
||||
|
||||
private static final String ACCEPT_ENCODING = "Accept-Encoding";
|
||||
private static final String CONTENT_ENCODING = "Content-Encoding";
|
||||
private static final String GZIP = "gzip";
|
||||
private static final String DEFLATE = "deflate";
|
||||
private static final String VARY = "Vary";
|
||||
|
||||
private static final List<String> COMPRESSIBLE_TYPES = Arrays.asList(
|
||||
"text/html",
|
||||
"text/xml",
|
||||
"text/plain",
|
||||
"text/css",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"application/json",
|
||||
"application/xml"
|
||||
);
|
||||
|
||||
private static final int MIN_COMPRESS_SIZE = 1024;
|
||||
|
||||
private boolean compressionEnabled = true;
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
|
||||
if (!compressionEnabled || !shouldCompress(request)) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
String acceptEncoding = request.getHeaders().getFirst(ACCEPT_ENCODING);
|
||||
|
||||
if (acceptEncoding == null || acceptEncoding.isEmpty()) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
String compressionType = determineCompressionType(acceptEncoding);
|
||||
|
||||
if (compressionType == null) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
logger.debug("Applying {} compression for request: {}",
|
||||
compressionType, request.getPath());
|
||||
|
||||
ServerHttpResponse response = exchange.getResponse();
|
||||
|
||||
response.getHeaders().set(CONTENT_ENCODING, compressionType);
|
||||
response.getHeaders().add(VARY, ACCEPT_ENCODING);
|
||||
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
private boolean shouldCompress(ServerHttpRequest request) {
|
||||
if (request.getMethod() == HttpMethod.OPTIONS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String contentType = request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
|
||||
|
||||
if (contentType != null) {
|
||||
return COMPRESSIBLE_TYPES.stream()
|
||||
.anyMatch(type -> contentType.contains(type));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private String determineCompressionType(String acceptEncoding) {
|
||||
if (acceptEncoding.contains(GZIP)) {
|
||||
return GZIP;
|
||||
}
|
||||
|
||||
if (acceptEncoding.contains(DEFLATE)) {
|
||||
return DEFLATE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void setCompressionEnabled(boolean enabled) {
|
||||
this.compressionEnabled = enabled;
|
||||
logger.info("Compression enabled: {}", enabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.LOWEST_PRECEDENCE - 100;
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import cn.novalon.manage.gateway.config.RateLimitConfig;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
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;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 网关限流过滤器
|
||||
*
|
||||
* 文件定义:实现多维度限流策略的全局过滤器
|
||||
* 涉及业务:API访问频率控制,防止滥用和DDoS攻击
|
||||
* 算法:使用Resilience4j的RateLimiter实现令牌桶算法
|
||||
*
|
||||
* 限流维度:
|
||||
* 1. 全局限流:保护系统整体稳定性
|
||||
* 2. IP限流:防止单个IP过度访问
|
||||
* 3. 用户限流:防止单个用户过度访问
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Component
|
||||
public class RateLimitFilter implements GlobalFilter, Ordered {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RateLimitFilter.class);
|
||||
private static final String USER_ID_HEADER = "X-User-Id";
|
||||
private static final String RATE_LIMIT_REMAINING_HEADER = "X-RateLimit-Remaining";
|
||||
private static final String RATE_LIMIT_LIMIT_HEADER = "X-RateLimit-Limit";
|
||||
private static final String RETRY_AFTER_HEADER = "Retry-After";
|
||||
|
||||
private final RateLimiter globalRateLimiter;
|
||||
private final RateLimiter ipRateLimiter;
|
||||
private final RateLimiter userRateLimiter;
|
||||
private final RateLimitConfig rateLimitConfig;
|
||||
|
||||
private final ConcurrentHashMap<String, RateLimiter> ipRateLimiterMap = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, RateLimiter> userRateLimiterMap = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicInteger totalRequests = new AtomicInteger(0);
|
||||
private final AtomicInteger blockedRequests = new AtomicInteger(0);
|
||||
|
||||
public RateLimitFilter(
|
||||
RateLimiter globalRateLimiter,
|
||||
RateLimiter ipRateLimiter,
|
||||
RateLimiter userRateLimiter,
|
||||
RateLimitConfig rateLimitConfig) {
|
||||
this.globalRateLimiter = globalRateLimiter;
|
||||
this.ipRateLimiter = ipRateLimiter;
|
||||
this.userRateLimiter = userRateLimiter;
|
||||
this.rateLimitConfig = rateLimitConfig;
|
||||
|
||||
logger.info("RateLimitFilter initialized with enabled: {}", rateLimitConfig.isRateLimitEnabled());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
if (!rateLimitConfig.isRateLimitEnabled()) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
totalRequests.incrementAndGet();
|
||||
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
String clientIp = getClientIp(request);
|
||||
String userId = getUserId(request);
|
||||
String requestPath = request.getPath().value();
|
||||
|
||||
logger.debug("Processing request - IP: {}, UserId: {}, Path: {}", clientIp, userId, requestPath);
|
||||
|
||||
return checkGlobalRateLimit(exchange, chain, clientIp, userId);
|
||||
}
|
||||
|
||||
private Mono<Void> checkGlobalRateLimit(
|
||||
ServerWebExchange exchange,
|
||||
GatewayFilterChain chain,
|
||||
String clientIp,
|
||||
String userId) {
|
||||
return Mono.fromCallable(() -> globalRateLimiter.acquirePermission())
|
||||
.flatMap(permitted -> {
|
||||
if (permitted) {
|
||||
return checkIpRateLimit(exchange, chain, clientIp, userId);
|
||||
} else {
|
||||
return handleRateLimitExceeded(exchange, "Global", clientIp, userId);
|
||||
}
|
||||
})
|
||||
.onErrorResume(RequestNotPermitted.class,
|
||||
e -> handleRateLimitExceeded(exchange, "Global", clientIp, userId));
|
||||
}
|
||||
|
||||
private Mono<Void> checkIpRateLimit(
|
||||
ServerWebExchange exchange,
|
||||
GatewayFilterChain chain,
|
||||
String clientIp,
|
||||
String userId) {
|
||||
RateLimiter ipLimiter = ipRateLimiterMap.computeIfAbsent(
|
||||
clientIp,
|
||||
k -> createIpRateLimiter(clientIp));
|
||||
|
||||
return Mono.fromCallable(() -> ipLimiter.acquirePermission())
|
||||
.flatMap(permitted -> {
|
||||
if (permitted) {
|
||||
if (userId != null && !userId.isEmpty()) {
|
||||
return checkUserRateLimit(exchange, chain, userId);
|
||||
} else {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
} else {
|
||||
return handleRateLimitExceeded(exchange, "IP", clientIp, userId);
|
||||
}
|
||||
})
|
||||
.onErrorResume(RequestNotPermitted.class,
|
||||
e -> handleRateLimitExceeded(exchange, "IP", clientIp, userId));
|
||||
}
|
||||
|
||||
private Mono<Void> checkUserRateLimit(
|
||||
ServerWebExchange exchange,
|
||||
GatewayFilterChain chain,
|
||||
String userId) {
|
||||
RateLimiter userLimiter = userRateLimiterMap.computeIfAbsent(
|
||||
userId,
|
||||
k -> createUserRateLimiter(userId));
|
||||
|
||||
return Mono.fromCallable(() -> userLimiter.acquirePermission())
|
||||
.flatMap(permitted -> {
|
||||
if (permitted) {
|
||||
return chain.filter(exchange);
|
||||
} else {
|
||||
return handleRateLimitExceeded(exchange, "User", null, userId);
|
||||
}
|
||||
})
|
||||
.onErrorResume(RequestNotPermitted.class, e -> handleRateLimitExceeded(exchange, "User", null, userId));
|
||||
}
|
||||
|
||||
private Mono<Void> handleRateLimitExceeded(
|
||||
ServerWebExchange exchange,
|
||||
String limitType,
|
||||
String clientIp,
|
||||
String userId) {
|
||||
blockedRequests.incrementAndGet();
|
||||
|
||||
logger.warn("Rate limit exceeded - Type: {}, IP: {}, UserId: {}", limitType, clientIp, userId);
|
||||
|
||||
ServerHttpResponse response = exchange.getResponse();
|
||||
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
|
||||
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
headers.add(RATE_LIMIT_LIMIT_HEADER, "0");
|
||||
headers.add(RATE_LIMIT_REMAINING_HEADER, "0");
|
||||
headers.add(RETRY_AFTER_HEADER, "1");
|
||||
headers.add("X-RateLimit-Type", limitType);
|
||||
|
||||
String errorMessage = String.format(
|
||||
"{\"error\":\"Rate limit exceeded\",\"type\":\"%s\",\"message\":\"Too many requests. Please try again later.\"}",
|
||||
limitType);
|
||||
|
||||
return response.writeWith(Mono.just(response.bufferFactory().wrap(errorMessage.getBytes())));
|
||||
}
|
||||
|
||||
private String getClientIp(ServerHttpRequest request) {
|
||||
String ip = request.getHeaders().getFirst("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeaders().getFirst("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddress() != null
|
||||
? request.getRemoteAddress().getAddress().getHostAddress()
|
||||
: "unknown";
|
||||
}
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
private String getUserId(ServerHttpRequest request) {
|
||||
return request.getHeaders().getFirst(USER_ID_HEADER);
|
||||
}
|
||||
|
||||
private RateLimiter createIpRateLimiter(String ip) {
|
||||
logger.debug("Creating rate limiter for IP: {}", ip);
|
||||
return RateLimiter.of("ip-" + ip, ipRateLimiter.getRateLimiterConfig());
|
||||
}
|
||||
|
||||
private RateLimiter createUserRateLimiter(String userId) {
|
||||
logger.debug("Creating rate limiter for user: {}", userId);
|
||||
return RateLimiter.of("user-" + userId, userRateLimiter.getRateLimiterConfig());
|
||||
}
|
||||
|
||||
public int getTotalRequests() {
|
||||
return totalRequests.get();
|
||||
}
|
||||
|
||||
public int getBlockedRequests() {
|
||||
return blockedRequests.get();
|
||||
}
|
||||
|
||||
public void resetCounters() {
|
||||
totalRequests.set(0);
|
||||
blockedRequests.set(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.HIGHEST_PRECEDENCE + 100;
|
||||
}
|
||||
}
|
||||
+24
-7
@@ -1,5 +1,8 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import cn.novalon.manage.gateway.service.PermissionService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilter;
|
||||
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -9,8 +12,13 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public class RbacAuthorizationFilter extends AbstractGatewayFilterFactory<RbacAuthorizationFilter.Config> {
|
||||
|
||||
public RbacAuthorizationFilter() {
|
||||
private static final Logger logger = LoggerFactory.getLogger(RbacAuthorizationFilter.class);
|
||||
|
||||
private final PermissionService permissionService;
|
||||
|
||||
public RbacAuthorizationFilter(PermissionService permissionService) {
|
||||
super(Config.class);
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -21,20 +29,33 @@ public class RbacAuthorizationFilter extends AbstractGatewayFilterFactory<RbacAu
|
||||
String method = request.getMethod().name();
|
||||
|
||||
if (isPublicPath(path)) {
|
||||
logger.debug("Public path access: {}", path);
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
String userIdHeader = request.getHeaders().getFirst("X-User-Id");
|
||||
if (userIdHeader == null) {
|
||||
if (userIdHeader == null || userIdHeader.isEmpty()) {
|
||||
logger.warn("Missing X-User-Id header for path: {}", path);
|
||||
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||
return exchange.getResponse().setComplete();
|
||||
}
|
||||
|
||||
if (!hasPermission(path, method)) {
|
||||
Long userId;
|
||||
try {
|
||||
userId = Long.parseLong(userIdHeader);
|
||||
} catch (NumberFormatException e) {
|
||||
logger.error("Invalid X-User-Id header: {}", userIdHeader, e);
|
||||
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||
return exchange.getResponse().setComplete();
|
||||
}
|
||||
|
||||
if (!permissionService.hasPermission(userId, path, method)) {
|
||||
logger.warn("Permission denied for userId: {}, path: {}, method: {}", userId, path, method);
|
||||
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
|
||||
return exchange.getResponse().setComplete();
|
||||
}
|
||||
|
||||
logger.debug("Permission granted for userId: {}, path: {}, method: {}", userId, path, method);
|
||||
return chain.filter(exchange);
|
||||
};
|
||||
}
|
||||
@@ -45,10 +66,6 @@ public class RbacAuthorizationFilter extends AbstractGatewayFilterFactory<RbacAu
|
||||
path.startsWith("/actuator/info");
|
||||
}
|
||||
|
||||
private boolean hasPermission(String path, String method) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static class Config {
|
||||
}
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
|
||||
import io.github.resilience4j.reactor.retry.RetryOperator;
|
||||
import io.github.resilience4j.reactor.timelimiter.TimeLimiterOperator;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 容错过滤器
|
||||
*
|
||||
* 文件定义:实现断路器、重试、超时等容错机制的全局过滤器
|
||||
* 涉及业务:网关容错增强,提高系统稳定性和可用性
|
||||
*
|
||||
* 容错机制:
|
||||
* 1. CircuitBreaker:断路器模式,防止级联故障
|
||||
* 2. Retry:重试机制,处理临时故障
|
||||
* 3. TimeLimiter:超时控制,防止长时间阻塞
|
||||
* 4. Fallback:降级策略,提供备用响应
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Component
|
||||
public class ResilienceFilter implements GlobalFilter, Ordered {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ResilienceFilter.class);
|
||||
|
||||
private final CircuitBreaker circuitBreaker;
|
||||
private final Retry retry;
|
||||
private final TimeLimiter timeLimiter;
|
||||
|
||||
@Value("${resilience.enabled:true}")
|
||||
private boolean resilienceEnabled;
|
||||
|
||||
@Value("${resilience.circuit-breaker.enabled:true}")
|
||||
private boolean circuitBreakerEnabled;
|
||||
|
||||
@Value("${resilience.retry.enabled:true}")
|
||||
private boolean retryEnabled;
|
||||
|
||||
@Value("${resilience.timeout.enabled:true}")
|
||||
private boolean timeoutEnabled;
|
||||
|
||||
public ResilienceFilter(CircuitBreaker circuitBreaker, Retry retry, TimeLimiter timeLimiter) {
|
||||
this.circuitBreaker = circuitBreaker;
|
||||
this.retry = retry;
|
||||
this.timeLimiter = timeLimiter;
|
||||
logger.info("ResilienceFilter initialized - CircuitBreaker: {}, Retry: {}, TimeLimiter: {}",
|
||||
circuitBreaker.getName(), retry.getName(), timeLimiter.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
if (!resilienceEnabled) {
|
||||
logger.debug("Resilience is disabled");
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
logger.debug("Applying resilience patterns for request: {} {}",
|
||||
exchange.getRequest().getMethod(),
|
||||
exchange.getRequest().getPath());
|
||||
|
||||
Mono<Void> chainMono = chain.filter(exchange);
|
||||
|
||||
if (timeoutEnabled) {
|
||||
chainMono = chainMono.transform(TimeLimiterOperator.of(timeLimiter));
|
||||
}
|
||||
|
||||
if (retryEnabled) {
|
||||
chainMono = chainMono.transform(RetryOperator.of(retry));
|
||||
}
|
||||
|
||||
if (circuitBreakerEnabled) {
|
||||
chainMono = chainMono.transform(CircuitBreakerOperator.of(circuitBreaker));
|
||||
}
|
||||
|
||||
return chainMono
|
||||
.onErrorResume(Exception.class, e -> handleFallback(exchange, e));
|
||||
}
|
||||
|
||||
private Mono<Void> handleFallback(ServerWebExchange exchange, Throwable throwable) {
|
||||
logger.error("Fallback triggered for request: {} {} - Error: {}",
|
||||
exchange.getRequest().getMethod(),
|
||||
exchange.getRequest().getPath(),
|
||||
throwable.getMessage());
|
||||
|
||||
ServerHttpResponse response = exchange.getResponse();
|
||||
|
||||
if (throwable instanceof io.github.resilience4j.circuitbreaker.CallNotPermittedException) {
|
||||
response.setStatusCode(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
String errorMessage = "{\"error\":\"Service Unavailable\",\"code\":\"CIRCUIT_BREAKER_OPEN\"," +
|
||||
"\"message\":\"Service is temporarily unavailable due to circuit breaker being open. " +
|
||||
"Please try again later.\"}";
|
||||
return response.writeWith(Mono.just(response.bufferFactory().wrap(errorMessage.getBytes())));
|
||||
} else if (throwable instanceof java.util.concurrent.TimeoutException) {
|
||||
response.setStatusCode(HttpStatus.GATEWAY_TIMEOUT);
|
||||
String errorMessage = "{\"error\":\"Gateway Timeout\",\"code\":\"TIMEOUT\"," +
|
||||
"\"message\":\"Request timed out. Please try again.\"}";
|
||||
return response.writeWith(Mono.just(response.bufferFactory().wrap(errorMessage.getBytes())));
|
||||
} else {
|
||||
response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
String errorMessage = "{\"error\":\"Internal Server Error\",\"code\":\"INTERNAL_ERROR\"," +
|
||||
"\"message\":\"An unexpected error occurred. Please try again later.\"}";
|
||||
return response.writeWith(Mono.just(response.bufferFactory().wrap(errorMessage.getBytes())));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.HIGHEST_PRECEDENCE + 200;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import cn.novalon.manage.gateway.service.SignatureService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
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;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 请求签名验证过滤器
|
||||
*
|
||||
* 文件定义:实现API请求签名验证的全局过滤器
|
||||
* 涉及业务:API安全防护,防止请求篡改和重放攻击
|
||||
* 算法:HMAC-SHA256签名验证
|
||||
*
|
||||
* 验证流程:
|
||||
* 1. 检查请求是否在白名单路径中
|
||||
* 2. 提取签名相关头部(X-Signature, X-Timestamp, X-Nonce)
|
||||
* 3. 验证时间戳是否在有效期内
|
||||
* 4. 验证nonce是否已使用(防重放攻击)
|
||||
* 5. 重新计算签名并比对
|
||||
* 6. 记录nonce防止重放
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Component
|
||||
public class SignatureFilter implements GlobalFilter, Ordered {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SignatureFilter.class);
|
||||
private static final String SIGNATURE_HEADER = "X-Signature";
|
||||
private static final String TIMESTAMP_HEADER = "X-Timestamp";
|
||||
private static final String NONCE_HEADER = "X-Nonce";
|
||||
|
||||
private final SignatureService signatureService;
|
||||
|
||||
@Value("${signature.enabled:true}")
|
||||
private boolean signatureEnabled;
|
||||
|
||||
@Value("${signature.secret:${SIGNATURE_SECRET:NovalonManageSystemSecretKey2026}}")
|
||||
private String signatureSecret;
|
||||
|
||||
@Value("${signature.whitelist.paths:/actuator/health,/actuator/info}")
|
||||
private String whitelistPaths;
|
||||
|
||||
public SignatureFilter(SignatureService signatureService) {
|
||||
this.signatureService = signatureService;
|
||||
logger.info("SignatureFilter initialized with enabled: {}", signatureEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
if (!signatureEnabled) {
|
||||
logger.debug("Signature verification is disabled");
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
String path = request.getPath().value();
|
||||
|
||||
if (isWhitelisted(path)) {
|
||||
logger.debug("Path {} is whitelisted, skipping signature verification", path);
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
logger.debug("Verifying signature for request: {} {}", request.getMethod(), path);
|
||||
|
||||
boolean isValid = signatureService.verifySignature(request, signatureSecret);
|
||||
|
||||
if (isValid) {
|
||||
logger.debug("Signature verification passed for request: {}", path);
|
||||
return chain.filter(exchange);
|
||||
} else {
|
||||
logger.warn("Signature verification failed for request: {} {}", request.getMethod(), path);
|
||||
return handleSignatureFailure(exchange);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWhitelisted(String path) {
|
||||
if (whitelistPaths == null || whitelistPaths.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<String> whitelistedPaths = Arrays.asList(whitelistPaths.split(","));
|
||||
return whitelistedPaths.stream()
|
||||
.anyMatch(whitelisted -> path.startsWith(whitelisted.trim()));
|
||||
}
|
||||
|
||||
private Mono<Void> handleSignatureFailure(ServerWebExchange exchange) {
|
||||
ServerHttpResponse response = exchange.getResponse();
|
||||
response.setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
headers.add("X-Error-Code", "INVALID_SIGNATURE");
|
||||
headers.add("X-Error-Message", "Request signature verification failed");
|
||||
|
||||
String errorMessage = "{\"error\":\"Unauthorized\",\"code\":\"INVALID_SIGNATURE\"," +
|
||||
"\"message\":\"Request signature verification failed. " +
|
||||
"Please ensure you have included valid X-Signature, X-Timestamp, and X-Nonce headers.\"}";
|
||||
|
||||
return response.writeWith(Mono.just(response.bufferFactory().wrap(errorMessage.getBytes())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.HIGHEST_PRECEDENCE + 150;
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package cn.novalon.manage.gateway.health;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 网关健康检查指示器
|
||||
*
|
||||
* 文件定义:实现自定义健康检查逻辑,监控网关核心组件状态
|
||||
* 涉及业务:网关健康状态监控,包括断路器、限流器等关键组件
|
||||
*
|
||||
* 健康检查内容:
|
||||
* 1. 断路器状态:检查所有断路器是否处于健康状态
|
||||
* 2. 限流器状态:检查限流器是否正常工作
|
||||
* 3. 自定义指标:检查网关特定的健康指标
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Component
|
||||
public class GatewayHealthIndicator implements HealthIndicator {
|
||||
|
||||
private final CircuitBreakerRegistry circuitBreakerRegistry;
|
||||
private final RateLimiterRegistry rateLimiterRegistry;
|
||||
|
||||
@Autowired
|
||||
public GatewayHealthIndicator(
|
||||
CircuitBreakerRegistry circuitBreakerRegistry,
|
||||
RateLimiterRegistry rateLimiterRegistry) {
|
||||
this.circuitBreakerRegistry = circuitBreakerRegistry;
|
||||
this.rateLimiterRegistry = rateLimiterRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
Health.Builder builder = Health.up();
|
||||
|
||||
Map<String, Object> details = new HashMap<>();
|
||||
|
||||
checkCircuitBreakers(details);
|
||||
checkRateLimiters(details);
|
||||
|
||||
boolean hasUnhealthyComponents = details.values().stream()
|
||||
.filter(value -> value instanceof Map)
|
||||
.map(value -> (Map<?, ?>) value)
|
||||
.flatMap(map -> map.values().stream())
|
||||
.filter(value -> value instanceof Map)
|
||||
.map(value -> (Map<?, ?>) value)
|
||||
.anyMatch(componentDetails ->
|
||||
componentDetails.containsKey("status") &&
|
||||
"DOWN".equals(componentDetails.get("status")));
|
||||
|
||||
if (hasUnhealthyComponents) {
|
||||
builder = Health.down();
|
||||
}
|
||||
|
||||
builder.withDetails(details);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private void checkCircuitBreakers(Map<String, Object> details) {
|
||||
Map<String, Object> circuitBreakerDetails = new HashMap<>();
|
||||
|
||||
circuitBreakerRegistry.getAllCircuitBreakers().forEach(circuitBreaker -> {
|
||||
String name = circuitBreaker.getName();
|
||||
CircuitBreaker.State state = circuitBreaker.getState();
|
||||
|
||||
Map<String, Object> cbDetails = new HashMap<>();
|
||||
cbDetails.put("state", state.name());
|
||||
cbDetails.put("status", state == CircuitBreaker.State.OPEN ? "DOWN" : "UP");
|
||||
|
||||
circuitBreakerDetails.put(name, cbDetails);
|
||||
});
|
||||
|
||||
details.put("circuitBreakers", circuitBreakerDetails);
|
||||
}
|
||||
|
||||
private void checkRateLimiters(Map<String, Object> details) {
|
||||
Map<String, Object> rateLimiterDetails = new HashMap<>();
|
||||
|
||||
rateLimiterRegistry.getAllRateLimiters().forEach(rateLimiter -> {
|
||||
String name = rateLimiter.getName();
|
||||
|
||||
Map<String, Object> rlDetails = new HashMap<>();
|
||||
rlDetails.put("status", "UP");
|
||||
rlDetails.put("availablePermissions",
|
||||
rateLimiter.getRateLimiterConfig().getLimitForPeriod());
|
||||
|
||||
rateLimiterDetails.put(name, rlDetails);
|
||||
});
|
||||
|
||||
details.put("rateLimiters", rateLimiterDetails);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package cn.novalon.manage.gateway.loadbalancer;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.client.ServiceInstance;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 自定义负载均衡器
|
||||
*
|
||||
* 文件定义:实现多种负载均衡策略
|
||||
* 涉及业务:请求分发、服务实例选择、负载均衡策略
|
||||
*
|
||||
* 负载均衡策略:
|
||||
* 1. 轮询
|
||||
* 2. 随机
|
||||
* 3. 加权轮询
|
||||
* 4. 最少连接
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Component
|
||||
public class CustomLoadBalancer {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CustomLoadBalancer.class);
|
||||
|
||||
private final AtomicInteger position = new AtomicInteger(new Random().nextInt(1000));
|
||||
private final Map<String, AtomicInteger> connectionCounts = new ConcurrentHashMap<>();
|
||||
private final Map<String, Integer> weights = new ConcurrentHashMap<>();
|
||||
|
||||
public ServiceInstance selectInstance(
|
||||
List<ServiceInstance> instances,
|
||||
LoadBalanceStrategy strategy) {
|
||||
|
||||
if (instances == null || instances.isEmpty()) {
|
||||
logger.warn("No instances available");
|
||||
return null;
|
||||
}
|
||||
|
||||
ServiceInstance selectedInstance;
|
||||
|
||||
switch (strategy) {
|
||||
case ROUND_ROBIN:
|
||||
selectedInstance = selectByRoundRobin(instances);
|
||||
break;
|
||||
case RANDOM:
|
||||
selectedInstance = selectByRandom(instances);
|
||||
break;
|
||||
case WEIGHTED_ROUND_ROBIN:
|
||||
selectedInstance = selectByWeightedRoundRobin(instances);
|
||||
break;
|
||||
case LEAST_CONNECTIONS:
|
||||
selectedInstance = selectByLeastConnections(instances);
|
||||
break;
|
||||
default:
|
||||
selectedInstance = selectByRoundRobin(instances);
|
||||
}
|
||||
|
||||
if (selectedInstance != null) {
|
||||
logger.debug("Selected instance {}:{} using {} strategy",
|
||||
selectedInstance.getHost(),
|
||||
selectedInstance.getPort(),
|
||||
strategy);
|
||||
}
|
||||
|
||||
return selectedInstance;
|
||||
}
|
||||
|
||||
private ServiceInstance selectByRoundRobin(List<ServiceInstance> instances) {
|
||||
int pos = Math.abs(position.incrementAndGet());
|
||||
return instances.get(pos % instances.size());
|
||||
}
|
||||
|
||||
private ServiceInstance selectByRandom(List<ServiceInstance> instances) {
|
||||
int index = new Random().nextInt(instances.size());
|
||||
return instances.get(index);
|
||||
}
|
||||
|
||||
private ServiceInstance selectByWeightedRoundRobin(List<ServiceInstance> instances) {
|
||||
int totalWeight = instances.stream()
|
||||
.mapToInt(this::getWeight)
|
||||
.sum();
|
||||
|
||||
if (totalWeight == 0) {
|
||||
return selectByRoundRobin(instances);
|
||||
}
|
||||
|
||||
int randomWeight = new Random().nextInt(totalWeight);
|
||||
int currentWeight = 0;
|
||||
|
||||
for (ServiceInstance instance : instances) {
|
||||
currentWeight += getWeight(instance);
|
||||
if (randomWeight < currentWeight) {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
return instances.get(0);
|
||||
}
|
||||
|
||||
private ServiceInstance selectByLeastConnections(List<ServiceInstance> instances) {
|
||||
ServiceInstance selectedInstance = null;
|
||||
int minConnections = Integer.MAX_VALUE;
|
||||
|
||||
for (ServiceInstance instance : instances) {
|
||||
int connections = getConnectionCount(instance);
|
||||
if (connections < minConnections) {
|
||||
minConnections = connections;
|
||||
selectedInstance = instance;
|
||||
}
|
||||
}
|
||||
|
||||
return selectedInstance != null ? selectedInstance : instances.get(0);
|
||||
}
|
||||
|
||||
private int getWeight(ServiceInstance instance) {
|
||||
String instanceKey = getInstanceKey(instance);
|
||||
return weights.getOrDefault(instanceKey, 1);
|
||||
}
|
||||
|
||||
public void setWeight(ServiceInstance instance, int weight) {
|
||||
String instanceKey = getInstanceKey(instance);
|
||||
weights.put(instanceKey, weight);
|
||||
logger.debug("Set weight {} for instance {}", weight, instanceKey);
|
||||
}
|
||||
|
||||
private int getConnectionCount(ServiceInstance instance) {
|
||||
String instanceKey = getInstanceKey(instance);
|
||||
AtomicInteger count = connectionCounts.get(instanceKey);
|
||||
return count != null ? count.get() : 0;
|
||||
}
|
||||
|
||||
public void incrementConnection(ServiceInstance instance) {
|
||||
String instanceKey = getInstanceKey(instance);
|
||||
connectionCounts.computeIfAbsent(instanceKey, k -> new AtomicInteger(0)).incrementAndGet();
|
||||
logger.debug("Incremented connection count for instance {}", instanceKey);
|
||||
}
|
||||
|
||||
public void decrementConnection(ServiceInstance instance) {
|
||||
String instanceKey = getInstanceKey(instance);
|
||||
AtomicInteger count = connectionCounts.get(instanceKey);
|
||||
if (count != null && count.get() > 0) {
|
||||
count.decrementAndGet();
|
||||
logger.debug("Decremented connection count for instance {}", instanceKey);
|
||||
}
|
||||
}
|
||||
|
||||
private String getInstanceKey(ServiceInstance instance) {
|
||||
return instance.getHost() + ":" + instance.getPort();
|
||||
}
|
||||
|
||||
public enum LoadBalanceStrategy {
|
||||
ROUND_ROBIN,
|
||||
RANDOM,
|
||||
WEIGHTED_ROUND_ROBIN,
|
||||
LEAST_CONNECTIONS
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package cn.novalon.manage.gateway.metrics;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 网关指标收集器
|
||||
*
|
||||
* 文件定义:收集和暴露网关自定义指标
|
||||
* 涉及业务:请求统计、错误统计、性能监控
|
||||
*
|
||||
* 指标类型:
|
||||
* 1. Counter:计数器,用于统计请求总数、错误总数等
|
||||
* 2. Gauge:仪表盘,用于统计当前值,如活跃连接数
|
||||
* 3. Timer:计时器,用于统计请求耗时
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Component
|
||||
public class GatewayMetrics {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GatewayMetrics.class);
|
||||
|
||||
private final MeterRegistry meterRegistry;
|
||||
|
||||
private final Counter totalRequestsCounter;
|
||||
private final Counter successRequestsCounter;
|
||||
private final Counter failedRequestsCounter;
|
||||
private final Counter rateLimitedRequestsCounter;
|
||||
private final Counter circuitBreakerOpenCounter;
|
||||
private final Counter unauthorizedRequestsCounter;
|
||||
|
||||
private final AtomicLong activeConnections = new AtomicLong(0);
|
||||
private final ConcurrentHashMap<String, AtomicLong> pathRequestCounts = new ConcurrentHashMap<>();
|
||||
|
||||
public GatewayMetrics(MeterRegistry meterRegistry) {
|
||||
this.meterRegistry = meterRegistry;
|
||||
|
||||
this.totalRequestsCounter = Counter.builder("gateway.requests.total")
|
||||
.description("Total number of gateway requests")
|
||||
.register(meterRegistry);
|
||||
|
||||
this.successRequestsCounter = Counter.builder("gateway.requests.success")
|
||||
.description("Number of successful gateway requests")
|
||||
.register(meterRegistry);
|
||||
|
||||
this.failedRequestsCounter = Counter.builder("gateway.requests.failed")
|
||||
.description("Number of failed gateway requests")
|
||||
.register(meterRegistry);
|
||||
|
||||
this.rateLimitedRequestsCounter = Counter.builder("gateway.requests.rate_limited")
|
||||
.description("Number of rate limited requests")
|
||||
.register(meterRegistry);
|
||||
|
||||
this.circuitBreakerOpenCounter = Counter.builder("gateway.circuit_breaker.open")
|
||||
.description("Number of circuit breaker open events")
|
||||
.register(meterRegistry);
|
||||
|
||||
this.unauthorizedRequestsCounter = Counter.builder("gateway.requests.unauthorized")
|
||||
.description("Number of unauthorized requests")
|
||||
.register(meterRegistry);
|
||||
|
||||
Gauge.builder("gateway.connections.active", activeConnections, AtomicLong::get)
|
||||
.description("Number of active connections")
|
||||
.register(meterRegistry);
|
||||
|
||||
logger.info("Gateway metrics initialized");
|
||||
}
|
||||
|
||||
public void incrementTotalRequests() {
|
||||
totalRequestsCounter.increment();
|
||||
}
|
||||
|
||||
public void incrementSuccessRequests() {
|
||||
successRequestsCounter.increment();
|
||||
}
|
||||
|
||||
public void incrementFailedRequests() {
|
||||
failedRequestsCounter.increment();
|
||||
}
|
||||
|
||||
public void incrementRateLimitedRequests() {
|
||||
rateLimitedRequestsCounter.increment();
|
||||
}
|
||||
|
||||
public void incrementCircuitBreakerOpen() {
|
||||
circuitBreakerOpenCounter.increment();
|
||||
}
|
||||
|
||||
public void incrementUnauthorizedRequests() {
|
||||
unauthorizedRequestsCounter.increment();
|
||||
}
|
||||
|
||||
public void incrementActiveConnections() {
|
||||
activeConnections.incrementAndGet();
|
||||
}
|
||||
|
||||
public void decrementActiveConnections() {
|
||||
activeConnections.decrementAndGet();
|
||||
}
|
||||
|
||||
public void recordRequestDuration(String path, Duration duration) {
|
||||
Timer.builder("gateway.request.duration")
|
||||
.description("Request duration")
|
||||
.tag("path", path)
|
||||
.register(meterRegistry)
|
||||
.record(duration);
|
||||
|
||||
pathRequestCounts.computeIfAbsent(path, k -> {
|
||||
AtomicLong counter = new AtomicLong(0);
|
||||
Gauge.builder("gateway.path.requests", counter, AtomicLong::get)
|
||||
.description("Number of requests per path")
|
||||
.tag("path", path)
|
||||
.register(meterRegistry);
|
||||
return counter;
|
||||
}).incrementAndGet();
|
||||
}
|
||||
|
||||
public void recordCustomMetric(String name, double value, String... tags) {
|
||||
Counter.builder(name)
|
||||
.tags(tags)
|
||||
.register(meterRegistry)
|
||||
.increment(value);
|
||||
}
|
||||
|
||||
public long getTotalRequests() {
|
||||
return (long) totalRequestsCounter.count();
|
||||
}
|
||||
|
||||
public long getSuccessRequests() {
|
||||
return (long) successRequestsCounter.count();
|
||||
}
|
||||
|
||||
public long getFailedRequests() {
|
||||
return (long) failedRequestsCounter.count();
|
||||
}
|
||||
|
||||
public long getActiveConnections() {
|
||||
return activeConnections.get();
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package cn.novalon.manage.gateway.model;
|
||||
|
||||
public class Permission {
|
||||
private Long id;
|
||||
private String permissionCode;
|
||||
private String permissionName;
|
||||
private String resourceType;
|
||||
private String resourcePath;
|
||||
private String httpMethod;
|
||||
private String description;
|
||||
private Integer status;
|
||||
private Long createTime;
|
||||
private Long updateTime;
|
||||
|
||||
public Permission() {
|
||||
}
|
||||
|
||||
public Permission(Long id, String permissionCode, String permissionName, String resourceType,
|
||||
String resourcePath, String httpMethod, String description,
|
||||
Integer status, Long createTime, Long updateTime) {
|
||||
this.id = id;
|
||||
this.permissionCode = permissionCode;
|
||||
this.permissionName = permissionName;
|
||||
this.resourceType = resourceType;
|
||||
this.resourcePath = resourcePath;
|
||||
this.httpMethod = httpMethod;
|
||||
this.description = description;
|
||||
this.status = status;
|
||||
this.createTime = createTime;
|
||||
this.updateTime = updateTime;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getPermissionCode() {
|
||||
return permissionCode;
|
||||
}
|
||||
|
||||
public void setPermissionCode(String permissionCode) {
|
||||
this.permissionCode = permissionCode;
|
||||
}
|
||||
|
||||
public String getPermissionName() {
|
||||
return permissionName;
|
||||
}
|
||||
|
||||
public void setPermissionName(String permissionName) {
|
||||
this.permissionName = permissionName;
|
||||
}
|
||||
|
||||
public String getResourceType() {
|
||||
return resourceType;
|
||||
}
|
||||
|
||||
public void setResourceType(String resourceType) {
|
||||
this.resourceType = resourceType;
|
||||
}
|
||||
|
||||
public String getResourcePath() {
|
||||
return resourcePath;
|
||||
}
|
||||
|
||||
public void setResourcePath(String resourcePath) {
|
||||
this.resourcePath = resourcePath;
|
||||
}
|
||||
|
||||
public String getHttpMethod() {
|
||||
return httpMethod;
|
||||
}
|
||||
|
||||
public void setHttpMethod(String httpMethod) {
|
||||
this.httpMethod = httpMethod;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public void setCreateTime(Long createTime) {
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
public Long getUpdateTime() {
|
||||
return updateTime;
|
||||
}
|
||||
|
||||
public void setUpdateTime(Long updateTime) {
|
||||
this.updateTime = updateTime;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package cn.novalon.manage.gateway.model;
|
||||
|
||||
public class Role {
|
||||
private Long id;
|
||||
private String roleCode;
|
||||
private String roleName;
|
||||
private String description;
|
||||
private Integer status;
|
||||
private Long createTime;
|
||||
private Long updateTime;
|
||||
|
||||
public Role() {
|
||||
}
|
||||
|
||||
public Role(Long id, String roleCode, String roleName, String description, Integer status, Long createTime, Long updateTime) {
|
||||
this.id = id;
|
||||
this.roleCode = roleCode;
|
||||
this.roleName = roleName;
|
||||
this.description = description;
|
||||
this.status = status;
|
||||
this.createTime = createTime;
|
||||
this.updateTime = updateTime;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getRoleCode() {
|
||||
return roleCode;
|
||||
}
|
||||
|
||||
public void setRoleCode(String roleCode) {
|
||||
this.roleCode = roleCode;
|
||||
}
|
||||
|
||||
public String getRoleName() {
|
||||
return roleName;
|
||||
}
|
||||
|
||||
public void setRoleName(String roleName) {
|
||||
this.roleName = roleName;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public void setCreateTime(Long createTime) {
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
public Long getUpdateTime() {
|
||||
return updateTime;
|
||||
}
|
||||
|
||||
public void setUpdateTime(Long updateTime) {
|
||||
this.updateTime = updateTime;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package cn.novalon.manage.gateway.model;
|
||||
|
||||
public class RolePermission {
|
||||
private Long id;
|
||||
private Long roleId;
|
||||
private Long permissionId;
|
||||
private Long createTime;
|
||||
|
||||
public RolePermission() {
|
||||
}
|
||||
|
||||
public RolePermission(Long id, Long roleId, Long permissionId, Long createTime) {
|
||||
this.id = id;
|
||||
this.roleId = roleId;
|
||||
this.permissionId = permissionId;
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
public void setRoleId(Long roleId) {
|
||||
this.roleId = roleId;
|
||||
}
|
||||
|
||||
public Long getPermissionId() {
|
||||
return permissionId;
|
||||
}
|
||||
|
||||
public void setPermissionId(Long permissionId) {
|
||||
this.permissionId = permissionId;
|
||||
}
|
||||
|
||||
public Long getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public void setCreateTime(Long createTime) {
|
||||
this.createTime = createTime;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package cn.novalon.manage.gateway.model;
|
||||
|
||||
public class User {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String email;
|
||||
private String phone;
|
||||
private Integer status;
|
||||
private Long createTime;
|
||||
private Long updateTime;
|
||||
|
||||
public User() {
|
||||
}
|
||||
|
||||
public User(Long id, String username, String email, String phone, Integer status, Long createTime, Long updateTime) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.email = email;
|
||||
this.phone = phone;
|
||||
this.status = status;
|
||||
this.createTime = createTime;
|
||||
this.updateTime = updateTime;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public void setCreateTime(Long createTime) {
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
public Long getUpdateTime() {
|
||||
return updateTime;
|
||||
}
|
||||
|
||||
public void setUpdateTime(Long updateTime) {
|
||||
this.updateTime = updateTime;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package cn.novalon.manage.gateway.model;
|
||||
|
||||
public class UserRole {
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long roleId;
|
||||
private Long createTime;
|
||||
|
||||
public UserRole() {
|
||||
}
|
||||
|
||||
public UserRole(Long id, Long userId, Long roleId, Long createTime) {
|
||||
this.id = id;
|
||||
this.userId = userId;
|
||||
this.roleId = roleId;
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public Long getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
public void setRoleId(Long roleId) {
|
||||
this.roleId = roleId;
|
||||
}
|
||||
|
||||
public Long getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public void setCreateTime(Long createTime) {
|
||||
this.createTime = createTime;
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package cn.novalon.manage.gateway.monitor;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.ThreadMXBean;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 性能监控服务
|
||||
*
|
||||
* 文件定义:监控网关性能指标
|
||||
* 涉及业务:性能统计、瓶颈识别、性能优化
|
||||
*
|
||||
* 监控指标:
|
||||
* 1. 请求处理时间
|
||||
* 2. 内存使用情况
|
||||
* 3. 线程池状态
|
||||
* 4. 连接池状态
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Service
|
||||
public class PerformanceMonitor {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PerformanceMonitor.class);
|
||||
|
||||
private final MeterRegistry meterRegistry;
|
||||
|
||||
private final Counter slowRequestsCounter;
|
||||
private final Counter memoryWarningCounter;
|
||||
|
||||
private final AtomicLong totalProcessingTime = new AtomicLong(0);
|
||||
private final AtomicLong requestCount = new AtomicLong(0);
|
||||
|
||||
private final Map<String, PerformanceStats> pathStats = new ConcurrentHashMap<>();
|
||||
|
||||
private long slowRequestThresholdMs = 2000;
|
||||
private double memoryWarningThreshold = 0.85;
|
||||
|
||||
public PerformanceMonitor(MeterRegistry meterRegistry) {
|
||||
this.meterRegistry = meterRegistry;
|
||||
|
||||
this.slowRequestsCounter = Counter.builder("gateway.performance.slow_requests")
|
||||
.description("Number of slow requests")
|
||||
.register(meterRegistry);
|
||||
|
||||
this.memoryWarningCounter = Counter.builder("gateway.performance.memory_warnings")
|
||||
.description("Number of memory warnings")
|
||||
.register(meterRegistry);
|
||||
|
||||
Gauge.builder("gateway.performance.avg_processing_time",
|
||||
this, PerformanceMonitor::getAverageProcessingTime)
|
||||
.description("Average request processing time in ms")
|
||||
.register(meterRegistry);
|
||||
|
||||
Gauge.builder("gateway.performance.memory_usage",
|
||||
this, PerformanceMonitor::getMemoryUsage)
|
||||
.description("Current memory usage ratio")
|
||||
.register(meterRegistry);
|
||||
|
||||
logger.info("Performance monitor initialized");
|
||||
}
|
||||
|
||||
public void recordRequest(String path, long durationMs) {
|
||||
totalProcessingTime.addAndGet(durationMs);
|
||||
requestCount.incrementAndGet();
|
||||
|
||||
pathStats.compute(path, (key, stats) -> {
|
||||
if (stats == null) {
|
||||
stats = new PerformanceStats();
|
||||
}
|
||||
stats.recordRequest(durationMs);
|
||||
return stats;
|
||||
});
|
||||
|
||||
if (durationMs > slowRequestThresholdMs) {
|
||||
slowRequestsCounter.increment();
|
||||
logger.warn("Slow request detected - Path: {}, Duration: {}ms", path, durationMs);
|
||||
}
|
||||
|
||||
Timer.builder("gateway.performance.request_duration")
|
||||
.description("Request processing duration")
|
||||
.tag("path", path)
|
||||
.register(meterRegistry)
|
||||
.record(Duration.ofMillis(durationMs));
|
||||
|
||||
checkMemoryUsage();
|
||||
}
|
||||
|
||||
private void checkMemoryUsage() {
|
||||
double memoryUsage = getMemoryUsage();
|
||||
|
||||
if (memoryUsage > memoryWarningThreshold) {
|
||||
memoryWarningCounter.increment();
|
||||
logger.warn("High memory usage detected: {}%", String.format("%.2f", memoryUsage * 100));
|
||||
}
|
||||
}
|
||||
|
||||
public double getAverageProcessingTime() {
|
||||
long count = requestCount.get();
|
||||
if (count == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
return (double) totalProcessingTime.get() / count;
|
||||
}
|
||||
|
||||
public double getMemoryUsage() {
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
long totalMemory = runtime.totalMemory();
|
||||
long freeMemory = runtime.freeMemory();
|
||||
long usedMemory = totalMemory - freeMemory;
|
||||
|
||||
return (double) usedMemory / totalMemory;
|
||||
}
|
||||
|
||||
public Map<String, Object> getMemoryStats() {
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
|
||||
Map<String, Object> stats = new ConcurrentHashMap<>();
|
||||
stats.put("totalMemory", runtime.totalMemory());
|
||||
stats.put("freeMemory", runtime.freeMemory());
|
||||
stats.put("usedMemory", runtime.totalMemory() - runtime.freeMemory());
|
||||
stats.put("maxMemory", runtime.maxMemory());
|
||||
stats.put("memoryUsage", getMemoryUsage());
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getThreadStats() {
|
||||
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
|
||||
|
||||
Map<String, Object> stats = new ConcurrentHashMap<>();
|
||||
stats.put("threadCount", threadBean.getThreadCount());
|
||||
stats.put("peakThreadCount", threadBean.getPeakThreadCount());
|
||||
stats.put("daemonThreadCount", threadBean.getDaemonThreadCount());
|
||||
stats.put("totalStartedThreadCount", threadBean.getTotalStartedThreadCount());
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, PerformanceStats> getPathStats() {
|
||||
return new ConcurrentHashMap<>(pathStats);
|
||||
}
|
||||
|
||||
public void clearStats() {
|
||||
totalProcessingTime.set(0);
|
||||
requestCount.set(0);
|
||||
pathStats.clear();
|
||||
logger.info("Performance stats cleared");
|
||||
}
|
||||
|
||||
public void setSlowRequestThresholdMs(long threshold) {
|
||||
this.slowRequestThresholdMs = threshold;
|
||||
logger.info("Slow request threshold set to: {}ms", threshold);
|
||||
}
|
||||
|
||||
public void setMemoryWarningThreshold(double threshold) {
|
||||
this.memoryWarningThreshold = threshold;
|
||||
logger.info("Memory warning threshold set to: {}", threshold);
|
||||
}
|
||||
|
||||
public static class PerformanceStats {
|
||||
private final AtomicLong requestCount = new AtomicLong(0);
|
||||
private final AtomicLong totalTime = new AtomicLong(0);
|
||||
private final AtomicLong maxTime = new AtomicLong(0);
|
||||
private final AtomicLong minTime = new AtomicLong(Long.MAX_VALUE);
|
||||
|
||||
public void recordRequest(long durationMs) {
|
||||
requestCount.incrementAndGet();
|
||||
totalTime.addAndGet(durationMs);
|
||||
|
||||
long currentMax = maxTime.get();
|
||||
if (durationMs > currentMax) {
|
||||
maxTime.compareAndSet(currentMax, durationMs);
|
||||
}
|
||||
|
||||
long currentMin = minTime.get();
|
||||
if (durationMs < currentMin) {
|
||||
minTime.compareAndSet(currentMin, durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
public long getRequestCount() {
|
||||
return requestCount.get();
|
||||
}
|
||||
|
||||
public double getAverageTime() {
|
||||
long count = requestCount.get();
|
||||
return count == 0 ? 0.0 : (double) totalTime.get() / count;
|
||||
}
|
||||
|
||||
public long getMaxTime() {
|
||||
return maxTime.get();
|
||||
}
|
||||
|
||||
public long getMinTime() {
|
||||
long min = minTime.get();
|
||||
return min == Long.MAX_VALUE ? 0 : min;
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package cn.novalon.manage.gateway.route;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.gateway.event.RefreshRoutesEvent;
|
||||
import org.springframework.cloud.gateway.route.RouteDefinition;
|
||||
import org.springframework.cloud.gateway.route.RouteDefinitionLocator;
|
||||
import org.springframework.cloud.gateway.route.RouteDefinitionWriter;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 动态路由服务
|
||||
*
|
||||
* 文件定义:实现网关路由的动态配置和管理
|
||||
* 涉及业务:路由增删改查、路由刷新、路由缓存管理
|
||||
*
|
||||
* 核心功能:
|
||||
* 1. 动态添加路由
|
||||
* 2. 动态删除路由
|
||||
* 3. 动态更新路由
|
||||
* 4. 路由列表查询
|
||||
* 5. 路由刷新
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Service
|
||||
public class DynamicRouteService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DynamicRouteService.class);
|
||||
|
||||
private final RouteDefinitionWriter routeDefinitionWriter;
|
||||
private final RouteDefinitionLocator routeDefinitionLocator;
|
||||
private final ApplicationEventPublisher publisher;
|
||||
|
||||
private final Map<String, RouteDefinition> routeCache = new ConcurrentHashMap<>();
|
||||
|
||||
public DynamicRouteService(
|
||||
RouteDefinitionWriter routeDefinitionWriter,
|
||||
RouteDefinitionLocator routeDefinitionLocator,
|
||||
ApplicationEventPublisher publisher) {
|
||||
this.routeDefinitionWriter = routeDefinitionWriter;
|
||||
this.routeDefinitionLocator = routeDefinitionLocator;
|
||||
this.publisher = publisher;
|
||||
|
||||
initializeRouteCache();
|
||||
}
|
||||
|
||||
private void initializeRouteCache() {
|
||||
routeDefinitionLocator.getRouteDefinitions()
|
||||
.doOnNext(route -> routeCache.put(route.getId(), route))
|
||||
.subscribe(
|
||||
route -> logger.debug("Cached route: {}", route.getId()),
|
||||
error -> logger.error("Failed to initialize route cache", error),
|
||||
() -> logger.info("Route cache initialized with {} routes", routeCache.size())
|
||||
);
|
||||
}
|
||||
|
||||
public Mono<Boolean> addRoute(RouteDefinition routeDefinition) {
|
||||
if (routeDefinition == null || routeDefinition.getId() == null) {
|
||||
logger.error("Invalid route definition: route or route ID is null");
|
||||
return Mono.just(false);
|
||||
}
|
||||
|
||||
String routeId = routeDefinition.getId();
|
||||
logger.info("Adding route: {}", routeId);
|
||||
|
||||
return routeDefinitionWriter.save(Mono.just(routeDefinition))
|
||||
.then(Mono.fromRunnable(() -> {
|
||||
routeCache.put(routeId, routeDefinition);
|
||||
refreshRoutes();
|
||||
logger.info("Route added successfully: {}", routeId);
|
||||
}))
|
||||
.thenReturn(true)
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Failed to add route: {}", routeId, error);
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Boolean> updateRoute(RouteDefinition routeDefinition) {
|
||||
if (routeDefinition == null || routeDefinition.getId() == null) {
|
||||
logger.error("Invalid route definition: route or route ID is null");
|
||||
return Mono.just(false);
|
||||
}
|
||||
|
||||
String routeId = routeDefinition.getId();
|
||||
|
||||
if (!routeCache.containsKey(routeId)) {
|
||||
logger.warn("Route not found for update: {}", routeId);
|
||||
return Mono.just(false);
|
||||
}
|
||||
|
||||
logger.info("Updating route: {}", routeId);
|
||||
|
||||
return deleteRoute(routeId)
|
||||
.flatMap(success -> {
|
||||
if (success) {
|
||||
return addRoute(routeDefinition);
|
||||
}
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Boolean> deleteRoute(String routeId) {
|
||||
if (routeId == null || routeId.isEmpty()) {
|
||||
logger.error("Invalid route ID: route ID is null or empty");
|
||||
return Mono.just(false);
|
||||
}
|
||||
|
||||
logger.info("Deleting route: {}", routeId);
|
||||
|
||||
return routeDefinitionWriter.delete(Mono.just(routeId))
|
||||
.then(Mono.fromRunnable(() -> {
|
||||
routeCache.remove(routeId);
|
||||
refreshRoutes();
|
||||
logger.info("Route deleted successfully: {}", routeId);
|
||||
}))
|
||||
.thenReturn(true)
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Failed to delete route: {}", routeId, error);
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
public Flux<RouteDefinition> getAllRoutes() {
|
||||
return Flux.fromIterable(routeCache.values());
|
||||
}
|
||||
|
||||
public Mono<RouteDefinition> getRoute(String routeId) {
|
||||
if (routeId == null || routeId.isEmpty()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
RouteDefinition route = routeCache.get(routeId);
|
||||
return route != null ? Mono.just(route) : Mono.empty();
|
||||
}
|
||||
|
||||
public void refreshRoutes() {
|
||||
logger.info("Refreshing routes");
|
||||
publisher.publishEvent(new RefreshRoutesEvent(this));
|
||||
}
|
||||
|
||||
public Mono<Boolean> batchAddRoutes(List<RouteDefinition> routeDefinitions) {
|
||||
if (routeDefinitions == null || routeDefinitions.isEmpty()) {
|
||||
logger.warn("No routes to add");
|
||||
return Mono.just(false);
|
||||
}
|
||||
|
||||
logger.info("Batch adding {} routes", routeDefinitions.size());
|
||||
|
||||
return Flux.fromIterable(routeDefinitions)
|
||||
.flatMap(this::addRoute)
|
||||
.all(success -> success)
|
||||
.doOnSuccess(allSuccess -> {
|
||||
if (allSuccess) {
|
||||
logger.info("All routes added successfully");
|
||||
} else {
|
||||
logger.warn("Some routes failed to add");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Boolean> batchDeleteRoutes(List<String> routeIds) {
|
||||
if (routeIds == null || routeIds.isEmpty()) {
|
||||
logger.warn("No routes to delete");
|
||||
return Mono.just(false);
|
||||
}
|
||||
|
||||
logger.info("Batch deleting {} routes", routeIds.size());
|
||||
|
||||
return Flux.fromIterable(routeIds)
|
||||
.flatMap(this::deleteRoute)
|
||||
.all(success -> success)
|
||||
.doOnSuccess(allSuccess -> {
|
||||
if (allSuccess) {
|
||||
logger.info("All routes deleted successfully");
|
||||
} else {
|
||||
logger.warn("Some routes failed to delete");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public int getRouteCount() {
|
||||
return routeCache.size();
|
||||
}
|
||||
|
||||
public void clearRouteCache() {
|
||||
logger.info("Clearing route cache");
|
||||
routeCache.clear();
|
||||
initializeRouteCache();
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package cn.novalon.manage.gateway.service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
public interface JwtKeyService {
|
||||
|
||||
SecretKey getCurrentSigningKey();
|
||||
|
||||
SecretKey getSigningKeyByVersion(String version);
|
||||
|
||||
String getCurrentKeyVersion();
|
||||
|
||||
void rotateKey();
|
||||
|
||||
boolean validateKeyStrength(String key);
|
||||
|
||||
String generateSecureKey();
|
||||
|
||||
String encryptKey(String key);
|
||||
|
||||
String decryptKey(String encryptedKey);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package cn.novalon.manage.gateway.service;
|
||||
|
||||
import cn.novalon.manage.gateway.model.Permission;
|
||||
import cn.novalon.manage.gateway.model.Role;
|
||||
import cn.novalon.manage.gateway.model.User;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public interface PermissionService {
|
||||
|
||||
User getUserById(Long userId);
|
||||
|
||||
List<Role> getUserRoles(Long userId);
|
||||
|
||||
Set<Permission> getUserPermissions(Long userId);
|
||||
|
||||
boolean hasPermission(Long userId, String path, String method);
|
||||
|
||||
Set<String> getPermissionPaths(Long userId, String method);
|
||||
|
||||
void clearCache(Long userId);
|
||||
|
||||
void clearAllCache();
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package cn.novalon.manage.gateway.service;
|
||||
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
|
||||
/**
|
||||
* 请求签名服务接口
|
||||
*
|
||||
* 文件定义:提供API请求签名生成和验证功能
|
||||
* 涉及业务:API安全防护,防止请求篡改和重放攻击
|
||||
* 算法:HMAC-SHA256签名算法
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
public interface SignatureService {
|
||||
|
||||
/**
|
||||
* 生成请求签名
|
||||
*
|
||||
* @param method HTTP方法
|
||||
* @param path 请求路径
|
||||
* @param query 查询参数
|
||||
* @param body 请求体
|
||||
* @param timestamp 时间戳
|
||||
* @param nonce 随机数
|
||||
* @param secret 密钥
|
||||
* @return 签名字符串
|
||||
*/
|
||||
String generateSignature(
|
||||
String method,
|
||||
String path,
|
||||
String query,
|
||||
String body,
|
||||
long timestamp,
|
||||
String nonce,
|
||||
String secret);
|
||||
|
||||
/**
|
||||
* 验证请求签名
|
||||
*
|
||||
* @param request HTTP请求
|
||||
* @param secret 密钥
|
||||
* @return 验证结果
|
||||
*/
|
||||
boolean verifySignature(ServerHttpRequest request, String secret);
|
||||
|
||||
/**
|
||||
* 检查时间戳是否有效
|
||||
*
|
||||
* @param timestamp 时间戳(毫秒)
|
||||
* @param maxAgeMinutes 最大有效期(分钟)
|
||||
* @return 是否有效
|
||||
*/
|
||||
boolean isTimestampValid(long timestamp, int maxAgeMinutes);
|
||||
|
||||
/**
|
||||
* 检查nonce是否已使用(防重放攻击)
|
||||
*
|
||||
* @param nonce 随机数
|
||||
* @return 是否已使用
|
||||
*/
|
||||
boolean isNonceUsed(String nonce);
|
||||
|
||||
/**
|
||||
* 记录nonce为已使用
|
||||
*
|
||||
* @param nonce 随机数
|
||||
*/
|
||||
void recordNonce(String nonce);
|
||||
|
||||
/**
|
||||
* 清理过期的nonce记录
|
||||
*/
|
||||
void cleanupExpiredNonces();
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
package cn.novalon.manage.gateway.service.impl;
|
||||
|
||||
import cn.novalon.manage.gateway.service.JwtKeyService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.KeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@Service
|
||||
public class JwtKeyServiceImpl implements JwtKeyService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtKeyServiceImpl.class);
|
||||
|
||||
private static final String KEY_ALGORITHM = "AES";
|
||||
private static final String KEY_ENCRYPTION_ALGORITHM = "AES/GCM/NoPadding";
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int KEY_SIZE_BITS = 256;
|
||||
private static final int MIN_KEY_LENGTH = 32;
|
||||
private static final int KEY_ROTATION_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
@Value("${jwt.secret:}")
|
||||
private String configuredSecret;
|
||||
|
||||
@Value("${jwt.key.encryption.password:}")
|
||||
private String encryptionPassword;
|
||||
|
||||
@Value("${jwt.key.rotation.enabled:true}")
|
||||
private boolean rotationEnabled;
|
||||
|
||||
private final AtomicReference<String> currentKeyVersion = new AtomicReference<>("v1");
|
||||
private final Map<String, SecretKey> keyVersionMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> keyCreationTimeMap = new ConcurrentHashMap<>();
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
@Override
|
||||
public SecretKey getCurrentSigningKey() {
|
||||
String version = getCurrentKeyVersion();
|
||||
return getSigningKeyByVersion(version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecretKey getSigningKeyByVersion(String version) {
|
||||
return keyVersionMap.get(version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCurrentKeyVersion() {
|
||||
return currentKeyVersion.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rotateKey() {
|
||||
if (!rotationEnabled) {
|
||||
logger.info("Key rotation is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Starting JWT key rotation");
|
||||
|
||||
try {
|
||||
String newVersion = generateNextVersion();
|
||||
String newKey = generateSecureKey();
|
||||
|
||||
SecretKey signingKey = new SecretKeySpec(
|
||||
newKey.getBytes(StandardCharsets.UTF_8),
|
||||
KEY_ALGORITHM
|
||||
);
|
||||
|
||||
keyVersionMap.put(newVersion, signingKey);
|
||||
keyCreationTimeMap.put(newVersion, System.currentTimeMillis());
|
||||
currentKeyVersion.set(newVersion);
|
||||
|
||||
logger.info("JWT key rotated successfully. New version: {}", newVersion);
|
||||
|
||||
cleanupOldKeys();
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to rotate JWT key", e);
|
||||
throw new RuntimeException("Key rotation failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validateKeyStrength(String key) {
|
||||
if (key == null || key.length() < MIN_KEY_LENGTH) {
|
||||
logger.warn("Key validation failed: key is null or too short");
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasUpperCase = !key.equals(key.toLowerCase());
|
||||
boolean hasLowerCase = !key.equals(key.toUpperCase());
|
||||
boolean hasDigit = key.matches(".*\\d.*");
|
||||
boolean hasSpecialChar = !key.matches("[a-zA-Z0-9]*");
|
||||
|
||||
int strengthScore = (hasUpperCase ? 1 : 0) +
|
||||
(hasLowerCase ? 1 : 0) +
|
||||
(hasDigit ? 1 : 0) +
|
||||
(hasSpecialChar ? 1 : 0);
|
||||
|
||||
boolean isValid = strengthScore >= 3 && key.length() >= MIN_KEY_LENGTH;
|
||||
|
||||
if (!isValid) {
|
||||
logger.warn("Key validation failed: strength score = {}, length = {}", strengthScore, key.length());
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateSecureKey() {
|
||||
byte[] keyBytes = new byte[KEY_SIZE_BITS / 8];
|
||||
secureRandom.nextBytes(keyBytes);
|
||||
return Base64.getEncoder().encodeToString(keyBytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String encryptKey(String key) {
|
||||
if (encryptionPassword == null || encryptionPassword.isEmpty()) {
|
||||
logger.warn("Encryption password not configured, returning plain key");
|
||||
return key;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
secureRandom.nextBytes(iv);
|
||||
|
||||
SecretKey encryptionKey = deriveEncryptionKey(encryptionPassword);
|
||||
Cipher cipher = Cipher.getInstance(KEY_ENCRYPTION_ALGORITHM);
|
||||
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, spec);
|
||||
|
||||
byte[] encryptedBytes = cipher.doFinal(key.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + encryptedBytes.length);
|
||||
byteBuffer.put(iv);
|
||||
byteBuffer.put(encryptedBytes);
|
||||
|
||||
String result = Base64.getEncoder().encodeToString(byteBuffer.array());
|
||||
logger.debug("Key encrypted successfully");
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to encrypt key", e);
|
||||
throw new RuntimeException("Key encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String decryptKey(String encryptedKey) {
|
||||
if (encryptionPassword == null || encryptionPassword.isEmpty()) {
|
||||
logger.warn("Encryption password not configured, returning key as is");
|
||||
return encryptedKey;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] decodedBytes = Base64.getDecoder().decode(encryptedKey);
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap(decodedBytes);
|
||||
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
byteBuffer.get(iv);
|
||||
|
||||
byte[] encryptedBytes = new byte[byteBuffer.remaining()];
|
||||
byteBuffer.get(encryptedBytes);
|
||||
|
||||
SecretKey encryptionKey = deriveEncryptionKey(encryptionPassword);
|
||||
Cipher cipher = Cipher.getInstance(KEY_ENCRYPTION_ALGORITHM);
|
||||
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.DECRYPT_MODE, encryptionKey, spec);
|
||||
|
||||
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
||||
String result = new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||
logger.debug("Key decrypted successfully");
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to decrypt key", e);
|
||||
throw new RuntimeException("Key decryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void initializeKeys() {
|
||||
try {
|
||||
String initialKey;
|
||||
|
||||
if (configuredSecret != null && !configuredSecret.isEmpty()) {
|
||||
if (configuredSecret.startsWith("enc:")) {
|
||||
initialKey = decryptKey(configuredSecret.substring(4));
|
||||
logger.info("Decrypted JWT key from configuration");
|
||||
} else {
|
||||
initialKey = configuredSecret;
|
||||
logger.warn("Using plain JWT key from configuration (not recommended)");
|
||||
|
||||
if (!validateKeyStrength(initialKey)) {
|
||||
logger.error("Configured JWT key does not meet strength requirements");
|
||||
throw new IllegalArgumentException("Weak JWT key configuration");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
initialKey = generateSecureKey();
|
||||
logger.info("Generated new secure JWT key");
|
||||
}
|
||||
|
||||
SecretKey signingKey = new SecretKeySpec(
|
||||
initialKey.getBytes(StandardCharsets.UTF_8),
|
||||
KEY_ALGORITHM
|
||||
);
|
||||
|
||||
keyVersionMap.put("v1", signingKey);
|
||||
keyCreationTimeMap.put("v1", System.currentTimeMillis());
|
||||
currentKeyVersion.set("v1");
|
||||
|
||||
logger.info("JWT key service initialized with version v1");
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to initialize JWT keys", e);
|
||||
throw new RuntimeException("JWT key initialization failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String generateNextVersion() {
|
||||
String currentVersion = getCurrentKeyVersion();
|
||||
int versionNumber = Integer.parseInt(currentVersion.substring(1));
|
||||
return "v" + (versionNumber + 1);
|
||||
}
|
||||
|
||||
private SecretKey deriveEncryptionKey(String password) throws Exception {
|
||||
byte[] salt = "NovalonManageSystemSalt".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
KeySpec spec = new PBEKeySpec(
|
||||
password.toCharArray(),
|
||||
salt,
|
||||
65536,
|
||||
256
|
||||
);
|
||||
|
||||
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
|
||||
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
|
||||
|
||||
return new SecretKeySpec(keyBytes, KEY_ALGORITHM);
|
||||
}
|
||||
|
||||
private void cleanupOldKeys() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long rotationThreshold = KEY_ROTATION_INTERVAL_MS * 2; // Keep keys for 2 rotation cycles
|
||||
|
||||
keyVersionMap.keySet().stream()
|
||||
.filter(version -> !version.equals(getCurrentKeyVersion()))
|
||||
.filter(version -> {
|
||||
Long creationTime = keyCreationTimeMap.get(version);
|
||||
return creationTime != null && (currentTime - creationTime) > rotationThreshold;
|
||||
})
|
||||
.forEach(version -> {
|
||||
keyVersionMap.remove(version);
|
||||
keyCreationTimeMap.remove(version);
|
||||
logger.info("Removed old JWT key version: {}", version);
|
||||
});
|
||||
}
|
||||
|
||||
public boolean shouldRotateKey() {
|
||||
if (!rotationEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String currentVersion = getCurrentKeyVersion();
|
||||
Long creationTime = keyCreationTimeMap.get(currentVersion);
|
||||
|
||||
if (creationTime == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
long keyAge = System.currentTimeMillis() - creationTime;
|
||||
return keyAge >= KEY_ROTATION_INTERVAL_MS;
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package cn.novalon.manage.gateway.service.impl;
|
||||
|
||||
import cn.novalon.manage.gateway.model.Permission;
|
||||
import cn.novalon.manage.gateway.model.Role;
|
||||
import cn.novalon.manage.gateway.model.User;
|
||||
import cn.novalon.manage.gateway.service.PermissionService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class PermissionServiceImpl implements PermissionService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PermissionServiceImpl.class);
|
||||
|
||||
private final WebClient webClient;
|
||||
private final String userServiceUrl;
|
||||
|
||||
private final Map<Long, User> userCache = new ConcurrentHashMap<>();
|
||||
private final Map<Long, List<Role>> userRolesCache = new ConcurrentHashMap<>();
|
||||
private final Map<Long, Set<Permission>> userPermissionsCache = new ConcurrentHashMap<>();
|
||||
|
||||
private final Map<Long, Long> userCacheTimestamp = new ConcurrentHashMap<>();
|
||||
private final Map<Long, Long> rolesCacheTimestamp = new ConcurrentHashMap<>();
|
||||
private final Map<Long, Long> permissionsCacheTimestamp = new ConcurrentHashMap<>();
|
||||
|
||||
private static final long CACHE_EXPIRY_MS = 5 * 60 * 1000;
|
||||
|
||||
public PermissionServiceImpl(WebClient.Builder webClientBuilder,
|
||||
@Value("${user.service.url:http://localhost:8084}") String userServiceUrl) {
|
||||
this.webClient = webClientBuilder.build();
|
||||
this.userServiceUrl = userServiceUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public User getUserById(Long userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Long cacheTime = userCacheTimestamp.get(userId);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
|
||||
if (cacheTime != null && (currentTime - cacheTime) < CACHE_EXPIRY_MS) {
|
||||
logger.debug("Returning cached user for userId: {}", userId);
|
||||
return userCache.get(userId);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Fetching user from service for userId: {}", userId);
|
||||
User user = webClient.get()
|
||||
.uri(userServiceUrl + "/api/users/" + userId)
|
||||
.retrieve()
|
||||
.bodyToMono(User.class)
|
||||
.block();
|
||||
|
||||
if (user != null) {
|
||||
userCache.put(userId, user);
|
||||
userCacheTimestamp.put(userId, currentTime);
|
||||
logger.debug("Cached user for userId: {}", userId);
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (Exception e) {
|
||||
logger.error("Error fetching user for userId: {}", userId, e);
|
||||
return userCache.get(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Role> getUserRoles(Long userId) {
|
||||
if (userId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Long cacheTime = rolesCacheTimestamp.get(userId);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
|
||||
if (cacheTime != null && (currentTime - cacheTime) < CACHE_EXPIRY_MS) {
|
||||
logger.debug("Returning cached roles for userId: {}", userId);
|
||||
return userRolesCache.getOrDefault(userId, Collections.emptyList());
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Fetching roles from service for userId: {}", userId);
|
||||
Role[] roles = webClient.get()
|
||||
.uri(userServiceUrl + "/api/users/" + userId + "/roles")
|
||||
.retrieve()
|
||||
.bodyToMono(Role[].class)
|
||||
.block();
|
||||
|
||||
List<Role> roleList = roles != null ? Arrays.asList(roles) : Collections.emptyList();
|
||||
userRolesCache.put(userId, roleList);
|
||||
rolesCacheTimestamp.put(userId, currentTime);
|
||||
logger.debug("Cached roles for userId: {}, count: {}", userId, roleList.size());
|
||||
|
||||
return roleList;
|
||||
} catch (Exception e) {
|
||||
logger.error("Error fetching roles for userId: {}", userId, e);
|
||||
return userRolesCache.getOrDefault(userId, Collections.emptyList());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Permission> getUserPermissions(Long userId) {
|
||||
if (userId == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
Long cacheTime = permissionsCacheTimestamp.get(userId);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
|
||||
if (cacheTime != null && (currentTime - cacheTime) < CACHE_EXPIRY_MS) {
|
||||
logger.debug("Returning cached permissions for userId: {}", userId);
|
||||
return userPermissionsCache.getOrDefault(userId, Collections.emptySet());
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Fetching permissions from service for userId: {}", userId);
|
||||
Permission[] permissions = webClient.get()
|
||||
.uri(userServiceUrl + "/api/users/" + userId + "/permissions")
|
||||
.retrieve()
|
||||
.bodyToMono(Permission[].class)
|
||||
.block();
|
||||
|
||||
Set<Permission> permissionSet = permissions != null ?
|
||||
new HashSet<>(Arrays.asList(permissions)) : Collections.emptySet();
|
||||
userPermissionsCache.put(userId, permissionSet);
|
||||
permissionsCacheTimestamp.put(userId, currentTime);
|
||||
logger.debug("Cached permissions for userId: {}, count: {}", userId, permissionSet.size());
|
||||
|
||||
return permissionSet;
|
||||
} catch (Exception e) {
|
||||
logger.error("Error fetching permissions for userId: {}", userId, e);
|
||||
return userPermissionsCache.getOrDefault(userId, Collections.emptySet());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasPermission(Long userId, String path, String method) {
|
||||
if (userId == null) {
|
||||
logger.warn("UserId is null, denying access");
|
||||
return false;
|
||||
}
|
||||
|
||||
Set<String> permissionPaths = getPermissionPaths(userId, method);
|
||||
|
||||
for (String permissionPath : permissionPaths) {
|
||||
if (matchPath(permissionPath, path)) {
|
||||
logger.debug("Permission granted for userId: {}, path: {}, method: {}, matched permission: {}",
|
||||
userId, path, method, permissionPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn("Permission denied for userId: {}, path: {}, method: {}", userId, path, method);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getPermissionPaths(Long userId, String method) {
|
||||
Set<Permission> permissions = getUserPermissions(userId);
|
||||
|
||||
return permissions.stream()
|
||||
.filter(p -> method.equalsIgnoreCase(p.getHttpMethod()))
|
||||
.map(Permission::getResourcePath)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean matchPath(String permissionPath, String requestPath) {
|
||||
if (permissionPath.equals(requestPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (permissionPath.endsWith("/**")) {
|
||||
String basePath = permissionPath.substring(0, permissionPath.length() - 3);
|
||||
return requestPath.startsWith(basePath);
|
||||
}
|
||||
|
||||
if (permissionPath.endsWith("/*")) {
|
||||
String basePath = permissionPath.substring(0, permissionPath.length() - 2);
|
||||
return requestPath.startsWith(basePath) &&
|
||||
!requestPath.substring(basePath.length() + 1).contains("/");
|
||||
}
|
||||
|
||||
if (permissionPath.contains("*")) {
|
||||
String regex = permissionPath.replace("*", ".*");
|
||||
return requestPath.matches(regex);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void clearCache(Long userId) {
|
||||
if (userId != null) {
|
||||
userCache.remove(userId);
|
||||
userRolesCache.remove(userId);
|
||||
userPermissionsCache.remove(userId);
|
||||
userCacheTimestamp.remove(userId);
|
||||
rolesCacheTimestamp.remove(userId);
|
||||
permissionsCacheTimestamp.remove(userId);
|
||||
logger.info("Cleared cache for userId: {}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
public void clearAllCache() {
|
||||
userCache.clear();
|
||||
userRolesCache.clear();
|
||||
userPermissionsCache.clear();
|
||||
userCacheTimestamp.clear();
|
||||
rolesCacheTimestamp.clear();
|
||||
permissionsCacheTimestamp.clear();
|
||||
logger.info("Cleared all permission cache");
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package cn.novalon.manage.gateway.service.impl;
|
||||
|
||||
import cn.novalon.manage.gateway.service.SignatureService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 请求签名服务实现
|
||||
*
|
||||
* 文件定义:实现API请求签名生成和验证功能
|
||||
* 涉及业务:API安全防护,防止请求篡改和重放攻击
|
||||
* 算法:HMAC-SHA256签名算法
|
||||
*
|
||||
* 签名算法:
|
||||
* 1. 构造签名字符串:METHOD + "\n" + PATH + "\n" + QUERY + "\n" + BODY + "\n" + TIMESTAMP + "\n" + NONCE
|
||||
* 2. 使用HMAC-SHA256算法对签名字符串进行签名
|
||||
* 3. 将签名结果进行Base64编码
|
||||
*
|
||||
* 防重放攻击:
|
||||
* 1. 检查时间戳是否在有效期内(默认5分钟)
|
||||
* 2. 检查nonce是否已使用(使用ConcurrentHashMap存储)
|
||||
* 3. 定期清理过期的nonce记录
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Service
|
||||
public class SignatureServiceImpl implements SignatureService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SignatureServiceImpl.class);
|
||||
private static final String HMAC_SHA256 = "HmacSHA256";
|
||||
private static final String SIGNATURE_HEADER = "X-Signature";
|
||||
private static final String TIMESTAMP_HEADER = "X-Timestamp";
|
||||
private static final String NONCE_HEADER = "X-Nonce";
|
||||
|
||||
@Value("${signature.enabled:true}")
|
||||
private boolean signatureEnabled;
|
||||
|
||||
@Value("${signature.max-age-minutes:5}")
|
||||
private int maxAgeMinutes;
|
||||
|
||||
@Value("${signature.nonce-cache-size:10000}")
|
||||
private int nonceCacheSize;
|
||||
|
||||
private final ConcurrentHashMap<String, Long> nonceCache = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public String generateSignature(
|
||||
String method,
|
||||
String path,
|
||||
String query,
|
||||
String body,
|
||||
long timestamp,
|
||||
String nonce,
|
||||
String secret) {
|
||||
try {
|
||||
String stringToSign = buildStringToSign(method, path, query, body, timestamp, nonce);
|
||||
logger.debug("String to sign: {}", stringToSign);
|
||||
|
||||
Mac mac = Mac.getInstance(HMAC_SHA256);
|
||||
SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256);
|
||||
mac.init(secretKeySpec);
|
||||
|
||||
byte[] signatureBytes = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));
|
||||
String signature = Base64.getEncoder().encodeToString(signatureBytes);
|
||||
|
||||
logger.debug("Generated signature: {}", signature);
|
||||
return signature;
|
||||
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
logger.error("Failed to generate signature", e);
|
||||
throw new RuntimeException("Signature generation failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifySignature(ServerHttpRequest request, String secret) {
|
||||
if (!signatureEnabled) {
|
||||
logger.debug("Signature verification is disabled");
|
||||
return true;
|
||||
}
|
||||
|
||||
String signature = request.getHeaders().getFirst(SIGNATURE_HEADER);
|
||||
String timestampStr = request.getHeaders().getFirst(TIMESTAMP_HEADER);
|
||||
String nonce = request.getHeaders().getFirst(NONCE_HEADER);
|
||||
|
||||
if (signature == null || timestampStr == null || nonce == null) {
|
||||
logger.warn("Missing signature headers - Signature: {}, Timestamp: {}, Nonce: {}",
|
||||
signature, timestampStr, nonce);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
long timestamp = Long.parseLong(timestampStr);
|
||||
|
||||
if (!isTimestampValid(timestamp, maxAgeMinutes)) {
|
||||
logger.warn("Timestamp is invalid or expired: {}", timestamp);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNonceUsed(nonce)) {
|
||||
logger.warn("Nonce has been used: {}", nonce);
|
||||
return false;
|
||||
}
|
||||
|
||||
String method = request.getMethod().name();
|
||||
String path = request.getPath().value();
|
||||
String query = request.getURI().getQuery() != null ? request.getURI().getQuery() : "";
|
||||
String body = ""; // 在WebFlux中,请求体需要特殊处理
|
||||
|
||||
String expectedSignature = generateSignature(method, path, query, body, timestamp, nonce, secret);
|
||||
|
||||
boolean isValid = signature.equals(expectedSignature);
|
||||
|
||||
if (isValid) {
|
||||
recordNonce(nonce);
|
||||
logger.debug("Signature verification passed for request: {} {}", method, path);
|
||||
} else {
|
||||
logger.warn("Signature verification failed - Expected: {}, Actual: {}", expectedSignature, signature);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
|
||||
} catch (NumberFormatException e) {
|
||||
logger.error("Invalid timestamp format: {}", timestampStr, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTimestampValid(long timestamp, int maxAgeMinutes) {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long timeDifference = Math.abs(currentTime - timestamp);
|
||||
long maxAgeMillis = TimeUnit.MINUTES.toMillis(maxAgeMinutes);
|
||||
|
||||
boolean isValid = timeDifference <= maxAgeMillis;
|
||||
|
||||
if (!isValid) {
|
||||
logger.debug("Timestamp validation failed - Current: {}, Request: {}, Difference: {}ms, Max: {}ms",
|
||||
currentTime, timestamp, timeDifference, maxAgeMillis);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isNonceUsed(String nonce) {
|
||||
return nonceCache.containsKey(nonce);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordNonce(String nonce) {
|
||||
nonceCache.put(nonce, System.currentTimeMillis());
|
||||
logger.debug("Recorded nonce: {}", nonce);
|
||||
|
||||
if (nonceCache.size() > nonceCacheSize) {
|
||||
cleanupExpiredNonces();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanupExpiredNonces() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long expirationTime = TimeUnit.MINUTES.toMillis(maxAgeMinutes * 2);
|
||||
|
||||
int initialSize = nonceCache.size();
|
||||
|
||||
nonceCache.entrySet().removeIf(entry ->
|
||||
(currentTime - entry.getValue()) > expirationTime);
|
||||
|
||||
int removedCount = initialSize - nonceCache.size();
|
||||
|
||||
if (removedCount > 0) {
|
||||
logger.info("Cleaned up {} expired nonces. Current cache size: {}",
|
||||
removedCount, nonceCache.size());
|
||||
}
|
||||
}
|
||||
|
||||
private String buildStringToSign(
|
||||
String method,
|
||||
String path,
|
||||
String query,
|
||||
String body,
|
||||
long timestamp,
|
||||
String nonce) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(method).append("\n");
|
||||
sb.append(path).append("\n");
|
||||
sb.append(query != null ? query : "").append("\n");
|
||||
sb.append(body != null ? body : "").append("\n");
|
||||
sb.append(timestamp).append("\n");
|
||||
sb.append(nonce);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public int getNonceCacheSize() {
|
||||
return nonceCache.size();
|
||||
}
|
||||
}
|
||||
+48
-17
@@ -1,8 +1,11 @@
|
||||
package cn.novalon.manage.gateway.util;
|
||||
|
||||
import cn.novalon.manage.gateway.service.JwtKeyService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -13,35 +16,53 @@ import java.util.Date;
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
@Value("${jwt.secret:mySecretKey}")
|
||||
private String secret;
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class);
|
||||
|
||||
@Value("${jwt.expiration:86400000}")
|
||||
private Long expiration;
|
||||
|
||||
@Autowired
|
||||
private JwtKeyService jwtKeyService;
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
return jwtKeyService.getCurrentSigningKey();
|
||||
}
|
||||
|
||||
public String generateToken(String username, Long userId) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(username)
|
||||
.claim("userId", userId)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
try {
|
||||
String token = Jwts.builder()
|
||||
.setSubject(username)
|
||||
.claim("userId", userId)
|
||||
.claim("keyVersion", jwtKeyService.getCurrentKeyVersion())
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
|
||||
logger.debug("Generated JWT token for user: {}, userId: {}", username, userId);
|
||||
return token;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to generate JWT token for user: {}", username, e);
|
||||
throw new RuntimeException("Token generation failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKey())
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
try {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKey())
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to parse JWT token", e);
|
||||
throw new RuntimeException("Invalid token", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getUsernameFromToken(String token) {
|
||||
@@ -57,8 +78,10 @@ public class JwtUtil {
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseToken(token);
|
||||
logger.debug("JWT token validation successful");
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
logger.warn("JWT token validation failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -66,8 +89,16 @@ public class JwtUtil {
|
||||
public boolean isTokenExpired(String token) {
|
||||
try {
|
||||
Claims claims = parseToken(token);
|
||||
return claims.getExpiration().before(new Date());
|
||||
boolean expired = claims.getExpiration().before(new Date());
|
||||
|
||||
if (expired) {
|
||||
logger.warn("JWT token is expired");
|
||||
}
|
||||
|
||||
return expired;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to check token expiration", e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,21 +26,116 @@ spring:
|
||||
basedOnPreviousValue: false
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:mySecretKeyForNovalonManageSystem2024}
|
||||
secret: ${JWT_SECRET:enc:U2FsdGVkX1+vZ5Y9QmKxL8nN3rP7tW2jH4fG6dA8sB1cE5yN0zX3qV7wM4}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
key:
|
||||
encryption:
|
||||
password: ${JWT_KEY_ENCRYPTION_PASSWORD:}
|
||||
rotation:
|
||||
enabled: ${JWT_KEY_ROTATION_ENABLED:true}
|
||||
interval:
|
||||
days: ${JWT_KEY_ROTATION_INTERVAL_DAYS:30}
|
||||
|
||||
rate:
|
||||
limit:
|
||||
enabled: ${RATE_LIMIT_ENABLED:true}
|
||||
global:
|
||||
limit-for-period: ${RATE_LIMIT_GLOBAL_LIMIT:1000}
|
||||
limit-refresh-period: ${RATE_LIMIT_GLOBAL_PERIOD:1s}
|
||||
timeout-duration: ${RATE_LIMIT_GLOBAL_TIMEOUT:0}
|
||||
ip:
|
||||
limit-for-period: ${RATE_LIMIT_IP_LIMIT:100}
|
||||
limit-refresh-period: ${RATE_LIMIT_IP_PERIOD:1s}
|
||||
timeout-duration: ${RATE_LIMIT_IP_TIMEOUT:0}
|
||||
user:
|
||||
limit-for-period: ${RATE_LIMIT_USER_LIMIT:200}
|
||||
limit-refresh-period: ${RATE_LIMIT_USER_PERIOD:1s}
|
||||
timeout-duration: ${RATE_LIMIT_USER_TIMEOUT:0}
|
||||
|
||||
signature:
|
||||
enabled: ${SIGNATURE_ENABLED:true}
|
||||
secret: ${SIGNATURE_SECRET:NovalonManageSystemSecretKey2026}
|
||||
max-age-minutes: ${SIGNATURE_MAX_AGE_MINUTES:5}
|
||||
nonce-cache-size: ${SIGNATURE_NONCE_CACHE_SIZE:10000}
|
||||
whitelist:
|
||||
paths: ${SIGNATURE_WHITELIST_PATHS:/actuator/health,/actuator/info}
|
||||
|
||||
resilience:
|
||||
enabled: ${RESILIENCE_ENABLED:true}
|
||||
circuit-breaker:
|
||||
enabled: ${RESILIENCE_CIRCUIT_BREAKER_ENABLED:true}
|
||||
failure-rate-threshold: ${RESILIENCE_CB_FAILURE_RATE:50}
|
||||
slow-call-rate-threshold: ${RESILIENCE_CB_SLOW_CALL_RATE:100}
|
||||
slow-call-duration-threshold: ${RESILIENCE_CB_SLOW_CALL_DURATION:2s}
|
||||
permitted-number-of-calls-in-half-open-state: ${RESILIENCE_CB_HALF_OPEN_CALLS:10}
|
||||
sliding-window-type: ${RESILIENCE_CB_SLIDING_WINDOW_TYPE:COUNT_BASED}
|
||||
sliding-window-size: ${RESILIENCE_CB_SLIDING_WINDOW_SIZE:100}
|
||||
minimum-number-of-calls: ${RESILIENCE_CB_MIN_CALLS:10}
|
||||
wait-duration-in-open-state: ${RESILIENCE_CB_WAIT_DURATION:10s}
|
||||
retry:
|
||||
enabled: ${RESILIENCE_RETRY_ENABLED:true}
|
||||
max-attempts: ${RESILIENCE_RETRY_MAX_ATTEMPTS:3}
|
||||
wait-duration: ${RESILIENCE_RETRY_WAIT_DURATION:500ms}
|
||||
timeout:
|
||||
enabled: ${RESILIENCE_TIMEOUT_ENABLED:true}
|
||||
duration: ${RESILIENCE_TIMEOUT_DURATION:3s}
|
||||
|
||||
user:
|
||||
service:
|
||||
url: ${USER_SERVICE_URL:http://localhost:8084}
|
||||
|
||||
permission:
|
||||
cache:
|
||||
expiry:
|
||||
minutes: 5
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
include: health,info,metrics,env,loggers,httptrace,threaddump,heapdump
|
||||
base-path: /actuator
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
probes:
|
||||
enabled: true
|
||||
group:
|
||||
liveness:
|
||||
include: ping,livenessState
|
||||
readiness:
|
||||
include: ping,readinessState
|
||||
metrics:
|
||||
enabled: true
|
||||
env:
|
||||
enabled: true
|
||||
loggers:
|
||||
enabled: true
|
||||
httptrace:
|
||||
enabled: true
|
||||
health:
|
||||
livenessstate:
|
||||
enabled: true
|
||||
readinessstate:
|
||||
enabled: true
|
||||
circuitbreakers:
|
||||
enabled: true
|
||||
ratelimiters:
|
||||
enabled: true
|
||||
metrics:
|
||||
tags:
|
||||
application: ${spring.application.name}
|
||||
distribution:
|
||||
percentiles-histogram:
|
||||
http.server.requests: true
|
||||
percentiles:
|
||||
http.server.requests: 0.5,0.95,0.99
|
||||
web:
|
||||
server:
|
||||
request:
|
||||
autotime:
|
||||
enabled: true
|
||||
percentiles: 0.5,0.95,0.99
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package cn.novalon.manage.gateway.audit;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* AuditLogService单元测试
|
||||
*
|
||||
* 文件定义:测试审计日志服务的核心功能
|
||||
* 涉及业务:请求日志记录、响应日志记录、安全事件记录
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuditLogServiceTest {
|
||||
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
auditLogService = new AuditLogService();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogRequest() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.header("X-Request-Id", "test-request-123")
|
||||
.header("User-Agent", "TestAgent")
|
||||
.remoteAddress(new InetSocketAddress("192.168.1.1", 8080))
|
||||
.build();
|
||||
|
||||
assertDoesNotThrow(() -> auditLogService.logRequest(request, "user123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogResponse() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.header("X-Request-Id", "test-request-456")
|
||||
.build();
|
||||
|
||||
auditLogService.logRequest(request, "user123");
|
||||
|
||||
assertDoesNotThrow(() -> auditLogService.logResponse("test-request-456", 200, 150));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogSecurityEvent() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/admin")
|
||||
.header("X-Request-Id", "test-request-789")
|
||||
.build();
|
||||
|
||||
auditLogService.logRequest(request, "user123");
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
auditLogService.logSecurityEvent("test-request-789", "UNAUTHORIZED_ACCESS", "User attempted to access admin resource"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogError() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.POST, "/api/data")
|
||||
.header("X-Request-Id", "test-request-error")
|
||||
.build();
|
||||
|
||||
auditLogService.logRequest(request, "user123");
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
auditLogService.logError("test-request-error", "INTERNAL_ERROR", "Database connection failed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogRequestWithoutRequestId() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/test")
|
||||
.remoteAddress(new InetSocketAddress("10.0.0.1", 8080))
|
||||
.build();
|
||||
|
||||
assertDoesNotThrow(() -> auditLogService.logRequest(request, "user456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogResponseWithNonExistentRequestId() {
|
||||
assertDoesNotThrow(() -> auditLogService.logResponse("non-existent-id", 404, 50));
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package cn.novalon.manage.gateway.cache;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RequestCacheServiceTest {
|
||||
|
||||
private RequestCacheService cacheService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cacheService = new RequestCacheService();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGet_CacheMiss() {
|
||||
ServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.build();
|
||||
|
||||
StepVerifier.create(cacheService.get(request))
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPutAndGet_CacheHit() {
|
||||
ServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.build();
|
||||
|
||||
String response = "{\"data\":\"test\"}";
|
||||
cacheService.put(request, response);
|
||||
|
||||
StepVerifier.create(cacheService.get(request))
|
||||
.expectNext(response)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEvict() {
|
||||
ServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.build();
|
||||
|
||||
String response = "{\"data\":\"test\"}";
|
||||
cacheService.put(request, response);
|
||||
|
||||
cacheService.evict(request);
|
||||
|
||||
StepVerifier.create(cacheService.get(request))
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEvictByPattern() {
|
||||
ServerHttpRequest request1 = MockServerHttpRequest
|
||||
.get("/api/test1")
|
||||
.build();
|
||||
ServerHttpRequest request2 = MockServerHttpRequest
|
||||
.get("/api/test2")
|
||||
.build();
|
||||
|
||||
cacheService.put(request1, "response1");
|
||||
cacheService.put(request2, "response2");
|
||||
|
||||
cacheService.evictByPattern("GET:/api/test.*");
|
||||
|
||||
assertEquals(0, cacheService.getCacheSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClear() {
|
||||
ServerHttpRequest request1 = MockServerHttpRequest
|
||||
.get("/api/test1")
|
||||
.build();
|
||||
ServerHttpRequest request2 = MockServerHttpRequest
|
||||
.get("/api/test2")
|
||||
.build();
|
||||
|
||||
cacheService.put(request1, "response1");
|
||||
cacheService.put(request2, "response2");
|
||||
|
||||
cacheService.clear();
|
||||
|
||||
assertEquals(0, cacheService.getCacheSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCacheDisabled() {
|
||||
cacheService.setCacheEnabled(false);
|
||||
|
||||
ServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.build();
|
||||
|
||||
cacheService.put(request, "response");
|
||||
|
||||
StepVerifier.create(cacheService.get(request))
|
||||
.verifyComplete();
|
||||
|
||||
assertEquals(0, cacheService.getCacheSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCacheStatistics() {
|
||||
ServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.build();
|
||||
|
||||
cacheService.put(request, "response");
|
||||
|
||||
StepVerifier.create(cacheService.get(request))
|
||||
.expectNext("response")
|
||||
.verifyComplete();
|
||||
|
||||
assertEquals(1, cacheService.getHitCount());
|
||||
assertEquals(0, cacheService.getMissCount());
|
||||
assertEquals(1.0, cacheService.getHitRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCacheMissStatistics() {
|
||||
ServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.build();
|
||||
|
||||
StepVerifier.create(cacheService.get(request))
|
||||
.verifyComplete();
|
||||
|
||||
assertEquals(0, cacheService.getHitCount());
|
||||
assertEquals(1, cacheService.getMissCount());
|
||||
assertEquals(0.0, cacheService.getHitRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMaxCacheSize() {
|
||||
cacheService.setMaxCacheSize(5);
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
ServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test" + i)
|
||||
.build();
|
||||
cacheService.put(request, "response" + i);
|
||||
}
|
||||
|
||||
assertTrue(cacheService.getCacheSize() <= 10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCacheWithQueryParams() {
|
||||
ServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test?param=value")
|
||||
.build();
|
||||
|
||||
String response = "{\"data\":\"test\"}";
|
||||
cacheService.put(request, response);
|
||||
|
||||
StepVerifier.create(cacheService.get(request))
|
||||
.expectNext(response)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCacheWithDifferentMethods() {
|
||||
ServerHttpRequest getRequest = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.build();
|
||||
ServerHttpRequest postRequest = MockServerHttpRequest
|
||||
.post("/api/test")
|
||||
.build();
|
||||
|
||||
cacheService.put(getRequest, "getResponse");
|
||||
cacheService.put(postRequest, "postResponse");
|
||||
|
||||
StepVerifier.create(cacheService.get(getRequest))
|
||||
.expectNext("getResponse")
|
||||
.verifyComplete();
|
||||
|
||||
StepVerifier.create(cacheService.get(postRequest))
|
||||
.expectNext("postResponse")
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package cn.novalon.manage.gateway.config;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiter;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ResilienceConfig单元测试
|
||||
*
|
||||
* 文件定义:测试Resilience4j配置类的核心功能
|
||||
* 涉及业务:断路器、重试、超时配置
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ResilienceConfigTest {
|
||||
|
||||
@InjectMocks
|
||||
private ResilienceConfig resilienceConfig;
|
||||
|
||||
@Test
|
||||
void testCircuitBreakerRegistry_ShouldCreateRegistry() {
|
||||
ReflectionTestUtils.setField(resilienceConfig, "circuitBreakerEnabled", true);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "failureRateThreshold", 50.0f);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "slowCallRateThreshold", 100.0f);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "slowCallDurationThreshold", java.time.Duration.ofSeconds(2));
|
||||
ReflectionTestUtils.setField(resilienceConfig, "permittedNumberOfCallsInHalfOpenState", 10);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "slidingWindowType", "COUNT_BASED");
|
||||
ReflectionTestUtils.setField(resilienceConfig, "slidingWindowSize", 100);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "minimumNumberOfCalls", 10);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "waitDurationInOpenState", java.time.Duration.ofSeconds(10));
|
||||
|
||||
CircuitBreakerRegistry registry = resilienceConfig.circuitBreakerRegistry();
|
||||
|
||||
assertNotNull(registry);
|
||||
assertNotNull(registry.getConfiguration("default"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGatewayCircuitBreaker_ShouldCreateCircuitBreaker() {
|
||||
ReflectionTestUtils.setField(resilienceConfig, "circuitBreakerEnabled", true);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "failureRateThreshold", 50.0f);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "slowCallRateThreshold", 100.0f);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "slowCallDurationThreshold", java.time.Duration.ofSeconds(2));
|
||||
ReflectionTestUtils.setField(resilienceConfig, "permittedNumberOfCallsInHalfOpenState", 10);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "slidingWindowType", "COUNT_BASED");
|
||||
ReflectionTestUtils.setField(resilienceConfig, "slidingWindowSize", 100);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "minimumNumberOfCalls", 10);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "waitDurationInOpenState", java.time.Duration.ofSeconds(10));
|
||||
|
||||
CircuitBreakerRegistry registry = resilienceConfig.circuitBreakerRegistry();
|
||||
CircuitBreaker circuitBreaker = resilienceConfig.gatewayCircuitBreaker(registry);
|
||||
|
||||
assertNotNull(circuitBreaker);
|
||||
assertEquals("gateway", circuitBreaker.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRetryRegistry_ShouldCreateRegistry() {
|
||||
ReflectionTestUtils.setField(resilienceConfig, "retryEnabled", true);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "retryMaxAttempts", 3);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "retryWaitDuration", java.time.Duration.ofMillis(500));
|
||||
|
||||
RetryRegistry registry = resilienceConfig.retryRegistry();
|
||||
|
||||
assertNotNull(registry);
|
||||
assertNotNull(registry.getConfiguration("default"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGatewayRetry_ShouldCreateRetry() {
|
||||
ReflectionTestUtils.setField(resilienceConfig, "retryEnabled", true);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "retryMaxAttempts", 3);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "retryWaitDuration", java.time.Duration.ofMillis(500));
|
||||
|
||||
RetryRegistry registry = resilienceConfig.retryRegistry();
|
||||
Retry retry = resilienceConfig.gatewayRetry(registry);
|
||||
|
||||
assertNotNull(retry);
|
||||
assertEquals("gateway", retry.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTimeLimiterRegistry_ShouldCreateRegistry() {
|
||||
ReflectionTestUtils.setField(resilienceConfig, "timeoutEnabled", true);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "timeoutDuration", java.time.Duration.ofSeconds(3));
|
||||
|
||||
TimeLimiterRegistry registry = resilienceConfig.timeLimiterRegistry();
|
||||
|
||||
assertNotNull(registry);
|
||||
assertNotNull(registry.getConfiguration("default"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGatewayTimeLimiter_ShouldCreateTimeLimiter() {
|
||||
ReflectionTestUtils.setField(resilienceConfig, "timeoutEnabled", true);
|
||||
ReflectionTestUtils.setField(resilienceConfig, "timeoutDuration", java.time.Duration.ofSeconds(3));
|
||||
|
||||
TimeLimiterRegistry registry = resilienceConfig.timeLimiterRegistry();
|
||||
TimeLimiter timeLimiter = resilienceConfig.gatewayTimeLimiter(registry);
|
||||
|
||||
assertNotNull(timeLimiter);
|
||||
assertEquals("gateway", timeLimiter.getName());
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CompressionFilterTest {
|
||||
|
||||
private CompressionFilter compressionFilter;
|
||||
|
||||
@Mock
|
||||
private GatewayFilterChain chain;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
compressionFilter = new CompressionFilter();
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WithGzipSupport() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.header("Accept-Encoding", "gzip, deflate")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
compressionFilter.filter(exchange, chain).block();
|
||||
|
||||
assertEquals("gzip", exchange.getResponse().getHeaders().getFirst("Content-Encoding"));
|
||||
verify(chain).filter(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WithDeflateSupport() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.header("Accept-Encoding", "deflate")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
compressionFilter.filter(exchange, chain).block();
|
||||
|
||||
assertEquals("deflate", exchange.getResponse().getHeaders().getFirst("Content-Encoding"));
|
||||
verify(chain).filter(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_NoAcceptEncoding() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
compressionFilter.filter(exchange, chain).block();
|
||||
|
||||
assertNull(exchange.getResponse().getHeaders().getFirst("Content-Encoding"));
|
||||
verify(chain).filter(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_CompressionDisabled() {
|
||||
compressionFilter.setCompressionEnabled(false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.header("Accept-Encoding", "gzip")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
compressionFilter.filter(exchange, chain).block();
|
||||
|
||||
assertNull(exchange.getResponse().getHeaders().getFirst("Content-Encoding"));
|
||||
verify(chain).filter(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_OptionsRequest() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.OPTIONS, "/api/test")
|
||||
.header("Accept-Encoding", "gzip")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
compressionFilter.filter(exchange, chain).block();
|
||||
|
||||
assertNull(exchange.getResponse().getHeaders().getFirst("Content-Encoding"));
|
||||
verify(chain).filter(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_VaryHeader() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.header("Accept-Encoding", "gzip")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
compressionFilter.filter(exchange, chain).block();
|
||||
|
||||
assertTrue(exchange.getResponse().getHeaders().get("Vary").contains("Accept-Encoding"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOrder() {
|
||||
assertEquals(Integer.MAX_VALUE - 100, compressionFilter.getOrder());
|
||||
}
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import cn.novalon.manage.gateway.config.RateLimitConfig;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RateLimitFilterTest {
|
||||
|
||||
@Mock
|
||||
private RateLimiter globalRateLimiter;
|
||||
|
||||
@Mock
|
||||
private RateLimiter ipRateLimiter;
|
||||
|
||||
@Mock
|
||||
private RateLimiter userRateLimiter;
|
||||
|
||||
@Mock
|
||||
private RateLimitConfig rateLimitConfig;
|
||||
|
||||
@Mock
|
||||
private GatewayFilterChain chain;
|
||||
|
||||
private RateLimitFilter rateLimitFilter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(rateLimitConfig.isRateLimitEnabled()).thenReturn(true);
|
||||
|
||||
RateLimiterConfig config = RateLimiterConfig.custom()
|
||||
.limitForPeriod(100)
|
||||
.limitRefreshPeriod(Duration.ofSeconds(1))
|
||||
.timeoutDuration(Duration.ZERO)
|
||||
.build();
|
||||
|
||||
lenient().when(globalRateLimiter.getRateLimiterConfig()).thenReturn(config);
|
||||
lenient().when(ipRateLimiter.getRateLimiterConfig()).thenReturn(config);
|
||||
lenient().when(userRateLimiter.getRateLimiterConfig()).thenReturn(config);
|
||||
|
||||
rateLimitFilter = new RateLimitFilter(
|
||||
globalRateLimiter,
|
||||
ipRateLimiter,
|
||||
userRateLimiter,
|
||||
rateLimitConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenRateLimitDisabled_ShouldPassThrough() {
|
||||
when(rateLimitConfig.isRateLimitEnabled()).thenReturn(false);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
verify(globalRateLimiter, never()).acquirePermission();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenGlobalRateLimitExceeded_ShouldReturn429() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.remoteAddress(new InetSocketAddress("192.168.1.1", 12345))
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
assertEquals(HttpStatus.TOO_MANY_REQUESTS, exchange.getResponse().getStatusCode());
|
||||
verify(chain, never()).filter(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenAllRateLimitsPass_ShouldContinueChain() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(true);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.header("X-User-Id", "user123")
|
||||
.remoteAddress(new InetSocketAddress("192.168.1.1", 12345))
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
verify(globalRateLimiter).acquirePermission();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WithoutUserId_ShouldSkipUserRateLimit() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(true);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.remoteAddress(new InetSocketAddress("192.168.1.1", 12345))
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetClientIp_FromXForwardedFor() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(true);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.header("X-Forwarded-For", "10.0.0.1, 192.168.1.1")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetClientIp_FromXRealIP() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(true);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.header("X-Real-IP", "10.0.0.2")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetClientIp_FromRemoteAddress() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(true);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.remoteAddress(new InetSocketAddress("192.168.1.100", 12345))
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRateLimitHeaders_WhenExceeded() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.remoteAddress(new InetSocketAddress("192.168.1.1", 12345))
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
ServerHttpResponse response = exchange.getResponse();
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
|
||||
assertTrue(headers.containsKey("X-RateLimit-Limit"));
|
||||
assertTrue(headers.containsKey("X-RateLimit-Remaining"));
|
||||
assertTrue(headers.containsKey("Retry-After"));
|
||||
assertTrue(headers.containsKey("X-RateLimit-Type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCounters_WhenRequestsProcessed() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(true);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.remoteAddress(new InetSocketAddress("192.168.1.1", 12345))
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
assertEquals(1, rateLimitFilter.getTotalRequests());
|
||||
assertEquals(0, rateLimitFilter.getBlockedRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCounters_WhenRequestsBlocked() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.remoteAddress(new InetSocketAddress("192.168.1.1", 12345))
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
assertEquals(1, rateLimitFilter.getTotalRequests());
|
||||
assertEquals(1, rateLimitFilter.getBlockedRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResetCounters() {
|
||||
when(globalRateLimiter.acquirePermission()).thenReturn(true);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.get("/api/test")
|
||||
.remoteAddress(new InetSocketAddress("192.168.1.1", 12345))
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
StepVerifier.create(rateLimitFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
assertEquals(1, rateLimitFilter.getTotalRequests());
|
||||
|
||||
rateLimitFilter.resetCounters();
|
||||
|
||||
assertEquals(0, rateLimitFilter.getTotalRequests());
|
||||
assertEquals(0, rateLimitFilter.getBlockedRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOrder() {
|
||||
int order = rateLimitFilter.getOrder();
|
||||
assertEquals(Ordered.HIGHEST_PRECEDENCE + 100, order);
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -1,5 +1,6 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import cn.novalon.manage.gateway.service.PermissionService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -22,12 +23,15 @@ class RbacAuthorizationFilterTest {
|
||||
@Mock
|
||||
private GatewayFilterChain chain;
|
||||
|
||||
@Mock
|
||||
private PermissionService permissionService;
|
||||
|
||||
private RbacAuthorizationFilter filter;
|
||||
private ServerWebExchange exchange;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
filter = new RbacAuthorizationFilter();
|
||||
filter = new RbacAuthorizationFilter(permissionService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -116,6 +120,7 @@ class RbacAuthorizationFilterTest {
|
||||
.build();
|
||||
exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(permissionService.hasPermission(eq(1L), eq("/api/users"), eq("GET"))).thenReturn(true);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
@@ -134,6 +139,7 @@ class RbacAuthorizationFilterTest {
|
||||
.build();
|
||||
exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(permissionService.hasPermission(eq(1L), eq("/api/users"), eq("POST"))).thenReturn(true);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
@@ -152,6 +158,7 @@ class RbacAuthorizationFilterTest {
|
||||
.build();
|
||||
exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(permissionService.hasPermission(eq(1L), eq("/api/users/1"), eq("PUT"))).thenReturn(true);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
@@ -170,6 +177,7 @@ class RbacAuthorizationFilterTest {
|
||||
.build();
|
||||
exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(permissionService.hasPermission(eq(1L), eq("/api/users/1"), eq("DELETE"))).thenReturn(true);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
@@ -188,15 +196,14 @@ class RbacAuthorizationFilterTest {
|
||||
.build();
|
||||
exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(exchange, chain);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(any(ServerWebExchange.class));
|
||||
assert exchange.getResponse().getStatusCode() == HttpStatus.UNAUTHORIZED;
|
||||
verify(chain, never()).filter(any(ServerWebExchange.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -222,6 +229,7 @@ class RbacAuthorizationFilterTest {
|
||||
.build();
|
||||
exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(permissionService.hasPermission(eq(1L), eq("/api/users/profile"), eq("GET"))).thenReturn(true);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
@@ -240,6 +248,7 @@ class RbacAuthorizationFilterTest {
|
||||
.build();
|
||||
exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(permissionService.hasPermission(eq(1L), eq("/actuator/metrics"), eq("GET"))).thenReturn(true);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryConfig;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiter;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* ResilienceFilter单元测试
|
||||
*
|
||||
* 文件定义:测试容错过滤器的核心功能
|
||||
* 涉及业务:断路器、重试、超时、降级
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ResilienceFilterTest {
|
||||
|
||||
@Mock
|
||||
private GatewayFilterChain chain;
|
||||
|
||||
private CircuitBreaker circuitBreaker;
|
||||
private Retry retry;
|
||||
private TimeLimiter timeLimiter;
|
||||
private ResilienceFilter resilienceFilter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom()
|
||||
.failureRateThreshold(50)
|
||||
.slidingWindowSize(100)
|
||||
.minimumNumberOfCalls(10)
|
||||
.waitDurationInOpenState(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
RetryConfig retryConfig = RetryConfig.custom()
|
||||
.maxAttempts(3)
|
||||
.waitDuration(Duration.ofMillis(500))
|
||||
.build();
|
||||
|
||||
TimeLimiterConfig tlConfig = TimeLimiterConfig.custom()
|
||||
.timeoutDuration(Duration.ofSeconds(3))
|
||||
.build();
|
||||
|
||||
circuitBreaker = CircuitBreaker.of("gateway", cbConfig);
|
||||
retry = Retry.of("gateway", retryConfig);
|
||||
timeLimiter = TimeLimiter.of("gateway", tlConfig);
|
||||
|
||||
resilienceFilter = new ResilienceFilter(circuitBreaker, retry, timeLimiter);
|
||||
|
||||
ReflectionTestUtils.setField(resilienceFilter, "resilienceEnabled", true);
|
||||
ReflectionTestUtils.setField(resilienceFilter, "circuitBreakerEnabled", true);
|
||||
ReflectionTestUtils.setField(resilienceFilter, "retryEnabled", true);
|
||||
ReflectionTestUtils.setField(resilienceFilter, "timeoutEnabled", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenResilienceDisabled_ShouldContinueChain() {
|
||||
ReflectionTestUtils.setField(resilienceFilter, "resilienceEnabled", false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(resilienceFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenAllPatternsEnabled_ShouldApplyResilience() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(resilienceFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenCircuitBreakerDisabled_ShouldSkipCircuitBreaker() {
|
||||
ReflectionTestUtils.setField(resilienceFilter, "circuitBreakerEnabled", false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(resilienceFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenRetryDisabled_ShouldSkipRetry() {
|
||||
ReflectionTestUtils.setField(resilienceFilter, "retryEnabled", false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(resilienceFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenTimeoutDisabled_ShouldSkipTimeout() {
|
||||
ReflectionTestUtils.setField(resilienceFilter, "timeoutEnabled", false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(resilienceFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenChainThrowsException_ShouldHandleFallback() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.error(new RuntimeException("Test error")));
|
||||
|
||||
StepVerifier.create(resilienceFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, exchange.getResponse().getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOrder_ShouldReturnCorrectOrder() {
|
||||
int order = resilienceFilter.getOrder();
|
||||
|
||||
assertEquals(-2147483448, order);
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package cn.novalon.manage.gateway.filter;
|
||||
|
||||
import cn.novalon.manage.gateway.service.SignatureService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* SignatureFilter单元测试
|
||||
*
|
||||
* 文件定义:测试签名验证过滤器的核心功能
|
||||
* 涉及业务:签名验证、白名单过滤、错误响应
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SignatureFilterTest {
|
||||
|
||||
@Mock
|
||||
private SignatureService signatureService;
|
||||
|
||||
@Mock
|
||||
private GatewayFilterChain chain;
|
||||
|
||||
@InjectMocks
|
||||
private SignatureFilter signatureFilter;
|
||||
|
||||
private static final String TEST_SECRET = "TestSecretKey123";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(signatureFilter, "signatureEnabled", true);
|
||||
ReflectionTestUtils.setField(signatureFilter, "signatureSecret", TEST_SECRET);
|
||||
ReflectionTestUtils.setField(signatureFilter, "whitelistPaths", "/actuator/health,/actuator/info");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenSignatureDisabled_ShouldContinueChain() {
|
||||
ReflectionTestUtils.setField(signatureFilter, "signatureEnabled", false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(signatureFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
verify(signatureService, never()).verifySignature(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenPathIsWhitelisted_ShouldContinueChain() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/actuator/health")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(signatureFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
verify(signatureService, never()).verifySignature(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenSignatureValid_ShouldContinueChain() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.header("X-Signature", "valid-signature")
|
||||
.header("X-Timestamp", String.valueOf(System.currentTimeMillis()))
|
||||
.header("X-Nonce", "test-nonce")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(signatureService.verifySignature(any(), any())).thenReturn(true);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(signatureFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(signatureService).verifySignature(request, TEST_SECRET);
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenSignatureInvalid_ShouldReturnUnauthorized() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.header("X-Signature", "invalid-signature")
|
||||
.header("X-Timestamp", String.valueOf(System.currentTimeMillis()))
|
||||
.header("X-Nonce", "test-nonce")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(signatureService.verifySignature(any(), any())).thenReturn(false);
|
||||
|
||||
StepVerifier.create(signatureFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(signatureService).verifySignature(request, TEST_SECRET);
|
||||
verify(chain, never()).filter(any());
|
||||
|
||||
assertEquals(HttpStatus.UNAUTHORIZED, exchange.getResponse().getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenMissingSignatureHeaders_ShouldReturnUnauthorized() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(signatureService.verifySignature(any(), any())).thenReturn(false);
|
||||
|
||||
StepVerifier.create(signatureFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(signatureService).verifySignature(request, TEST_SECRET);
|
||||
verify(chain, never()).filter(any());
|
||||
|
||||
assertEquals(HttpStatus.UNAUTHORIZED, exchange.getResponse().getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenMultipleWhitelistPaths_ShouldMatchAny() {
|
||||
MockServerHttpRequest request1 = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/actuator/health")
|
||||
.build();
|
||||
|
||||
MockServerHttpRequest request2 = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/actuator/info")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange1 = MockServerWebExchange.builder(request1).build();
|
||||
MockServerWebExchange exchange2 = MockServerWebExchange.builder(request2).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(signatureFilter.filter(exchange1, chain))
|
||||
.verifyComplete();
|
||||
|
||||
StepVerifier.create(signatureFilter.filter(exchange2, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain, times(2)).filter(any());
|
||||
verify(signatureService, never()).verifySignature(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenPathStartsWithWhitelist_ShouldMatch() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/actuator/health/details")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(signatureFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
verify(signatureService, never()).verifySignature(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOrder_ShouldReturnCorrectOrder() {
|
||||
int order = signatureFilter.getOrder();
|
||||
|
||||
assertEquals(-2147483498, order);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFilter_WhenSignatureEnabled_ShouldVerifySignature() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.header("X-Signature", "test-signature")
|
||||
.header("X-Timestamp", String.valueOf(System.currentTimeMillis()))
|
||||
.header("X-Nonce", "test-nonce")
|
||||
.build();
|
||||
|
||||
MockServerWebExchange exchange = MockServerWebExchange.builder(request).build();
|
||||
|
||||
when(signatureService.verifySignature(any(), any())).thenReturn(true);
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(signatureFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(signatureService).verifySignature(request, TEST_SECRET);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package cn.novalon.manage.gateway.health;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.Status;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* GatewayHealthIndicator单元测试
|
||||
*
|
||||
* 文件定义:测试网关健康检查指示器的核心功能
|
||||
* 涉及业务:断路器健康检查、限流器健康检查
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GatewayHealthIndicatorTest {
|
||||
|
||||
private CircuitBreakerRegistry circuitBreakerRegistry;
|
||||
private RateLimiterRegistry rateLimiterRegistry;
|
||||
private GatewayHealthIndicator healthIndicator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom()
|
||||
.failureRateThreshold(50)
|
||||
.slidingWindowSize(100)
|
||||
.minimumNumberOfCalls(10)
|
||||
.waitDurationInOpenState(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
RateLimiterConfig rlConfig = RateLimiterConfig.custom()
|
||||
.limitForPeriod(100)
|
||||
.limitRefreshPeriod(Duration.ofSeconds(1))
|
||||
.build();
|
||||
|
||||
circuitBreakerRegistry = CircuitBreakerRegistry.of(cbConfig);
|
||||
rateLimiterRegistry = RateLimiterRegistry.of(rlConfig);
|
||||
|
||||
healthIndicator = new GatewayHealthIndicator(circuitBreakerRegistry, rateLimiterRegistry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHealth_WhenAllComponentsHealthy_ShouldReturnUp() {
|
||||
circuitBreakerRegistry.circuitBreaker("test-cb");
|
||||
rateLimiterRegistry.rateLimiter("test-rl");
|
||||
|
||||
Health health = healthIndicator.health();
|
||||
|
||||
assertEquals(Status.UP, health.getStatus());
|
||||
assertTrue(health.getDetails().containsKey("circuitBreakers"));
|
||||
assertTrue(health.getDetails().containsKey("rateLimiters"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHealth_WhenNoComponents_ShouldReturnUp() {
|
||||
Health health = healthIndicator.health();
|
||||
|
||||
assertEquals(Status.UP, health.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHealth_ShouldIncludeComponentDetails() {
|
||||
circuitBreakerRegistry.circuitBreaker("gateway");
|
||||
rateLimiterRegistry.rateLimiter("gateway");
|
||||
|
||||
Health health = healthIndicator.health();
|
||||
|
||||
assertTrue(health.getDetails().containsKey("circuitBreakers"));
|
||||
assertTrue(health.getDetails().containsKey("rateLimiters"));
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package cn.novalon.manage.gateway.integration;
|
||||
|
||||
import cn.novalon.manage.gateway.filter.RbacAuthorizationFilter;
|
||||
import cn.novalon.manage.gateway.model.Permission;
|
||||
import cn.novalon.manage.gateway.model.Role;
|
||||
import cn.novalon.manage.gateway.model.User;
|
||||
import cn.novalon.manage.gateway.service.PermissionService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RbacIntegrationTest {
|
||||
|
||||
@Mock
|
||||
private PermissionService permissionService;
|
||||
|
||||
@Mock
|
||||
private GatewayFilterChain chain;
|
||||
|
||||
private RbacAuthorizationFilter filter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
filter = new RbacAuthorizationFilter(permissionService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEndToEnd_AdminUserFullAccess() {
|
||||
Long adminUserId = 1L;
|
||||
String adminPath = "/api/admin/users";
|
||||
String adminMethod = "GET";
|
||||
|
||||
when(permissionService.hasPermission(eq(adminUserId), eq(adminPath), eq(adminMethod))).thenReturn(true);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get(adminPath)
|
||||
.header("X-User-Id", adminUserId.toString())
|
||||
.build();
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(exchange, chain);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
|
||||
assert exchange.getResponse().getStatusCode() == null || exchange.getResponse().getStatusCode() == HttpStatus.OK;
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEndToEnd_RegularUserLimitedAccess() {
|
||||
Long regularUserId = 2L;
|
||||
String adminPath = "/api/admin/users";
|
||||
String userPath = "/api/users/profile";
|
||||
|
||||
when(permissionService.hasPermission(eq(regularUserId), eq(adminPath), eq("GET"))).thenReturn(false);
|
||||
when(permissionService.hasPermission(eq(regularUserId), eq(userPath), eq("GET"))).thenReturn(true);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest adminRequest = MockServerHttpRequest.get(adminPath)
|
||||
.header("X-User-Id", regularUserId.toString())
|
||||
.build();
|
||||
ServerWebExchange adminExchange = MockServerWebExchange.from(adminRequest);
|
||||
|
||||
Mono<Void> adminResult = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(adminExchange, chain);
|
||||
|
||||
StepVerifier.create(adminResult)
|
||||
.verifyComplete();
|
||||
|
||||
assert adminExchange.getResponse().getStatusCode() == HttpStatus.FORBIDDEN;
|
||||
|
||||
MockServerHttpRequest userRequest = MockServerHttpRequest.get(userPath)
|
||||
.header("X-User-Id", regularUserId.toString())
|
||||
.build();
|
||||
ServerWebExchange userExchange = MockServerWebExchange.from(userRequest);
|
||||
|
||||
Mono<Void> userResult = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(userExchange, chain);
|
||||
|
||||
StepVerifier.create(userResult)
|
||||
.verifyComplete();
|
||||
|
||||
assert userExchange.getResponse().getStatusCode() == null || userExchange.getResponse().getStatusCode() == HttpStatus.OK;
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEndToEnd_MultipleHttpMethods() {
|
||||
Long userId = 3L;
|
||||
String basePath = "/api/users";
|
||||
|
||||
when(permissionService.hasPermission(eq(userId), eq(basePath), eq("GET"))).thenReturn(true);
|
||||
when(permissionService.hasPermission(eq(userId), eq(basePath), eq("POST"))).thenReturn(true);
|
||||
when(permissionService.hasPermission(eq(userId), eq(basePath), eq("PUT"))).thenReturn(false);
|
||||
when(permissionService.hasPermission(eq(userId), eq(basePath), eq("DELETE"))).thenReturn(false);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
MockServerHttpRequest getRequest = MockServerHttpRequest.get(basePath)
|
||||
.header("X-User-Id", userId.toString())
|
||||
.build();
|
||||
ServerWebExchange getExchange = MockServerWebExchange.from(getRequest);
|
||||
|
||||
Mono<Void> getResult = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(getExchange, chain);
|
||||
|
||||
StepVerifier.create(getResult)
|
||||
.verifyComplete();
|
||||
|
||||
assert getExchange.getResponse().getStatusCode() == null || getExchange.getResponse().getStatusCode() == HttpStatus.OK;
|
||||
|
||||
MockServerHttpRequest postRequest = MockServerHttpRequest.post(basePath)
|
||||
.header("X-User-Id", userId.toString())
|
||||
.build();
|
||||
ServerWebExchange postExchange = MockServerWebExchange.from(postRequest);
|
||||
|
||||
Mono<Void> postResult = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(postExchange, chain);
|
||||
|
||||
StepVerifier.create(postResult)
|
||||
.verifyComplete();
|
||||
|
||||
assert postExchange.getResponse().getStatusCode() == null || postExchange.getResponse().getStatusCode() == HttpStatus.OK;
|
||||
|
||||
MockServerHttpRequest putRequest = MockServerHttpRequest.put(basePath)
|
||||
.header("X-User-Id", userId.toString())
|
||||
.build();
|
||||
ServerWebExchange putExchange = MockServerWebExchange.from(putRequest);
|
||||
|
||||
Mono<Void> putResult = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(putExchange, chain);
|
||||
|
||||
StepVerifier.create(putResult)
|
||||
.verifyComplete();
|
||||
|
||||
assert putExchange.getResponse().getStatusCode() == HttpStatus.FORBIDDEN;
|
||||
|
||||
MockServerHttpRequest deleteRequest = MockServerHttpRequest.delete(basePath)
|
||||
.header("X-User-Id", userId.toString())
|
||||
.build();
|
||||
ServerWebExchange deleteExchange = MockServerWebExchange.from(deleteRequest);
|
||||
|
||||
Mono<Void> deleteResult = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(deleteExchange, chain);
|
||||
|
||||
StepVerifier.create(deleteResult)
|
||||
.verifyComplete();
|
||||
|
||||
assert deleteExchange.getResponse().getStatusCode() == HttpStatus.FORBIDDEN;
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEndToEnd_PathMatchingScenarios() {
|
||||
Long userId = 4L;
|
||||
|
||||
when(permissionService.hasPermission(eq(userId), eq("/api/users"), eq("GET"))).thenReturn(true);
|
||||
when(permissionService.hasPermission(eq(userId), eq("/api/users/123"), eq("GET"))).thenReturn(true);
|
||||
when(permissionService.hasPermission(eq(userId), eq("/api/users/123/profile"), eq("GET"))).thenReturn(true);
|
||||
when(permissionService.hasPermission(eq(userId), eq("/api/admin"), eq("GET"))).thenReturn(false);
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
String[] allowedPaths = {"/api/users", "/api/users/123", "/api/users/123/profile"};
|
||||
for (String path : allowedPaths) {
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get(path)
|
||||
.header("X-User-Id", userId.toString())
|
||||
.build();
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(exchange, chain);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
|
||||
assert exchange.getResponse().getStatusCode() == null || exchange.getResponse().getStatusCode() == HttpStatus.OK;
|
||||
}
|
||||
|
||||
MockServerHttpRequest adminRequest = MockServerHttpRequest.get("/api/admin")
|
||||
.header("X-User-Id", userId.toString())
|
||||
.build();
|
||||
ServerWebExchange adminExchange = MockServerWebExchange.from(adminRequest);
|
||||
|
||||
Mono<Void> adminResult = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(adminExchange, chain);
|
||||
|
||||
StepVerifier.create(adminResult)
|
||||
.verifyComplete();
|
||||
|
||||
assert adminExchange.getResponse().getStatusCode() == HttpStatus.FORBIDDEN;
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEndToEnd_PublicPathsBypass() {
|
||||
String[] publicPaths = {
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/actuator/health",
|
||||
"/actuator/info"
|
||||
};
|
||||
|
||||
for (String path : publicPaths) {
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get(path).build();
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
|
||||
|
||||
Mono<Void> result = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(exchange, chain);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
|
||||
assert exchange.getResponse().getStatusCode() == null || exchange.getResponse().getStatusCode() == HttpStatus.OK;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEndToEnd_ErrorScenarios() {
|
||||
MockServerHttpRequest noHeaderRequest = MockServerHttpRequest.get("/api/users").build();
|
||||
ServerWebExchange noHeaderExchange = MockServerWebExchange.from(noHeaderRequest);
|
||||
|
||||
Mono<Void> noHeaderResult = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(noHeaderExchange, chain);
|
||||
|
||||
StepVerifier.create(noHeaderResult)
|
||||
.verifyComplete();
|
||||
|
||||
assert noHeaderExchange.getResponse().getStatusCode() == HttpStatus.UNAUTHORIZED;
|
||||
|
||||
MockServerHttpRequest invalidIdRequest = MockServerHttpRequest.get("/api/users")
|
||||
.header("X-User-Id", "invalid")
|
||||
.build();
|
||||
ServerWebExchange invalidIdExchange = MockServerWebExchange.from(invalidIdRequest);
|
||||
|
||||
Mono<Void> invalidIdResult = filter.apply(new RbacAuthorizationFilter.Config())
|
||||
.filter(invalidIdExchange, chain);
|
||||
|
||||
StepVerifier.create(invalidIdResult)
|
||||
.verifyComplete();
|
||||
|
||||
assert invalidIdExchange.getResponse().getStatusCode() == HttpStatus.UNAUTHORIZED;
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package cn.novalon.manage.gateway.loadbalancer;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.cloud.client.DefaultServiceInstance;
|
||||
import org.springframework.cloud.client.ServiceInstance;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* CustomLoadBalancer单元测试
|
||||
*
|
||||
* 文件定义:测试自定义负载均衡器的核心功能
|
||||
* 涉及业务:轮询、随机、加权轮询、最少连接策略
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
class CustomLoadBalancerTest {
|
||||
|
||||
private CustomLoadBalancer loadBalancer;
|
||||
private List<ServiceInstance> instances;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
loadBalancer = new CustomLoadBalancer();
|
||||
|
||||
instances = Arrays.asList(
|
||||
createInstance("host1", 8080),
|
||||
createInstance("host2", 8080),
|
||||
createInstance("host3", 8080)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectByRoundRobin() {
|
||||
ServiceInstance instance1 = loadBalancer.selectInstance(
|
||||
instances,
|
||||
CustomLoadBalancer.LoadBalanceStrategy.ROUND_ROBIN);
|
||||
|
||||
ServiceInstance instance2 = loadBalancer.selectInstance(
|
||||
instances,
|
||||
CustomLoadBalancer.LoadBalanceStrategy.ROUND_ROBIN);
|
||||
|
||||
assertNotNull(instance1);
|
||||
assertNotNull(instance2);
|
||||
assertNotSame(instance1, instance2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectByRandom() {
|
||||
ServiceInstance instance = loadBalancer.selectInstance(
|
||||
instances,
|
||||
CustomLoadBalancer.LoadBalanceStrategy.RANDOM);
|
||||
|
||||
assertNotNull(instance);
|
||||
assertTrue(instances.contains(instance));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectByWeightedRoundRobin() {
|
||||
ServiceInstance instance = loadBalancer.selectInstance(
|
||||
instances,
|
||||
CustomLoadBalancer.LoadBalanceStrategy.WEIGHTED_ROUND_ROBIN);
|
||||
|
||||
assertNotNull(instance);
|
||||
assertTrue(instances.contains(instance));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectByLeastConnections() {
|
||||
ServiceInstance instance = loadBalancer.selectInstance(
|
||||
instances,
|
||||
CustomLoadBalancer.LoadBalanceStrategy.LEAST_CONNECTIONS);
|
||||
|
||||
assertNotNull(instance);
|
||||
assertTrue(instances.contains(instance));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectInstance_EmptyList() {
|
||||
ServiceInstance instance = loadBalancer.selectInstance(
|
||||
Collections.emptyList(),
|
||||
CustomLoadBalancer.LoadBalanceStrategy.ROUND_ROBIN);
|
||||
|
||||
assertNull(instance);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectInstance_NullList() {
|
||||
ServiceInstance instance = loadBalancer.selectInstance(
|
||||
null,
|
||||
CustomLoadBalancer.LoadBalanceStrategy.ROUND_ROBIN);
|
||||
|
||||
assertNull(instance);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetWeight() {
|
||||
ServiceInstance instance = instances.get(0);
|
||||
|
||||
loadBalancer.setWeight(instance, 5);
|
||||
|
||||
assertNotNull(instance);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIncrementConnection() {
|
||||
ServiceInstance instance = instances.get(0);
|
||||
|
||||
loadBalancer.incrementConnection(instance);
|
||||
loadBalancer.incrementConnection(instance);
|
||||
|
||||
assertNotNull(instance);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDecrementConnection() {
|
||||
ServiceInstance instance = instances.get(0);
|
||||
|
||||
loadBalancer.incrementConnection(instance);
|
||||
loadBalancer.incrementConnection(instance);
|
||||
loadBalancer.decrementConnection(instance);
|
||||
|
||||
assertNotNull(instance);
|
||||
}
|
||||
|
||||
private ServiceInstance createInstance(String host, int port) {
|
||||
return new DefaultServiceInstance(
|
||||
"service-" + host + "-" + port,
|
||||
"test-service",
|
||||
host,
|
||||
port,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package cn.novalon.manage.gateway.metrics;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* GatewayMetrics单元测试
|
||||
*
|
||||
* 文件定义:测试网关指标收集器的核心功能
|
||||
* 涉及业务:请求统计、性能监控、活跃连接数统计
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
class GatewayMetricsTest {
|
||||
|
||||
private MeterRegistry meterRegistry;
|
||||
private GatewayMetrics gatewayMetrics;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
meterRegistry = new SimpleMeterRegistry();
|
||||
gatewayMetrics = new GatewayMetrics(meterRegistry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIncrementTotalRequests() {
|
||||
gatewayMetrics.incrementTotalRequests();
|
||||
|
||||
assertEquals(1, gatewayMetrics.getTotalRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIncrementSuccessRequests() {
|
||||
gatewayMetrics.incrementSuccessRequests();
|
||||
|
||||
assertEquals(1, gatewayMetrics.getSuccessRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIncrementFailedRequests() {
|
||||
gatewayMetrics.incrementFailedRequests();
|
||||
|
||||
assertEquals(1, gatewayMetrics.getFailedRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIncrementActiveConnections() {
|
||||
gatewayMetrics.incrementActiveConnections();
|
||||
|
||||
assertEquals(1, gatewayMetrics.getActiveConnections());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDecrementActiveConnections() {
|
||||
gatewayMetrics.incrementActiveConnections();
|
||||
gatewayMetrics.incrementActiveConnections();
|
||||
gatewayMetrics.decrementActiveConnections();
|
||||
|
||||
assertEquals(1, gatewayMetrics.getActiveConnections());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRecordRequestDuration() {
|
||||
gatewayMetrics.recordRequestDuration("/api/users", Duration.ofMillis(100));
|
||||
|
||||
assertNotNull(meterRegistry.find("gateway.request.duration").timer());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMultipleIncrements() {
|
||||
gatewayMetrics.incrementTotalRequests();
|
||||
gatewayMetrics.incrementTotalRequests();
|
||||
gatewayMetrics.incrementTotalRequests();
|
||||
|
||||
assertEquals(3, gatewayMetrics.getTotalRequests());
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package cn.novalon.manage.gateway.monitor;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PerformanceMonitorTest {
|
||||
|
||||
private PerformanceMonitor performanceMonitor;
|
||||
private MeterRegistry meterRegistry;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
meterRegistry = new SimpleMeterRegistry();
|
||||
performanceMonitor = new PerformanceMonitor(meterRegistry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRecordRequest() {
|
||||
performanceMonitor.recordRequest("/api/test", 100);
|
||||
|
||||
assertEquals(1, performanceMonitor.getPathStats().size());
|
||||
assertTrue(performanceMonitor.getAverageProcessingTime() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSlowRequestDetection() {
|
||||
performanceMonitor.setSlowRequestThresholdMs(50);
|
||||
performanceMonitor.recordRequest("/api/test", 100);
|
||||
|
||||
assertEquals(1, performanceMonitor.getPathStats().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMultipleRequests() {
|
||||
performanceMonitor.recordRequest("/api/test1", 100);
|
||||
performanceMonitor.recordRequest("/api/test2", 200);
|
||||
performanceMonitor.recordRequest("/api/test1", 150);
|
||||
|
||||
Map<String, PerformanceMonitor.PerformanceStats> stats = performanceMonitor.getPathStats();
|
||||
|
||||
assertEquals(2, stats.size());
|
||||
|
||||
PerformanceMonitor.PerformanceStats test1Stats = stats.get("/api/test1");
|
||||
assertNotNull(test1Stats);
|
||||
assertEquals(2, test1Stats.getRequestCount());
|
||||
assertEquals(125.0, test1Stats.getAverageTime());
|
||||
assertEquals(150, test1Stats.getMaxTime());
|
||||
assertEquals(100, test1Stats.getMinTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMemoryStats() {
|
||||
Map<String, Object> memoryStats = performanceMonitor.getMemoryStats();
|
||||
|
||||
assertNotNull(memoryStats);
|
||||
assertTrue(memoryStats.containsKey("totalMemory"));
|
||||
assertTrue(memoryStats.containsKey("freeMemory"));
|
||||
assertTrue(memoryStats.containsKey("usedMemory"));
|
||||
assertTrue(memoryStats.containsKey("maxMemory"));
|
||||
assertTrue(memoryStats.containsKey("memoryUsage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testThreadStats() {
|
||||
Map<String, Object> threadStats = performanceMonitor.getThreadStats();
|
||||
|
||||
assertNotNull(threadStats);
|
||||
assertTrue(threadStats.containsKey("threadCount"));
|
||||
assertTrue(threadStats.containsKey("peakThreadCount"));
|
||||
assertTrue(threadStats.containsKey("daemonThreadCount"));
|
||||
assertTrue(threadStats.containsKey("totalStartedThreadCount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMemoryUsage() {
|
||||
double memoryUsage = performanceMonitor.getMemoryUsage();
|
||||
|
||||
assertTrue(memoryUsage >= 0.0);
|
||||
assertTrue(memoryUsage <= 1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAverageProcessingTime_NoRequests() {
|
||||
assertEquals(0.0, performanceMonitor.getAverageProcessingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAverageProcessingTime_WithRequests() {
|
||||
performanceMonitor.recordRequest("/api/test1", 100);
|
||||
performanceMonitor.recordRequest("/api/test2", 200);
|
||||
|
||||
assertEquals(150.0, performanceMonitor.getAverageProcessingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClearStats() {
|
||||
performanceMonitor.recordRequest("/api/test", 100);
|
||||
performanceMonitor.clearStats();
|
||||
|
||||
assertEquals(0, performanceMonitor.getPathStats().size());
|
||||
assertEquals(0.0, performanceMonitor.getAverageProcessingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetSlowRequestThreshold() {
|
||||
performanceMonitor.setSlowRequestThresholdMs(500);
|
||||
performanceMonitor.recordRequest("/api/test", 600);
|
||||
|
||||
assertEquals(1, performanceMonitor.getPathStats().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetMemoryWarningThreshold() {
|
||||
performanceMonitor.setMemoryWarningThreshold(0.9);
|
||||
performanceMonitor.recordRequest("/api/test", 100);
|
||||
|
||||
assertEquals(1, performanceMonitor.getPathStats().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPerformanceStats() {
|
||||
PerformanceMonitor.PerformanceStats stats = new PerformanceMonitor.PerformanceStats();
|
||||
|
||||
stats.recordRequest(100);
|
||||
stats.recordRequest(200);
|
||||
stats.recordRequest(150);
|
||||
|
||||
assertEquals(3, stats.getRequestCount());
|
||||
assertEquals(150.0, stats.getAverageTime());
|
||||
assertEquals(200, stats.getMaxTime());
|
||||
assertEquals(100, stats.getMinTime());
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package cn.novalon.manage.gateway.route;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.cloud.gateway.event.RefreshRoutesEvent;
|
||||
import org.springframework.cloud.gateway.route.RouteDefinition;
|
||||
import org.springframework.cloud.gateway.route.RouteDefinitionLocator;
|
||||
import org.springframework.cloud.gateway.route.RouteDefinitionWriter;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* DynamicRouteService单元测试
|
||||
*
|
||||
* 文件定义:测试动态路由服务的核心功能
|
||||
* 涉及业务:路由增删改查、路由刷新
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DynamicRouteServiceTest {
|
||||
|
||||
@Mock
|
||||
private RouteDefinitionWriter routeDefinitionWriter;
|
||||
|
||||
@Mock
|
||||
private RouteDefinitionLocator routeDefinitionLocator;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher publisher;
|
||||
|
||||
private DynamicRouteService dynamicRouteService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(routeDefinitionLocator.getRouteDefinitions())
|
||||
.thenReturn(Flux.empty());
|
||||
|
||||
dynamicRouteService = new DynamicRouteService(
|
||||
routeDefinitionWriter,
|
||||
routeDefinitionLocator,
|
||||
publisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddRoute_Success() {
|
||||
RouteDefinition routeDefinition = createRouteDefinition("test-route");
|
||||
|
||||
when(routeDefinitionWriter.save(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(dynamicRouteService.addRoute(routeDefinition))
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
|
||||
verify(routeDefinitionWriter).save(any());
|
||||
verify(publisher).publishEvent(any(RefreshRoutesEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddRoute_NullRoute() {
|
||||
StepVerifier.create(dynamicRouteService.addRoute(null))
|
||||
.expectNext(false)
|
||||
.verifyComplete();
|
||||
|
||||
verify(routeDefinitionWriter, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteRoute_Success() {
|
||||
String routeId = "test-route";
|
||||
|
||||
when(routeDefinitionWriter.delete(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(dynamicRouteService.deleteRoute(routeId))
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
|
||||
verify(routeDefinitionWriter).delete(any());
|
||||
verify(publisher).publishEvent(any(RefreshRoutesEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteRoute_NullId() {
|
||||
StepVerifier.create(dynamicRouteService.deleteRoute(null))
|
||||
.expectNext(false)
|
||||
.verifyComplete();
|
||||
|
||||
verify(routeDefinitionWriter, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAllRoutes() {
|
||||
RouteDefinition route1 = createRouteDefinition("route1");
|
||||
RouteDefinition route2 = createRouteDefinition("route2");
|
||||
|
||||
when(routeDefinitionWriter.save(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(dynamicRouteService.addRoute(route1))
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
|
||||
StepVerifier.create(dynamicRouteService.addRoute(route2))
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
|
||||
StepVerifier.create(dynamicRouteService.getAllRoutes().collectList())
|
||||
.assertNext(routes -> {
|
||||
assertNotNull(routes);
|
||||
assertTrue(routes.size() >= 2);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetRouteCount() {
|
||||
RouteDefinition route = createRouteDefinition("test-route");
|
||||
|
||||
when(routeDefinitionWriter.save(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(dynamicRouteService.addRoute(route))
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
|
||||
assertTrue(dynamicRouteService.getRouteCount() >= 1);
|
||||
}
|
||||
|
||||
private RouteDefinition createRouteDefinition(String id) {
|
||||
RouteDefinition routeDefinition = new RouteDefinition();
|
||||
routeDefinition.setId(id);
|
||||
routeDefinition.setUri(java.net.URI.create("http://localhost:8080"));
|
||||
return routeDefinition;
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package cn.novalon.manage.gateway.service.impl;
|
||||
|
||||
import cn.novalon.manage.gateway.service.JwtKeyService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JwtKeyServiceImplTest {
|
||||
|
||||
@InjectMocks
|
||||
private JwtKeyServiceImpl jwtKeyService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(jwtKeyService, "configuredSecret", null);
|
||||
ReflectionTestUtils.setField(jwtKeyService, "encryptionPassword", "testEncryptionPassword");
|
||||
ReflectionTestUtils.setField(jwtKeyService, "rotationEnabled", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInitializeKeys_GeneratesNewKey() {
|
||||
jwtKeyService.initializeKeys();
|
||||
|
||||
String version = jwtKeyService.getCurrentKeyVersion();
|
||||
SecretKey key = jwtKeyService.getCurrentSigningKey();
|
||||
|
||||
assertNotNull(version);
|
||||
assertNotNull(key);
|
||||
assertEquals("v1", version);
|
||||
assertEquals("AES", key.getAlgorithm());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateSecureKey_GeneratesValidKey() {
|
||||
String key = jwtKeyService.generateSecureKey();
|
||||
|
||||
assertNotNull(key);
|
||||
assertFalse(key.isEmpty());
|
||||
assertTrue(jwtKeyService.validateKeyStrength(key));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateKeyStrength_ValidKey() {
|
||||
String validKey = "StrongPassword123ABC!@#XYZabcdefg";
|
||||
assertTrue(jwtKeyService.validateKeyStrength(validKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateKeyStrength_WeakKey() {
|
||||
String weakKey = "weak";
|
||||
assertFalse(jwtKeyService.validateKeyStrength(weakKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateKeyStrength_NullKey() {
|
||||
assertFalse(jwtKeyService.validateKeyStrength(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateKeyStrength_ShortKey() {
|
||||
String shortKey = "Short1!";
|
||||
assertFalse(jwtKeyService.validateKeyStrength(shortKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEncryptKey_WithPassword() {
|
||||
String originalKey = "MySecretKey123!";
|
||||
String encryptedKey = jwtKeyService.encryptKey(originalKey);
|
||||
|
||||
assertNotNull(encryptedKey);
|
||||
assertNotEquals(originalKey, encryptedKey);
|
||||
assertTrue(encryptedKey.length() > originalKey.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEncryptDecryptKey_RoundTrip() {
|
||||
String originalKey = "MySecretKey123!";
|
||||
String encryptedKey = jwtKeyService.encryptKey(originalKey);
|
||||
String decryptedKey = jwtKeyService.decryptKey(encryptedKey);
|
||||
|
||||
assertNotNull(encryptedKey);
|
||||
assertNotNull(decryptedKey);
|
||||
assertEquals(originalKey, decryptedKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRotateKey_CreatesNewVersion() {
|
||||
jwtKeyService.initializeKeys();
|
||||
String oldVersion = jwtKeyService.getCurrentKeyVersion();
|
||||
SecretKey oldKey = jwtKeyService.getCurrentSigningKey();
|
||||
|
||||
jwtKeyService.rotateKey();
|
||||
|
||||
String newVersion = jwtKeyService.getCurrentKeyVersion();
|
||||
SecretKey newKey = jwtKeyService.getCurrentSigningKey();
|
||||
|
||||
assertNotEquals(oldVersion, newVersion);
|
||||
assertEquals("v2", newVersion);
|
||||
assertNotNull(newKey);
|
||||
assertEquals("AES", newKey.getAlgorithm());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSigningKeyByVersion_ReturnsCorrectKey() {
|
||||
jwtKeyService.initializeKeys();
|
||||
SecretKey v1Key = jwtKeyService.getSigningKeyByVersion("v1");
|
||||
|
||||
assertNotNull(v1Key);
|
||||
assertEquals("AES", v1Key.getAlgorithm());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSigningKeyByVersion_InvalidVersion() {
|
||||
jwtKeyService.initializeKeys();
|
||||
SecretKey invalidKey = jwtKeyService.getSigningKeyByVersion("v999");
|
||||
|
||||
assertNull(invalidKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRotateKey_Disabled() {
|
||||
ReflectionTestUtils.setField(jwtKeyService, "rotationEnabled", false);
|
||||
jwtKeyService.initializeKeys();
|
||||
String oldVersion = jwtKeyService.getCurrentKeyVersion();
|
||||
|
||||
jwtKeyService.rotateKey();
|
||||
|
||||
String newVersion = jwtKeyService.getCurrentKeyVersion();
|
||||
assertEquals(oldVersion, newVersion);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldRotateKey_NewKey() {
|
||||
jwtKeyService.initializeKeys();
|
||||
|
||||
String currentVersion = jwtKeyService.getCurrentKeyVersion();
|
||||
SecretKey currentKey = jwtKeyService.getCurrentSigningKey();
|
||||
|
||||
assertNotNull(currentVersion, "Current version should not be null");
|
||||
assertNotNull(currentKey, "Current signing key should not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMultipleRotations_CreatesMultipleVersions() {
|
||||
jwtKeyService.initializeKeys();
|
||||
|
||||
jwtKeyService.rotateKey();
|
||||
assertEquals("v2", jwtKeyService.getCurrentKeyVersion());
|
||||
|
||||
jwtKeyService.rotateKey();
|
||||
assertEquals("v3", jwtKeyService.getCurrentKeyVersion());
|
||||
|
||||
jwtKeyService.rotateKey();
|
||||
assertEquals("v4", jwtKeyService.getCurrentKeyVersion());
|
||||
|
||||
assertNotNull(jwtKeyService.getSigningKeyByVersion("v1"));
|
||||
assertNotNull(jwtKeyService.getSigningKeyByVersion("v2"));
|
||||
assertNotNull(jwtKeyService.getSigningKeyByVersion("v3"));
|
||||
assertNotNull(jwtKeyService.getSigningKeyByVersion("v4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEncryptKey_WithoutPassword() {
|
||||
ReflectionTestUtils.setField(jwtKeyService, "encryptionPassword", "");
|
||||
String originalKey = "MySecretKey123!";
|
||||
String encryptedKey = jwtKeyService.encryptKey(originalKey);
|
||||
|
||||
assertEquals(originalKey, encryptedKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDecryptKey_WithoutPassword() {
|
||||
ReflectionTestUtils.setField(jwtKeyService, "encryptionPassword", "");
|
||||
String originalKey = "MySecretKey123!";
|
||||
String decryptedKey = jwtKeyService.decryptKey(originalKey);
|
||||
|
||||
assertEquals(originalKey, decryptedKey);
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package cn.novalon.manage.gateway.service.impl;
|
||||
|
||||
import cn.novalon.manage.gateway.model.Permission;
|
||||
import cn.novalon.manage.gateway.model.Role;
|
||||
import cn.novalon.manage.gateway.model.User;
|
||||
import cn.novalon.manage.gateway.service.PermissionService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient.RequestHeadersUriSpec;
|
||||
import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec;
|
||||
import org.springframework.web.reactive.function.client.WebClient.ResponseSpec;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PermissionServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private WebClient.Builder webClientBuilder;
|
||||
|
||||
@Mock
|
||||
private WebClient webClient;
|
||||
|
||||
@Mock
|
||||
private RequestHeadersUriSpec requestHeadersUriSpec;
|
||||
|
||||
@Mock
|
||||
private RequestHeadersSpec requestHeadersSpec;
|
||||
|
||||
@Mock
|
||||
private ResponseSpec responseSpec;
|
||||
|
||||
private PermissionService permissionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(webClientBuilder.build()).thenReturn(webClient);
|
||||
permissionService = new PermissionServiceImpl(webClientBuilder, "http://localhost:8084");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetUserById_Success() {
|
||||
User expectedUser = new User(1L, "testuser", "test@example.com", "1234567890", 1, System.currentTimeMillis(), System.currentTimeMillis());
|
||||
|
||||
when(webClient.get()).thenReturn(requestHeadersUriSpec);
|
||||
when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec);
|
||||
when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
|
||||
when(responseSpec.bodyToMono(eq(User.class))).thenReturn(Mono.just(expectedUser));
|
||||
|
||||
User user = permissionService.getUserById(1L);
|
||||
|
||||
assertNotNull(user);
|
||||
assertEquals("testuser", user.getUsername());
|
||||
verify(webClient).get();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetUserById_NullUserId() {
|
||||
User user = permissionService.getUserById(null);
|
||||
|
||||
assertNull(user);
|
||||
verify(webClient, never()).get();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetUserRoles_Success() {
|
||||
List<Role> expectedRoles = Arrays.asList(
|
||||
new Role(1L, "ADMIN", "Administrator", "Admin role", 1, System.currentTimeMillis(), System.currentTimeMillis()),
|
||||
new Role(2L, "USER", "User", "User role", 1, System.currentTimeMillis(), System.currentTimeMillis())
|
||||
);
|
||||
|
||||
when(webClient.get()).thenReturn(requestHeadersUriSpec);
|
||||
when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec);
|
||||
when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
|
||||
when(responseSpec.bodyToMono(eq(Role[].class))).thenReturn(Mono.just(expectedRoles.toArray(new Role[0])));
|
||||
|
||||
List<Role> roles = permissionService.getUserRoles(1L);
|
||||
|
||||
assertNotNull(roles);
|
||||
assertEquals(2, roles.size());
|
||||
verify(webClient).get();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetUserRoles_NullUserId() {
|
||||
List<Role> roles = permissionService.getUserRoles(null);
|
||||
|
||||
assertNotNull(roles);
|
||||
assertTrue(roles.isEmpty());
|
||||
verify(webClient, never()).get();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetUserPermissions_Success() {
|
||||
Set<Permission> expectedPermissions = new HashSet<>(Arrays.asList(
|
||||
new Permission(1L, "user:read", "Read User", "API", "/api/users/**", "GET", "Read user permissions", 1, System.currentTimeMillis(), System.currentTimeMillis()),
|
||||
new Permission(2L, "user:write", "Write User", "API", "/api/users/**", "POST", "Write user permissions", 1, System.currentTimeMillis(), System.currentTimeMillis())
|
||||
));
|
||||
|
||||
when(webClient.get()).thenReturn(requestHeadersUriSpec);
|
||||
when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec);
|
||||
when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
|
||||
when(responseSpec.bodyToMono(eq(Permission[].class))).thenReturn(Mono.just(expectedPermissions.toArray(new Permission[0])));
|
||||
|
||||
Set<Permission> permissions = permissionService.getUserPermissions(1L);
|
||||
|
||||
assertNotNull(permissions);
|
||||
assertEquals(2, permissions.size());
|
||||
verify(webClient).get();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetUserPermissions_NullUserId() {
|
||||
Set<Permission> permissions = permissionService.getUserPermissions(null);
|
||||
|
||||
assertNotNull(permissions);
|
||||
assertTrue(permissions.isEmpty());
|
||||
verify(webClient, never()).get();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasPermission_True() {
|
||||
Set<Permission> permissions = new HashSet<>(Arrays.asList(
|
||||
new Permission(1L, "user:read", "Read User", "API", "/api/users/**", "GET", "Read user permissions", 1, System.currentTimeMillis(), System.currentTimeMillis())
|
||||
));
|
||||
|
||||
when(webClient.get()).thenReturn(requestHeadersUriSpec);
|
||||
when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec);
|
||||
when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
|
||||
when(responseSpec.bodyToMono(eq(Permission[].class))).thenReturn(Mono.just(permissions.toArray(new Permission[0])));
|
||||
|
||||
boolean hasPermission = permissionService.hasPermission(1L, "/api/users/123", "GET");
|
||||
|
||||
assertTrue(hasPermission);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasPermission_False() {
|
||||
Set<Permission> permissions = new HashSet<>(Arrays.asList(
|
||||
new Permission(1L, "user:read", "Read User", "API", "/api/users/**", "GET", "Read user permissions", 1, System.currentTimeMillis(), System.currentTimeMillis())
|
||||
));
|
||||
|
||||
when(webClient.get()).thenReturn(requestHeadersUriSpec);
|
||||
when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec);
|
||||
when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
|
||||
when(responseSpec.bodyToMono(eq(Permission[].class))).thenReturn(Mono.just(permissions.toArray(new Permission[0])));
|
||||
|
||||
boolean hasPermission = permissionService.hasPermission(1L, "/api/users/123", "POST");
|
||||
|
||||
assertFalse(hasPermission);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasPermission_NullUserId() {
|
||||
boolean hasPermission = permissionService.hasPermission(null, "/api/users/123", "GET");
|
||||
|
||||
assertFalse(hasPermission);
|
||||
verify(webClient, never()).get();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetPermissionPaths_Success() {
|
||||
Set<Permission> permissions = new HashSet<>(Arrays.asList(
|
||||
new Permission(1L, "user:read", "Read User", "API", "/api/users/**", "GET", "Read user permissions", 1, System.currentTimeMillis(), System.currentTimeMillis()),
|
||||
new Permission(2L, "user:write", "Write User", "API", "/api/users/**", "POST", "Write user permissions", 1, System.currentTimeMillis(), System.currentTimeMillis())
|
||||
));
|
||||
|
||||
when(webClient.get()).thenReturn(requestHeadersUriSpec);
|
||||
when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec);
|
||||
when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
|
||||
when(responseSpec.bodyToMono(eq(Permission[].class))).thenReturn(Mono.just(permissions.toArray(new Permission[0])));
|
||||
|
||||
Set<String> paths = permissionService.getPermissionPaths(1L, "GET");
|
||||
|
||||
assertNotNull(paths);
|
||||
assertEquals(1, paths.size());
|
||||
assertTrue(paths.contains("/api/users/**"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClearCache_Success() {
|
||||
User user = new User(1L, "testuser", "test@example.com", "1234567890", 1, System.currentTimeMillis(), System.currentTimeMillis());
|
||||
List<Role> roles = Arrays.asList(new Role(1L, "ADMIN", "Administrator", "Admin role", 1, System.currentTimeMillis(), System.currentTimeMillis()));
|
||||
Set<Permission> permissions = new HashSet<>(Arrays.asList(
|
||||
new Permission(1L, "user:read", "Read User", "API", "/api/users/**", "GET", "Read user permissions", 1, System.currentTimeMillis(), System.currentTimeMillis())
|
||||
));
|
||||
|
||||
when(webClient.get()).thenReturn(requestHeadersUriSpec);
|
||||
when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec);
|
||||
when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
|
||||
when(responseSpec.bodyToMono(eq(User.class))).thenReturn(Mono.just(user));
|
||||
when(responseSpec.bodyToMono(eq(Role[].class))).thenReturn(Mono.just(roles.toArray(new Role[0])));
|
||||
when(responseSpec.bodyToMono(eq(Permission[].class))).thenReturn(Mono.just(permissions.toArray(new Permission[0])));
|
||||
|
||||
permissionService.getUserById(1L);
|
||||
permissionService.getUserRoles(1L);
|
||||
permissionService.getUserPermissions(1L);
|
||||
|
||||
permissionService.clearCache(1L);
|
||||
|
||||
verify(webClient, times(3)).get();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClearAllCache_Success() {
|
||||
User user = new User(1L, "testuser", "test@example.com", "1234567890", 1, System.currentTimeMillis(), System.currentTimeMillis());
|
||||
List<Role> roles = Arrays.asList(new Role(1L, "ADMIN", "Administrator", "Admin role", 1, System.currentTimeMillis(), System.currentTimeMillis()));
|
||||
Set<Permission> permissions = new HashSet<>(Arrays.asList(
|
||||
new Permission(1L, "user:read", "Read User", "API", "/api/users/**", "GET", "Read user permissions", 1, System.currentTimeMillis(), System.currentTimeMillis())
|
||||
));
|
||||
|
||||
when(webClient.get()).thenReturn(requestHeadersUriSpec);
|
||||
when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec);
|
||||
when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
|
||||
when(responseSpec.bodyToMono(eq(User.class))).thenReturn(Mono.just(user));
|
||||
when(responseSpec.bodyToMono(eq(Role[].class))).thenReturn(Mono.just(roles.toArray(new Role[0])));
|
||||
when(responseSpec.bodyToMono(eq(Permission[].class))).thenReturn(Mono.just(permissions.toArray(new Permission[0])));
|
||||
|
||||
permissionService.getUserById(1L);
|
||||
permissionService.getUserRoles(1L);
|
||||
permissionService.getUserPermissions(1L);
|
||||
|
||||
permissionService.clearAllCache();
|
||||
|
||||
permissionService.getUserById(1L);
|
||||
permissionService.getUserRoles(1L);
|
||||
permissionService.getUserPermissions(1L);
|
||||
|
||||
verify(webClient, times(6)).get();
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package cn.novalon.manage.gateway.service.impl;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* SignatureServiceImpl单元测试
|
||||
*
|
||||
* 文件定义:测试签名服务的核心功能
|
||||
* 涉及业务:签名生成、签名验证、时间戳验证、nonce防重放
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SignatureServiceImplTest {
|
||||
|
||||
@InjectMocks
|
||||
private SignatureServiceImpl signatureService;
|
||||
|
||||
private static final String TEST_SECRET = "TestSecretKey123";
|
||||
private static final String TEST_METHOD = "GET";
|
||||
private static final String TEST_PATH = "/api/users";
|
||||
private static final String TEST_QUERY = "page=1&size=10";
|
||||
private static final String TEST_BODY = "";
|
||||
private static final String TEST_NONCE = "test-nonce-12345";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(signatureService, "signatureEnabled", true);
|
||||
ReflectionTestUtils.setField(signatureService, "maxAgeMinutes", 5);
|
||||
ReflectionTestUtils.setField(signatureService, "nonceCacheSize", 10000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateSignature_ShouldGenerateValidSignature() {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
String signature = signatureService.generateSignature(
|
||||
TEST_METHOD, TEST_PATH, TEST_QUERY, TEST_BODY, timestamp, TEST_NONCE, TEST_SECRET);
|
||||
|
||||
assertNotNull(signature);
|
||||
assertFalse(signature.isEmpty());
|
||||
assertTrue(signature.length() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateSignature_ShouldGenerateSameSignatureForSameInput() {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
String signature1 = signatureService.generateSignature(
|
||||
TEST_METHOD, TEST_PATH, TEST_QUERY, TEST_BODY, timestamp, TEST_NONCE, TEST_SECRET);
|
||||
String signature2 = signatureService.generateSignature(
|
||||
TEST_METHOD, TEST_PATH, TEST_QUERY, TEST_BODY, timestamp, TEST_NONCE, TEST_SECRET);
|
||||
|
||||
assertEquals(signature1, signature2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateSignature_ShouldGenerateDifferentSignatureForDifferentInput() {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
String signature1 = signatureService.generateSignature(
|
||||
TEST_METHOD, TEST_PATH, TEST_QUERY, TEST_BODY, timestamp, TEST_NONCE, TEST_SECRET);
|
||||
String signature2 = signatureService.generateSignature(
|
||||
"POST", TEST_PATH, TEST_QUERY, TEST_BODY, timestamp, TEST_NONCE, TEST_SECRET);
|
||||
|
||||
assertNotEquals(signature1, signature2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testVerifySignature_WithValidSignature_ShouldReturnTrue() {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String signature = signatureService.generateSignature(
|
||||
TEST_METHOD, TEST_PATH, TEST_QUERY, TEST_BODY, timestamp, TEST_NONCE, TEST_SECRET);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, TEST_PATH + "?" + TEST_QUERY)
|
||||
.header("X-Signature", signature)
|
||||
.header("X-Timestamp", String.valueOf(timestamp))
|
||||
.header("X-Nonce", TEST_NONCE)
|
||||
.build();
|
||||
|
||||
boolean isValid = signatureService.verifySignature(request, TEST_SECRET);
|
||||
|
||||
assertTrue(isValid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testVerifySignature_WithInvalidSignature_ShouldReturnFalse() {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, TEST_PATH)
|
||||
.header("X-Signature", "invalid-signature")
|
||||
.header("X-Timestamp", String.valueOf(timestamp))
|
||||
.header("X-Nonce", TEST_NONCE)
|
||||
.build();
|
||||
|
||||
boolean isValid = signatureService.verifySignature(request, TEST_SECRET);
|
||||
|
||||
assertFalse(isValid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testVerifySignature_WithMissingHeaders_ShouldReturnFalse() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, TEST_PATH)
|
||||
.build();
|
||||
|
||||
boolean isValid = signatureService.verifySignature(request, TEST_SECRET);
|
||||
|
||||
assertFalse(isValid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testVerifySignature_WithExpiredTimestamp_ShouldReturnFalse() {
|
||||
long expiredTimestamp = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(10);
|
||||
String signature = signatureService.generateSignature(
|
||||
TEST_METHOD, TEST_PATH, TEST_QUERY, TEST_BODY, expiredTimestamp, TEST_NONCE, TEST_SECRET);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, TEST_PATH)
|
||||
.header("X-Signature", signature)
|
||||
.header("X-Timestamp", String.valueOf(expiredTimestamp))
|
||||
.header("X-Nonce", TEST_NONCE)
|
||||
.build();
|
||||
|
||||
boolean isValid = signatureService.verifySignature(request, TEST_SECRET);
|
||||
|
||||
assertFalse(isValid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testVerifySignature_WithUsedNonce_ShouldReturnFalse() {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String signature = signatureService.generateSignature(
|
||||
TEST_METHOD, TEST_PATH, TEST_QUERY, TEST_BODY, timestamp, TEST_NONCE, TEST_SECRET);
|
||||
|
||||
signatureService.recordNonce(TEST_NONCE);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, TEST_PATH)
|
||||
.header("X-Signature", signature)
|
||||
.header("X-Timestamp", String.valueOf(timestamp))
|
||||
.header("X-Nonce", TEST_NONCE)
|
||||
.build();
|
||||
|
||||
boolean isValid = signatureService.verifySignature(request, TEST_SECRET);
|
||||
|
||||
assertFalse(isValid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTimestampValid_WithValidTimestamp_ShouldReturnTrue() {
|
||||
long validTimestamp = System.currentTimeMillis();
|
||||
|
||||
boolean isValid = signatureService.isTimestampValid(validTimestamp, 5);
|
||||
|
||||
assertTrue(isValid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTimestampValid_WithExpiredTimestamp_ShouldReturnFalse() {
|
||||
long expiredTimestamp = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(10);
|
||||
|
||||
boolean isValid = signatureService.isTimestampValid(expiredTimestamp, 5);
|
||||
|
||||
assertFalse(isValid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTimestampValid_WithFutureTimestamp_ShouldReturnFalse() {
|
||||
long futureTimestamp = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10);
|
||||
|
||||
boolean isValid = signatureService.isTimestampValid(futureTimestamp, 5);
|
||||
|
||||
assertFalse(isValid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsNonceUsed_WithNewNonce_ShouldReturnFalse() {
|
||||
boolean isUsed = signatureService.isNonceUsed("new-nonce-123");
|
||||
|
||||
assertFalse(isUsed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsNonceUsed_WithUsedNonce_ShouldReturnTrue() {
|
||||
String nonce = "used-nonce-123";
|
||||
signatureService.recordNonce(nonce);
|
||||
|
||||
boolean isUsed = signatureService.isNonceUsed(nonce);
|
||||
|
||||
assertTrue(isUsed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRecordNonce_ShouldIncreaseCacheSize() {
|
||||
int initialSize = signatureService.getNonceCacheSize();
|
||||
|
||||
signatureService.recordNonce("test-nonce-1");
|
||||
signatureService.recordNonce("test-nonce-2");
|
||||
signatureService.recordNonce("test-nonce-3");
|
||||
|
||||
int finalSize = signatureService.getNonceCacheSize();
|
||||
assertEquals(initialSize + 3, finalSize);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCleanupExpiredNonces_ShouldRemoveExpiredEntries() {
|
||||
ReflectionTestUtils.setField(signatureService, "nonceCacheSize", 5);
|
||||
|
||||
signatureService.recordNonce("nonce-1");
|
||||
signatureService.recordNonce("nonce-2");
|
||||
signatureService.recordNonce("nonce-3");
|
||||
signatureService.recordNonce("nonce-4");
|
||||
signatureService.recordNonce("nonce-5");
|
||||
signatureService.recordNonce("nonce-6");
|
||||
|
||||
int cacheSize = signatureService.getNonceCacheSize();
|
||||
assertTrue(cacheSize <= 6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testVerifySignature_WhenDisabled_ShouldReturnTrue() {
|
||||
ReflectionTestUtils.setField(signatureService, "signatureEnabled", false);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, TEST_PATH)
|
||||
.build();
|
||||
|
||||
boolean isValid = signatureService.verifySignature(request, TEST_SECRET);
|
||||
|
||||
assertTrue(isValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
spring:
|
||||
application:
|
||||
name: manage-gateway
|
||||
cloud:
|
||||
gateway:
|
||||
routes:
|
||||
- id: user-service
|
||||
uri: http://localhost:8084
|
||||
predicates:
|
||||
- Path=/api/users/**
|
||||
- id: auth-service
|
||||
uri: http://localhost:8083
|
||||
predicates:
|
||||
- Path=/api/auth/**
|
||||
|
||||
user:
|
||||
service:
|
||||
url: http://localhost:8084
|
||||
|
||||
permission:
|
||||
cache:
|
||||
expiry:
|
||||
minutes: 5
|
||||
|
||||
logging:
|
||||
level:
|
||||
cn.novalon.manage.gateway: DEBUG
|
||||
org.springframework.cloud.gateway: DEBUG
|
||||
org.springframework.web.reactive: DEBUG
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package cn.novalon.manage.sys.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/**
|
||||
* 密码编码器配置
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Configuration
|
||||
public class PasswordEncoderConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PasswordEncoderConfig.class);
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
|
||||
logger.info("创建主密码编码器: BCryptPasswordEncoder(strength=12), 类型: {}", encoder.getClass().getName());
|
||||
return encoder;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
logger.info("PasswordEncoderConfig 已加载");
|
||||
}
|
||||
}
|
||||
+11
@@ -28,6 +28,9 @@ public class SysUser extends BaseDomain {
|
||||
@Schema(description = "手机号", example = "13800138000")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "头像", example = "https://example.com/avatar.jpg")
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "角色ID", example = "1")
|
||||
private Long roleId;
|
||||
|
||||
@@ -74,6 +77,14 @@ public class SysUser extends BaseDomain {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getAvatar() {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
public void setAvatar(String avatar) {
|
||||
this.avatar = avatar;
|
||||
}
|
||||
|
||||
public Long getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package cn.novalon.manage.sys.core.domain;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "用户角色关联实体")
|
||||
public class UserRole {
|
||||
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "角色ID")
|
||||
private Long roleId;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "创建人")
|
||||
private String createdBy;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public Long getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
public void setRoleId(Long roleId) {
|
||||
this.roleId = roleId;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getCreatedBy() {
|
||||
return createdBy;
|
||||
}
|
||||
|
||||
public void setCreatedBy(String createdBy) {
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package cn.novalon.manage.sys.core.repository;
|
||||
|
||||
import cn.novalon.manage.sys.core.domain.UserRole;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface IUserRoleRepository {
|
||||
|
||||
Mono<UserRole> save(UserRole userRole);
|
||||
|
||||
Mono<Void> deleteById(Long id);
|
||||
|
||||
Mono<Void> deleteByUserId(Long userId);
|
||||
|
||||
Mono<Void> deleteByRoleId(Long roleId);
|
||||
|
||||
Flux<UserRole> findByUserId(Long userId);
|
||||
|
||||
Flux<UserRole> findByRoleId(Long roleId);
|
||||
|
||||
Mono<Long> countByUserId(Long userId);
|
||||
|
||||
Mono<Long> countByRoleId(Long roleId);
|
||||
|
||||
Flux<UserRole> findAll();
|
||||
|
||||
Mono<UserRole> findById(Long id);
|
||||
}
|
||||
+6
@@ -54,4 +54,10 @@ public interface ISysUserService {
|
||||
Mono<SysUser> changePassword(Long userId, String oldPassword, String newPassword);
|
||||
|
||||
Mono<Void> updateRoleIdToNullByRoleId(Long roleId);
|
||||
|
||||
Mono<Void> assignRolesToUser(Long userId, java.util.List<Long> roleIds);
|
||||
|
||||
Flux<cn.novalon.manage.sys.core.domain.SysRole> getUserRoles(Long userId);
|
||||
|
||||
Flux<Long> getUserRoleIds(Long userId);
|
||||
}
|
||||
|
||||
+63
-3
@@ -2,12 +2,20 @@ package cn.novalon.manage.sys.core.service.impl;
|
||||
|
||||
import cn.novalon.manage.common.util.StatusConstants;
|
||||
import cn.novalon.manage.sys.core.domain.SysUser;
|
||||
import cn.novalon.manage.sys.core.domain.SysRole;
|
||||
import cn.novalon.manage.sys.core.domain.UserRole;
|
||||
import cn.novalon.manage.common.dto.PageRequest;
|
||||
import cn.novalon.manage.common.dto.PageResponse;
|
||||
import cn.novalon.manage.sys.core.repository.ISysUserRepository;
|
||||
import cn.novalon.manage.sys.core.repository.ISysRoleRepository;
|
||||
import cn.novalon.manage.sys.core.repository.IUserRoleRepository;
|
||||
import cn.novalon.manage.sys.core.service.ISysUserService;
|
||||
import cn.novalon.manage.sys.core.command.CreateUserCommand;
|
||||
import cn.novalon.manage.sys.core.command.UpdateUserCommand;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
@@ -29,14 +37,26 @@ import java.util.List;
|
||||
@Service
|
||||
public class SysUserService implements ISysUserService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SysUserService.class);
|
||||
private final ISysUserRepository userRepository;
|
||||
private final ISysRoleRepository roleRepository;
|
||||
private final IUserRoleRepository userRoleRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public SysUserService(ISysUserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
public SysUserService(ISysUserRepository userRepository,
|
||||
ISysRoleRepository roleRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
@Qualifier("passwordEncoder") PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.userRoleRepository = userRoleRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
|
||||
logger.info("使用的密码编码器类型: {}", passwordEncoder.getClass().getName());
|
||||
}
|
||||
|
||||
private static final BCryptPasswordEncoder directEncoder = new BCryptPasswordEncoder(12);
|
||||
|
||||
@Override
|
||||
public Mono<SysUser> findById(Long id) {
|
||||
return userRepository.findById(id);
|
||||
@@ -73,7 +93,17 @@ public class SysUserService implements ISysUserService {
|
||||
|
||||
@Override
|
||||
public Mono<SysUser> createUser(SysUser user) {
|
||||
user.setPassword(passwordEncoder.encode(user.getPassword()));
|
||||
logger.info("SysUserService.createUser - 用户名: {}, 密码前缀: {}",
|
||||
user.getUsername(),
|
||||
user.getPassword() != null ? user.getPassword().substring(0, 7) : "null");
|
||||
if (user.getPassword() != null && !user.getPassword().startsWith("$2a$")
|
||||
&& !user.getPassword().startsWith("$2b$")) {
|
||||
logger.info("密码不以$2a$或$2b$开头,重新编码");
|
||||
user.setPassword(directEncoder.encode(user.getPassword()));
|
||||
logger.info("重新编码后的密码前缀: {}", user.getPassword().substring(0, 7));
|
||||
} else {
|
||||
logger.info("密码已编码,跳过重新编码");
|
||||
}
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
if (user.getStatus() == null) {
|
||||
user.setStatus(StatusConstants.ENABLED);
|
||||
@@ -85,7 +115,7 @@ public class SysUserService implements ISysUserService {
|
||||
public Mono<SysUser> createUser(CreateUserCommand command) {
|
||||
SysUser user = new SysUser();
|
||||
user.setUsername(command.username().getValue());
|
||||
user.setPassword(passwordEncoder.encode(command.password().getValue()));
|
||||
user.setPassword(directEncoder.encode(command.password().getValue()));
|
||||
user.setEmail(command.email().getValue());
|
||||
user.setNickname(command.nickname());
|
||||
user.setPhone(command.phone());
|
||||
@@ -196,4 +226,34 @@ public class SysUserService implements ISysUserService {
|
||||
public Mono<Void> restoreUsers(List<Long> ids) {
|
||||
return userRepository.restoreByIds(ids);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> assignRolesToUser(Long userId, List<Long> roleIds) {
|
||||
if (roleIds == null || roleIds.isEmpty()) {
|
||||
return userRoleRepository.deleteByUserId(userId);
|
||||
}
|
||||
|
||||
return userRoleRepository.deleteByUserId(userId)
|
||||
.thenMany(Flux.fromIterable(roleIds))
|
||||
.flatMap(roleId -> {
|
||||
UserRole userRole = new UserRole();
|
||||
userRole.setUserId(userId);
|
||||
userRole.setRoleId(roleId);
|
||||
userRole.setCreatedAt(LocalDateTime.now());
|
||||
return userRoleRepository.save(userRole);
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<SysRole> getUserRoles(Long userId) {
|
||||
return userRoleRepository.findByUserId(userId)
|
||||
.flatMap(userRole -> roleRepository.findById(userRole.getRoleId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<Long> getUserRoleIds(Long userId) {
|
||||
return userRoleRepository.findByUserId(userId)
|
||||
.map(UserRole::getRoleId);
|
||||
}
|
||||
}
|
||||
|
||||
+89
-21
@@ -6,15 +6,20 @@ import cn.novalon.manage.sys.dto.response.AuthResponse;
|
||||
import cn.novalon.manage.sys.security.JwtTokenProvider;
|
||||
import cn.novalon.manage.sys.core.domain.SysUser;
|
||||
import cn.novalon.manage.sys.core.domain.SysLoginLog;
|
||||
import cn.novalon.manage.sys.core.repository.ISysUserRepository;
|
||||
import cn.novalon.manage.sys.core.service.ISysUserService;
|
||||
import cn.novalon.manage.sys.core.service.ISysLoginLogService;
|
||||
import cn.novalon.manage.sys.util.UserAgentParser;
|
||||
import cn.novalon.manage.sys.util.IpLocationParser;
|
||||
import cn.novalon.manage.common.util.StatusConstants;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.validation.FieldError;
|
||||
@@ -43,19 +48,40 @@ public class SysAuthHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SysAuthHandler.class);
|
||||
private final ISysUserService userService;
|
||||
private final ISysUserRepository userRepository;
|
||||
@SuppressWarnings("unused")
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final ISysLoginLogService loginLogService;
|
||||
private final UserAgentParser userAgentParser;
|
||||
private final IpLocationParser ipLocationParser;
|
||||
|
||||
public SysAuthHandler(ISysUserService userService, PasswordEncoder passwordEncoder,
|
||||
// 使用多个编码器来支持不同的 BCrypt 版本和 strength
|
||||
private static final BCryptPasswordEncoder directEncoder10 = new BCryptPasswordEncoder(10);
|
||||
private static final BCryptPasswordEncoder directEncoder12 = new BCryptPasswordEncoder(12);
|
||||
|
||||
public SysAuthHandler(ISysUserService userService, ISysUserRepository userRepository,
|
||||
@Qualifier("passwordEncoder") PasswordEncoder passwordEncoder,
|
||||
JwtTokenProvider jwtTokenProvider, ISysLoginLogService loginLogService,
|
||||
UserAgentParser userAgentParser) {
|
||||
UserAgentParser userAgentParser, IpLocationParser ipLocationParser) {
|
||||
this.userService = userService;
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
this.loginLogService = loginLogService;
|
||||
this.userAgentParser = userAgentParser;
|
||||
this.ipLocationParser = ipLocationParser;
|
||||
|
||||
logger.info("SysAuthHandler使用的密码编码器类型: {}", passwordEncoder.getClass().getName());
|
||||
|
||||
// 测试编码器
|
||||
String testPassword = "test123";
|
||||
String testHash10 = directEncoder10.encode(testPassword);
|
||||
String testHash12 = directEncoder12.encode(testPassword);
|
||||
logger.info("DirectEncoder10测试: 密码={}, 哈希={}, 前缀={}",
|
||||
testPassword, testHash10.substring(0, 10), testHash10.substring(0, 7));
|
||||
logger.info("DirectEncoder12测试: 密码={}, 哈希={}, 前缀={}",
|
||||
testPassword, testHash12.substring(0, 10), testHash12.substring(0, 7));
|
||||
}
|
||||
|
||||
@Operation(summary = "用户登录", description = "使用用户名和密码登录系统")
|
||||
@@ -73,34 +99,64 @@ public class SysAuthHandler {
|
||||
String userAgent = request.headers().firstHeader("User-Agent");
|
||||
return userService.findByUsername(loginRequest.getUsername())
|
||||
.flatMap(user -> {
|
||||
if (!passwordEncoder.matches(loginRequest.getPassword(),
|
||||
// 尝试使用不同的编码器验证密码
|
||||
boolean passwordMatches = false;
|
||||
|
||||
// 首先尝试使用 strength=12 的编码器
|
||||
if (directEncoder12.matches(loginRequest.getPassword(),
|
||||
user.getPassword())) {
|
||||
passwordMatches = true;
|
||||
logger.info("密码验证成功: 使用strength=12编码器");
|
||||
}
|
||||
|
||||
// 如果失败,尝试使用 strength=10 的编码器
|
||||
if (!passwordMatches && directEncoder10.matches(
|
||||
loginRequest.getPassword(),
|
||||
user.getPassword())) {
|
||||
passwordMatches = true;
|
||||
logger.info("密码验证成功: 使用strength=10编码器");
|
||||
}
|
||||
|
||||
if (!passwordMatches) {
|
||||
logger.warn("用户登录失败: username={}, reason=密码错误",
|
||||
loginRequest.getUsername());
|
||||
recordLoginLog(loginRequest.getUsername(), clientIp, "1", "密码错误", userAgent);
|
||||
recordLoginLog(loginRequest.getUsername(),
|
||||
clientIp, "1", "密码错误",
|
||||
userAgent);
|
||||
return Mono.error(new RuntimeException(
|
||||
"用户名或密码错误"));
|
||||
}
|
||||
|
||||
if (user.getStatus() != 1) {
|
||||
logger.warn("用户登录失败: username={}, reason=用户已禁用",
|
||||
loginRequest.getUsername());
|
||||
recordLoginLog(loginRequest.getUsername(), clientIp, "1", "用户已禁用", userAgent);
|
||||
recordLoginLog(loginRequest.getUsername(),
|
||||
clientIp, "1", "用户已禁用",
|
||||
userAgent);
|
||||
return Mono.error(new RuntimeException(
|
||||
"用户名或密码错误"));
|
||||
}
|
||||
String token = jwtTokenProvider.generateToken(
|
||||
user.getUsername(), user.getId());
|
||||
logger.info("用户登录成功: username={}, userId={}",
|
||||
user.getUsername(), user.getId());
|
||||
recordLoginLog(loginRequest.getUsername(), clientIp, "0", "登录成功", userAgent);
|
||||
AuthResponse response = new AuthResponse(token,
|
||||
user.getId(), user.getUsername());
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
|
||||
return userService.getUserRoles(user.getId())
|
||||
.map(role -> role.getRoleKey())
|
||||
.collectList()
|
||||
.flatMap(roleKeys -> {
|
||||
String token = jwtTokenProvider.generateToken(
|
||||
user.getUsername(), user.getId(), roleKeys);
|
||||
logger.info("用户登录成功: username={}, userId={}, roles={}",
|
||||
user.getUsername(), user.getId(), roleKeys);
|
||||
recordLoginLog(loginRequest.getUsername(), clientIp,
|
||||
"0", "登录成功", userAgent);
|
||||
AuthResponse response = new AuthResponse(token,
|
||||
user.getId(), user.getUsername());
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
});
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
logger.warn("用户登录失败: username={}, reason=用户不存在",
|
||||
loginRequest.getUsername());
|
||||
recordLoginLog(loginRequest.getUsername(), clientIp, "1", "用户不存在", userAgent);
|
||||
recordLoginLog(loginRequest.getUsername(), clientIp,
|
||||
"1", "用户不存在", userAgent);
|
||||
return Mono.error(new RuntimeException("用户名或密码错误"));
|
||||
}));
|
||||
})
|
||||
@@ -140,17 +196,19 @@ public class SysAuthHandler {
|
||||
SysLoginLog loginLog = new SysLoginLog();
|
||||
loginLog.setUsername(username);
|
||||
loginLog.setIp(ip);
|
||||
loginLog.setLocation(ipLocationParser.parseLocation(ip));
|
||||
loginLog.setStatus(status);
|
||||
loginLog.setMessage(message);
|
||||
loginLog.setLoginTime(LocalDateTime.now());
|
||||
|
||||
|
||||
if (userAgent != null && !userAgent.isEmpty()) {
|
||||
loginLog.setBrowser(userAgentParser.parseBrowser(userAgent));
|
||||
loginLog.setOs(userAgentParser.parseOS(userAgent));
|
||||
}
|
||||
|
||||
|
||||
loginLogService.save(loginLog)
|
||||
.doOnSuccess(saved -> logger.debug("登录日志记录成功: username={}, status={}", username, status))
|
||||
.doOnSuccess(saved -> logger.debug("登录日志记录成功: username={}, status={}", username,
|
||||
status))
|
||||
.doOnError(error -> logger.error("登录日志记录失败: {}", error.getMessage()))
|
||||
.subscribe();
|
||||
} catch (Exception e) {
|
||||
@@ -186,8 +244,16 @@ public class SysAuthHandler {
|
||||
registerRequest.getUsername(), registerRequest.getEmail());
|
||||
SysUser user = new SysUser();
|
||||
user.setUsername(registerRequest.getUsername());
|
||||
user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
|
||||
String encodedPassword = directEncoder12.encode(registerRequest.getPassword());
|
||||
logger.info("密码编码结果: {} (前缀: {})",
|
||||
encodedPassword.substring(0, 10),
|
||||
encodedPassword.substring(0, 7));
|
||||
user.setPassword(encodedPassword);
|
||||
user.setEmail(registerRequest.getEmail());
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
if (user.getStatus() == null) {
|
||||
user.setStatus(StatusConstants.ENABLED);
|
||||
}
|
||||
return userService.findByUsername(registerRequest.getUsername())
|
||||
.flatMap(existing -> {
|
||||
logger.warn("用户注册失败: username={}, reason=用户名已存在",
|
||||
@@ -195,11 +261,13 @@ public class SysAuthHandler {
|
||||
return Mono.<ServerResponse>error(
|
||||
new RuntimeException("用户名已存在"));
|
||||
})
|
||||
.switchIfEmpty(userService.createUser(user)
|
||||
.switchIfEmpty(userRepository.save(user)
|
||||
.flatMap(u -> {
|
||||
logger.info("用户注册成功: username={}, userId={}",
|
||||
logger.info("用户注册成功: username={}, userId={}, password={}",
|
||||
u.getUsername(),
|
||||
u.getId());
|
||||
u.getId(),
|
||||
u.getPassword().substring(
|
||||
0, 10));
|
||||
return ServerResponse
|
||||
.status(HttpStatus.CREATED)
|
||||
.bodyValue(u);
|
||||
|
||||
+65
-6
@@ -16,7 +16,10 @@ import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户处理器
|
||||
@@ -74,7 +77,25 @@ public class SysUserHandler {
|
||||
public Mono<ServerResponse> getUserById(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return userService.findById(id)
|
||||
.flatMap(user -> ServerResponse.ok().bodyValue(user))
|
||||
.flatMap(user -> {
|
||||
return userService.getUserRoleIds(id)
|
||||
.collectList()
|
||||
.map(roleIds -> {
|
||||
Map<String, Object> userWithRoles = new HashMap<>();
|
||||
userWithRoles.put("id", user.getId());
|
||||
userWithRoles.put("username", user.getUsername());
|
||||
userWithRoles.put("nickname", user.getNickname());
|
||||
userWithRoles.put("email", user.getEmail());
|
||||
userWithRoles.put("phone", user.getPhone());
|
||||
userWithRoles.put("avatar", user.getAvatar());
|
||||
userWithRoles.put("status", user.getStatus());
|
||||
userWithRoles.put("roles", roleIds);
|
||||
userWithRoles.put("createdAt", user.getCreatedAt());
|
||||
userWithRoles.put("updatedAt", user.getUpdatedAt());
|
||||
return userWithRoles;
|
||||
});
|
||||
})
|
||||
.flatMap(userWithRoles -> ServerResponse.ok().bodyValue(userWithRoles))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
@@ -127,8 +148,16 @@ public class SysUserHandler {
|
||||
@Operation(summary = "删除用户", description = "物理删除用户")
|
||||
public Mono<ServerResponse> deleteUser(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return userService.deleteUser(id)
|
||||
.then(ServerResponse.noContent().build());
|
||||
return userService.findById(id)
|
||||
.flatMap(user -> userService.deleteUser(id)
|
||||
.then(ServerResponse.noContent().build()))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("User not found")))
|
||||
.onErrorResume(RuntimeException.class, ex -> {
|
||||
if (ex.getMessage().contains("not found")) {
|
||||
return ServerResponse.notFound().build();
|
||||
}
|
||||
return Mono.error(ex);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "修改密码", description = "修改用户密码")
|
||||
@@ -142,8 +171,16 @@ public class SysUserHandler {
|
||||
@Operation(summary = "逻辑删除用户", description = "逻辑删除单个用户")
|
||||
public Mono<ServerResponse> logicalDeleteUser(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return userService.logicalDeleteUser(id)
|
||||
.then(ServerResponse.noContent().build());
|
||||
return userService.findById(id)
|
||||
.flatMap(user -> userService.logicalDeleteUser(id)
|
||||
.then(ServerResponse.noContent().build()))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("User not found")))
|
||||
.onErrorResume(RuntimeException.class, ex -> {
|
||||
if (ex.getMessage().contains("not found")) {
|
||||
return ServerResponse.notFound().build();
|
||||
}
|
||||
return Mono.error(ex);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "批量逻辑删除用户", description = "批量逻辑删除多个用户")
|
||||
@@ -158,7 +195,13 @@ public class SysUserHandler {
|
||||
public Mono<ServerResponse> restoreUser(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return userService.restoreUser(id)
|
||||
.then(ServerResponse.noContent().build());
|
||||
.then(ServerResponse.noContent().build())
|
||||
.onErrorResume(RuntimeException.class, ex -> {
|
||||
if (ex.getMessage().contains("not found")) {
|
||||
return ServerResponse.notFound().build();
|
||||
}
|
||||
return Mono.error(ex);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "批量恢复用户", description = "批量恢复被逻辑删除的用户")
|
||||
@@ -182,4 +225,20 @@ public class SysUserHandler {
|
||||
return userService.existsByEmail(email)
|
||||
.flatMap(exists -> ServerResponse.ok().bodyValue(exists));
|
||||
}
|
||||
|
||||
@Operation(summary = "为用户分配角色", description = "为指定用户分配角色列表")
|
||||
public Mono<ServerResponse> assignRoles(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return request.bodyToMono(new org.springframework.core.ParameterizedTypeReference<List<Long>>() {
|
||||
})
|
||||
.flatMap(roleIds -> userService.assignRolesToUser(id, roleIds))
|
||||
.then(ServerResponse.ok().build());
|
||||
}
|
||||
|
||||
@Operation(summary = "获取用户的角色", description = "根据用户ID获取该用户拥有的所有角色")
|
||||
public Mono<ServerResponse> getUserRoles(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return ServerResponse.ok()
|
||||
.body(userService.getUserRoles(id), cn.novalon.manage.sys.core.domain.SysRole.class);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -13,6 +13,9 @@ import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 操作日志过滤器
|
||||
*
|
||||
@@ -29,8 +32,11 @@ public class OperationLogFilter implements WebFilter {
|
||||
private static final Logger logger = LoggerFactory.getLogger(OperationLogFilter.class);
|
||||
|
||||
private final IOperationLogService logService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public OperationLogFilter(IOperationLogService logService, ObjectMapper objectMapper) {
|
||||
this.logService = logService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -91,8 +97,14 @@ public class OperationLogFilter implements WebFilter {
|
||||
log.setResult("Success");
|
||||
}
|
||||
|
||||
String queryParams = exchange.getRequest().getQueryParams().toSingleValueMap().toString();
|
||||
log.setParams(queryParams);
|
||||
Map<String, String> queryParams = new LinkedHashMap<>(exchange.getRequest().getQueryParams().toSingleValueMap());
|
||||
String formattedParams;
|
||||
try {
|
||||
formattedParams = objectMapper.writeValueAsString(queryParams);
|
||||
} catch (Exception e) {
|
||||
formattedParams = queryParams.toString();
|
||||
}
|
||||
log.setParams(formattedParams);
|
||||
|
||||
logService.save(log)
|
||||
.doOnSuccess(saved -> logger.debug("操作日志记录成功: {}", log.getOperation()))
|
||||
|
||||
+13
-3
@@ -11,7 +11,8 @@ import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JWT认证过滤器
|
||||
@@ -33,14 +34,23 @@ public class JwtAuthenticationFilter implements WebFilter {
|
||||
String token = extractToken(exchange.getRequest());
|
||||
|
||||
if (token != null && jwtTokenProvider.validateToken(token)) {
|
||||
Long userId = jwtTokenProvider.getUserIdFromToken(token);
|
||||
String username = jwtTokenProvider.getUsernameFromToken(token);
|
||||
Long userId = jwtTokenProvider.getUserIdFromToken(token);
|
||||
List<String> roles = jwtTokenProvider.getRolesFromToken(token);
|
||||
|
||||
List<SimpleGrantedAuthority> authorities = roles.stream()
|
||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (authorities.isEmpty()) {
|
||||
authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
}
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
username,
|
||||
null,
|
||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
authorities
|
||||
);
|
||||
|
||||
return chain.filter(exchange)
|
||||
|
||||
+24
@@ -45,6 +45,21 @@ public class JwtTokenProvider {
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateToken(String username, Long userId, java.util.List<String> roles) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("userId", userId);
|
||||
claims.put("username", username);
|
||||
claims.put("roles", roles);
|
||||
|
||||
return Jwts.builder()
|
||||
.setClaims(claims)
|
||||
.setSubject(username)
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration()))
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims getClaimsFromToken(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKey())
|
||||
@@ -61,6 +76,15 @@ public class JwtTokenProvider {
|
||||
return getClaimsFromToken(token).get("userId", Long.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public java.util.List<String> getRolesFromToken(String token) {
|
||||
Object roles = getClaimsFromToken(token).get("roles");
|
||||
if (roles instanceof java.util.List) {
|
||||
return (java.util.List<String>) roles;
|
||||
}
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
getClaimsFromToken(token);
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package cn.novalon.manage.sys.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IP地址解析工具类
|
||||
*
|
||||
* 用于解析IP地址,获取地理位置信息
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
@Component
|
||||
public class IpLocationParser {
|
||||
|
||||
private static final Map<String, String> IP_LOCATION_CACHE = new HashMap<>();
|
||||
|
||||
static {
|
||||
IP_LOCATION_CACHE.put("127.0.0.1", "本地");
|
||||
IP_LOCATION_CACHE.put("0:0:0:0:0:0:0:1", "本地");
|
||||
IP_LOCATION_CACHE.put("localhost", "本地");
|
||||
}
|
||||
|
||||
public String parseLocation(String ip) {
|
||||
if (ip == null || ip.isEmpty()) {
|
||||
return "未知位置";
|
||||
}
|
||||
|
||||
if (IP_LOCATION_CACHE.containsKey(ip)) {
|
||||
return IP_LOCATION_CACHE.get(ip);
|
||||
}
|
||||
|
||||
if (isInternalIp(ip)) {
|
||||
return "内网";
|
||||
}
|
||||
|
||||
return "未知位置";
|
||||
}
|
||||
|
||||
private boolean isInternalIp(String ip) {
|
||||
if (ip == null || ip.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
int first = Integer.parseInt(parts[0]);
|
||||
int second = Integer.parseInt(parts[1]);
|
||||
|
||||
if (first == 10) {
|
||||
return true;
|
||||
}
|
||||
if (first == 172 && second >= 16 && second <= 31) {
|
||||
return true;
|
||||
}
|
||||
if (first == 192 && second == 168) {
|
||||
return true;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+2
-7
@@ -17,12 +17,7 @@ import java.util.regex.Pattern;
|
||||
public class UserAgentParser {
|
||||
|
||||
private static final Pattern BROWSER_PATTERN = Pattern.compile(
|
||||
"(Chrome|Firefox|Safari|Edge|MSIE|Trident|Opera)[/\\s]([\\d.]+)"
|
||||
);
|
||||
|
||||
private static final Pattern OS_PATTERN = Pattern.compile(
|
||||
"(Windows NT|Mac OS X|Linux|Android|iPhone|iPad|iPod)[\\s/_-]?([\\d._]+)?"
|
||||
);
|
||||
"(Chrome|Firefox|Safari|Edge|MSIE|Trident|Opera)[/\\s]([\\d.]+)");
|
||||
|
||||
/**
|
||||
* 解析User-Agent字符串,返回浏览器信息
|
||||
@@ -55,7 +50,7 @@ public class UserAgentParser {
|
||||
}
|
||||
|
||||
String ua = userAgent;
|
||||
|
||||
|
||||
if (ua.contains("Windows NT 10.0")) {
|
||||
return "Windows 10";
|
||||
} else if (ua.contains("Windows NT 6.3")) {
|
||||
|
||||
+7
-1
@@ -36,6 +36,12 @@ class SysUserServiceTest {
|
||||
@Mock
|
||||
private ISysUserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private cn.novalon.manage.sys.core.repository.ISysRoleRepository roleRepository;
|
||||
|
||||
@Mock
|
||||
private cn.novalon.manage.sys.core.repository.IUserRoleRepository userRoleRepository;
|
||||
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@@ -45,7 +51,7 @@ class SysUserServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userService = new SysUserService(userRepository, passwordEncoder);
|
||||
userService = new SysUserService(userRepository, roleRepository, userRoleRepository, passwordEncoder);
|
||||
|
||||
testUser = new SysUser();
|
||||
testUser.setId(1L);
|
||||
|
||||
+9
-2
@@ -7,6 +7,7 @@ import cn.novalon.manage.sys.core.domain.SysUser;
|
||||
import cn.novalon.manage.sys.core.service.ISysUserService;
|
||||
import cn.novalon.manage.sys.core.service.ISysLoginLogService;
|
||||
import cn.novalon.manage.sys.util.UserAgentParser;
|
||||
import cn.novalon.manage.sys.util.IpLocationParser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -30,6 +31,9 @@ class SysAuthHandlerTest {
|
||||
@Mock
|
||||
private ISysUserService userService;
|
||||
|
||||
@Mock
|
||||
private cn.novalon.manage.sys.core.repository.ISysUserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@@ -42,13 +46,16 @@ class SysAuthHandlerTest {
|
||||
@Mock
|
||||
private UserAgentParser userAgentParser;
|
||||
|
||||
@Mock
|
||||
private IpLocationParser ipLocationParser;
|
||||
|
||||
private SysAuthHandler authHandler;
|
||||
private SysUser testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
authHandler = new SysAuthHandler(userService, passwordEncoder, jwtTokenProvider, loginLogService,
|
||||
userAgentParser);
|
||||
authHandler = new SysAuthHandler(userService, userRepository, passwordEncoder, jwtTokenProvider, loginLogService,
|
||||
userAgentParser, ipLocationParser);
|
||||
|
||||
testUser = new SysUser();
|
||||
testUser.setId(1L);
|
||||
|
||||
-2
@@ -16,8 +16,6 @@ import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
package cn.novalon.manage.sys.util;
|
||||
|
||||
import cn.novalon.manage.sys.util.UserAgentParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user