refactor(security): 重构安全配置并优化测试环境

- 移除旧的测试套件和UAT测试文件
- 更新密码编码器配置使用BCrypt strength=12
- 添加用户角色关联表和相关服务
- 优化前端日期显示格式
- 清理无用资源和配置文件
- 增强测试数据管理和清理功能
This commit is contained in:
张翔
2026-03-27 13:00:22 +08:00
parent ce30893a96
commit af44c23f21
294 changed files with 16057 additions and 22601 deletions
@@ -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("应用程序启动完成");
}
}
@@ -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);
}
}
@@ -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;
+4
View File
@@ -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>
@@ -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;
}
}
@@ -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);
}
@@ -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;
}
}
@@ -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
@@ -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 '登录日志表';
@@ -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));
@@ -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 '创建人';
@@ -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);
@@ -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());
@@ -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
+10
View File
@@ -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>
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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 {
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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);
}
@@ -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();
}
@@ -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();
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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();
}
}
@@ -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:
@@ -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));
}
}
@@ -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();
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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())
@@ -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);
}
}
@@ -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);
}
}
@@ -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"));
}
}
@@ -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;
}
}
@@ -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
);
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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
@@ -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 已加载");
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
@@ -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);
}
}
@@ -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()))
@@ -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)
@@ -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);
@@ -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;
}
}
@@ -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")) {
@@ -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);
@@ -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);
@@ -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,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.*;