feat(admin): 添加用户管理相关文件

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
@@ -0,0 +1,11 @@
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/everything-is-suitable-gateway-1.0.0.jar app.jar
EXPOSE 8080
ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
@@ -0,0 +1,183 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.destiny</groupId>
<artifactId>everything-is-suitable-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>everything-is-suitable-gateway</artifactId>
<packaging>jar</packaging>
<name>Everything Is Suitable Gateway</name>
<description>Gateway module for Everything Is Suitable API</description>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>io.destiny</groupId>
<artifactId>everything-is-suitable-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.destiny</groupId>
<artifactId>everything-is-suitable-biz</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.destiny</groupId>
<artifactId>everything-is-suitable-client</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.destiny</groupId>
<artifactId>everything-is-suitable-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.destiny</groupId>
<artifactId>everything-is-suitable-statistics</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<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>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.50</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.50</minimum>
</limit>
</limits>
</rule>
</rules>
<excludes>
<exclude>**/dto/**</exclude>
<exclude>**/domain/**</exclude>
<exclude>**/enums/**</exclude>
<exclude>**/config/**</exclude>
<exclude>**/exception/**</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.50</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.50</minimum>
</limit>
</limits>
</rule>
</rules>
<excludes>
<exclude>**/dto/**</exclude>
<exclude>**/domain/**</exclude>
<exclude>**/enums/**</exclude>
<exclude>**/config/**</exclude>
<exclude>**/exception/**</exclude>
</excludes>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,77 @@
package io.destiny.gateway.config;
import io.destiny.common.exception.ReactiveGlobalExceptionHandler;
import io.destiny.gateway.handler.ClientHandler;
import io.destiny.gateway.handler.HealthHandler;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springdoc.core.annotations.RouterOperation;
import org.springdoc.core.annotations.RouterOperations;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
/**
* 网关路由配置
*
* @author zhangxiang
*/
@Configuration
public class GatewayConfig {
/**
* 配置全局异常处理器
*
* @return 全局异常处理器
*/
@Bean
public ReactiveGlobalExceptionHandler reactiveGlobalExceptionHandler() {
return new ReactiveGlobalExceptionHandler();
}
/**
* 配置客户端路由
* 所有 /client/** 请求将路由到 ClientHandler
*
* @param clientHandler 客户端处理器
* @return 路由函数
*/
@Bean
public RouterFunction<ServerResponse> clientRouter(ClientHandler clientHandler) {
return route()
.POST("/client/auth/register", accept(MediaType.APPLICATION_JSON),
clientHandler::register)
.POST("/client/auth/login", accept(MediaType.APPLICATION_JSON),
clientHandler::login)
.POST("/client/auth/logout/{userId}", clientHandler::logout)
.GET("/client/auth/status/{userId}", clientHandler::checkLoginStatus)
.build();
}
/**
* 配置健康检查路由
*
* @param healthHandler 健康检查处理器
* @return 路由函数
*/
@Bean
@RouterOperations({
@RouterOperation(path = "/health", method = RequestMethod.GET, beanClass = HealthHandler.class, beanMethod = "handleHealth")
})
@Operation(summary = "健康检查路由配置")
@Tag(name = "健康检查", description = "健康检查相关接口")
public RouterFunction<ServerResponse> healthRouter(HealthHandler healthHandler) {
return route()
.GET("/health", healthHandler::handleHealth)
.build();
}
}
@@ -0,0 +1,20 @@
package io.destiny.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@ConfigurationProperties(prefix = "gateway")
public class GatewayProperties {
private List<String> publicPaths = new ArrayList<>();
public List<String> getPublicPaths() {
return publicPaths;
}
public void setPublicPaths(List<String> publicPaths) {
this.publicPaths = publicPaths;
}
}
@@ -0,0 +1,122 @@
package io.destiny.gateway.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Configuration
public class SwaggerConfig {
@Value("${springdoc.swagger-ui.path:/swagger-ui.html}")
private String swaggerUiPath;
@Value("${server.port:8080}")
private int serverPort;
@Value("${spring.application.name:everything-is-suitable-api}")
private String applicationName;
@Value("${spring.profiles.active:local}")
private String activeProfile;
private static final String SECURITY_SCHEME_NAME = "JWT认证";
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Everything Is Suitable API")
.version("1.0.0")
.description("紫微斗数API接口文档 - 提供紫微命盘生成、运势分析等核心功能")
.contact(new Contact()
.name("张翔")
.email("zhangxiang@destiny.io")
.url("https://github.com/destiny/everything-is-suitable-api"))
.license(new License()
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0.html")))
.servers(List.of(
new Server().url("http://localhost:" + serverPort)
.description("本地开发环境"),
new Server().url("http://dev-api.destiny.io").description("开发环境"),
new Server().url("http://test-api.destiny.io").description("测试环境")))
.components(new Components()
.addSecuritySchemes(SECURITY_SCHEME_NAME,
new SecurityScheme()
.name(SECURITY_SCHEME_NAME)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("请输入JWT Token,格式: Bearer {token}")))
.addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME));
}
@Bean
public GroupedOpenApi clientApi() {
return GroupedOpenApi.builder()
.group("client-api")
.displayName("客户端接口")
.pathsToMatch("/client/**")
.packagesToScan("io.destiny.client.handler")
.build();
}
@Bean
public GroupedOpenApi sysApi() {
return GroupedOpenApi.builder()
.group("sys-api")
.displayName("系统管理接口")
.pathsToMatch("/sys/**")
.packagesToScan("io.destiny.sys.handler")
.build();
}
@Bean
public GroupedOpenApi ziweiApi() {
return GroupedOpenApi.builder()
.group("ziwei-api")
.displayName("紫微斗数接口")
.pathsToMatch("/ziwei/**")
.build();
}
@Bean
public GroupedOpenApi healthApi() {
return GroupedOpenApi.builder()
.group("health-api")
.displayName("健康检查接口")
.pathsToMatch("/health", "/actuator/**")
.build();
}
@Bean
public GroupedOpenApi bizApi() {
return GroupedOpenApi.builder()
.group("biz-api")
.displayName("业务接口")
.pathsToMatch("/almanac/**", "/fortune/**", "/calendar/**", "/lunar-calendar/**")
.build();
}
@Bean
public GroupedOpenApi statisticsApi() {
return GroupedOpenApi.builder()
.group("statistics-api")
.displayName("统计接口")
.pathsToMatch("/statistics/**", "/export/**")
.packagesToScan("io.destiny.statistics.handler")
.build();
}
}
@@ -0,0 +1,92 @@
package io.destiny.gateway.config;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.Paths;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
public class SwaggerSecurityConfig {
private static final String SECURITY_SCHEME_NAME = "JWT认证";
@Bean
@Profile({"local", "dev"})
public OpenApiCustomizer disableSecurityForDev() {
return openApi -> {
Paths paths = openApi.getPaths();
if (paths != null) {
paths.forEach((path, pathItem) -> {
removeSecurityFromPathItem(pathItem);
});
}
};
}
@Bean
@Profile({"test", "prod"})
public OpenApiCustomizer enableSecurityForProd() {
return openApi -> {
Paths paths = openApi.getPaths();
if (paths != null) {
paths.forEach((path, pathItem) -> {
addSecurityToPathItem(pathItem, path);
});
}
};
}
private void removeSecurityFromPathItem(PathItem pathItem) {
if (pathItem.getGet() != null) {
pathItem.getGet().setSecurity(null);
}
if (pathItem.getPost() != null) {
pathItem.getPost().setSecurity(null);
}
if (pathItem.getPut() != null) {
pathItem.getPut().setSecurity(null);
}
if (pathItem.getDelete() != null) {
pathItem.getDelete().setSecurity(null);
}
if (pathItem.getPatch() != null) {
pathItem.getPatch().setSecurity(null);
}
}
private void addSecurityToPathItem(PathItem pathItem, String path) {
if (isPublicEndpoint(path)) {
return;
}
SecurityRequirement securityRequirement = new SecurityRequirement().addList(SECURITY_SCHEME_NAME);
if (pathItem.getGet() != null && pathItem.getGet().getSecurity() == null) {
pathItem.getGet().addSecurityItem(securityRequirement);
}
if (pathItem.getPost() != null && pathItem.getPost().getSecurity() == null) {
pathItem.getPost().addSecurityItem(securityRequirement);
}
if (pathItem.getPut() != null && pathItem.getPut().getSecurity() == null) {
pathItem.getPut().addSecurityItem(securityRequirement);
}
if (pathItem.getDelete() != null && pathItem.getDelete().getSecurity() == null) {
pathItem.getDelete().addSecurityItem(securityRequirement);
}
if (pathItem.getPatch() != null && pathItem.getPatch().getSecurity() == null) {
pathItem.getPatch().addSecurityItem(securityRequirement);
}
}
private boolean isPublicEndpoint(String path) {
return path.contains("/auth/register") ||
path.contains("/auth/login") ||
path.contains("/auth/refresh") ||
path.contains("/auth/logout") ||
path.contains("/health") ||
path.contains("/actuator");
}
}
@@ -0,0 +1,142 @@
package io.destiny.gateway.filter;
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.Value;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
@Component("gatewayJwtAuthenticationFilter")
@Order(1)
public class JwtAuthenticationFilter implements WebFilter {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private static final String BEARER_PREFIX = "Bearer ";
private static final String USER_ID_HEADER = "X-User-Id";
private static final String USERNAME_HEADER = "X-Username";
private final SecretKey signingKey;
private final List<String> publicPaths;
public JwtAuthenticationFilter(
@Value("${jwt.secret:this-is-a-secure-jwt-secret-key-that-must-be-at-least-64-characters-long-for-hs512-algorithm}") String secret,
@Value("${gateway.public-paths:/api/client/auth/register,/api/client/auth/login,/api/sys/auth/register,/api/sys/auth/login,/api/sys/auth/refresh,/api/sys/auth/logout,/sys/auth/register,/sys/auth/login,/sys/auth/refresh,/sys/auth/logout,/api/health,/api/swagger-ui,/api/swagger-ui/**,/api/v3/api-docs,/api/v3/api-docs/**,/api/webjars/swagger-ui/**,/api/webjars/**,/api/actuator/**,/api/almanac,/api/almanac/**,/actuator/**,/swagger-ui,/swagger-ui/**,/v3/api-docs,/v3/api-docs/**,/webjars/swagger-ui/**,/webjars/**,/almanac,/almanac/**}") String publicPaths) {
this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.publicPaths = List.of(publicPaths.split(","));
logger.info("Loaded {} public paths: {}", this.publicPaths.size(), this.publicPaths);
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getPath().value();
System.out.println("JwtAuthenticationFilter processing path: " + path);
// Special handling for Swagger paths
if (path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || path.startsWith("/webjars")) {
System.out.println("Swagger path detected, skipping authentication: " + path);
logger.info("Swagger path detected, skipping authentication: {}", path);
return chain.filter(exchange);
}
if (isPublicPath(path)) {
logger.debug("Public path, skipping authentication: {}", path);
return chain.filter(exchange);
}
String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
logger.warn("Missing or invalid authorization header for path: {}", path);
return unauthorized(exchange, "Missing or invalid authorization header");
}
String token = authHeader.substring(BEARER_PREFIX.length());
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token)
.getBody();
Long userId = claims.get("userId", Long.class);
String username = claims.getSubject();
if (userId == null || username == null) {
logger.warn("Invalid token claims for path: {}", path);
return unauthorized(exchange, "Invalid token claims");
}
Date expiration = claims.getExpiration();
if (expiration != null && expiration.before(new Date())) {
logger.warn("Token expired for user: {} ({})", username, userId);
return unauthorized(exchange, "Token has expired");
}
ServerWebExchange modifiedExchange = exchange.mutate()
.request(r -> r.header(USER_ID_HEADER, userId.toString())
.header(USERNAME_HEADER, username))
.build();
logger.debug("Authentication successful for user: {} ({})", username, userId);
return chain.filter(modifiedExchange);
} catch (Exception e) {
logger.error("Token validation failed for path: {}", path, e);
return unauthorized(exchange, "Invalid or expired token");
}
}
private boolean isPublicPath(String path) {
logger.info("JwtAuthenticationFilter.isPublicPath called with path: '{}'", path);
logger.info("Public paths list: {}", publicPaths);
boolean isPublic = publicPaths.stream().anyMatch(publicPath -> {
boolean match = false;
if (publicPath.endsWith("/**")) {
String prefix = publicPath.substring(0, publicPath.length() - 3);
match = path.startsWith(prefix);
logger.info(" Checking path '{}' against public path '{}/**': prefix='{}', startsWith={}", path, prefix, prefix, match);
} else if (publicPath.endsWith("/*")) {
String prefix = publicPath.substring(0, publicPath.length() - 2);
match = path.startsWith(prefix) && path.substring(prefix.length()).indexOf('/') == -1;
logger.info(" Checking path '{}' against public path '{}/*': prefix='{}', match={}", path, prefix, prefix, match);
} else {
match = path.equals(publicPath) || path.startsWith(publicPath + "/");
logger.info(" Checking path '{}' against public path '{}': equals={}, startsWith={}", path, publicPath, path.equals(publicPath), path.startsWith(publicPath + "/"));
}
logger.debug("Checking path '{}' against public path '{}': {}", path, publicPath, match);
return match;
});
logger.info("Checking path '{}' against public paths: {}", path, isPublic);
return isPublic;
}
private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = String.format("{\"code\":\"UNAUTHORIZED\",\"message\":\"%s\"}", message);
return exchange.getResponse().writeWith(
reactor.core.publisher.Flux.just(
exchange.getResponse().bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8))));
}
}
@@ -0,0 +1,51 @@
package io.destiny.gateway.filter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.Instant;
/**
* 网关日志过滤器
* 记录请求和响应信息
*
* @author zhangxiang
*/
@Component
public class LoggingFilter implements WebFilter {
private static final Logger logger = LoggerFactory.getLogger(LoggingFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
Instant startTime = Instant.now();
String method = exchange.getRequest().getMethod().name();
String path = exchange.getRequest().getPath().value();
String clientIp = exchange.getRequest().getRemoteAddress() != null
? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
: "unknown";
logger.info("Request: {} {} from {}", method, path, clientIp);
return chain.filter(exchange)
.doOnSuccess(aVoid -> {
Duration duration = Duration.between(startTime, Instant.now());
int statusCode = exchange.getResponse().getStatusCode() != null
? exchange.getResponse().getStatusCode().value()
: 0;
logger.info("Response: {} {} - Status: {} - Duration: {}ms",
method, path, statusCode, duration.toMillis());
})
.doOnError(error -> {
Duration duration = Duration.between(startTime, Instant.now());
logger.error("Error: {} {} - Error: {} - Duration: {}ms",
method, path, error.getMessage(), duration.toMillis());
});
}
}
@@ -0,0 +1,136 @@
package io.destiny.gateway.filter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Component
@Order(3)
public class RateLimitFilter implements WebFilter {
private static final Logger logger = LoggerFactory.getLogger(RateLimitFilter.class);
private final ConcurrentHashMap<String, RateLimiter> rateLimiters = new ConcurrentHashMap<>();
private final int maxRequests;
private final Duration timeWindow;
private final boolean enabled;
private final List<String> whitelist;
public RateLimitFilter(@Value("${gateway.rate-limit.max-requests:100}") int maxRequests,
@Value("${gateway.rate-limit.time-window-seconds:60}") int timeWindowSeconds,
@Value("${gateway.rate-limit.enabled:true}") boolean enabled,
@Value("${gateway.rate-limit.whitelist:}") String whitelistConfig) {
this.maxRequests = maxRequests;
this.timeWindow = Duration.ofSeconds(timeWindowSeconds);
this.enabled = enabled;
this.whitelist = whitelistConfig != null && !whitelistConfig.isEmpty()
? List.of(whitelistConfig.split(","))
: List.of();
logger.info("Rate limit initialized: {} requests per {} seconds, enabled: {}, whitelist: {}",
maxRequests, timeWindowSeconds, enabled, whitelist);
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
if (!enabled) {
return chain.filter(exchange);
}
String clientIp = getClientIp(exchange);
String path = exchange.getRequest().getPath().value();
if (isWhitelisted(clientIp)) {
logger.debug("IP {} is whitelisted, skipping rate limit", clientIp);
return chain.filter(exchange);
}
RateLimiter rateLimiter = rateLimiters.computeIfAbsent(clientIp, k -> new RateLimiter(maxRequests, timeWindow));
if (!rateLimiter.tryAcquire()) {
logger.warn("Rate limit exceeded for IP: {} on path: {}", clientIp, path);
return tooManyRequests(exchange, "Rate limit exceeded. Please try again later.");
}
return chain.filter(exchange);
}
private boolean isWhitelisted(String clientIp) {
return whitelist.stream().anyMatch(whitelistedIp -> {
if (whitelistedIp.equals("127.0.0.1") || whitelistedIp.equals("localhost")) {
return clientIp.equals("127.0.0.1") || clientIp.equals("::1") || clientIp.equals("0:0:0:0:1");
}
return clientIp.equals(whitelistedIp);
});
}
private String getClientIp(ServerWebExchange exchange) {
String xForwardedFor = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
String xRealIp = exchange.getRequest().getHeaders().getFirst("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}
return exchange.getRequest().getRemoteAddress() != null
? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
: "unknown";
}
private Mono<Void> tooManyRequests(ServerWebExchange exchange, String message) {
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = String.format("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"%s\"}", message);
return exchange.getResponse().writeWith(
reactor.core.publisher.Flux.just(
exchange.getResponse().bufferFactory().wrap(body.getBytes(java.nio.charset.StandardCharsets.UTF_8))
)
);
}
private static class RateLimiter {
private final AtomicLong counter = new AtomicLong(0);
private volatile Instant windowStart = Instant.now();
private final int maxRequests;
private final Duration timeWindow;
public RateLimiter(int maxRequests, Duration timeWindow) {
this.maxRequests = maxRequests;
this.timeWindow = timeWindow;
}
public synchronized boolean tryAcquire() {
Instant now = Instant.now();
if (Duration.between(windowStart, now).compareTo(timeWindow) > 0) {
windowStart = now;
counter.set(0);
}
if (counter.incrementAndGet() <= maxRequests) {
return true;
}
counter.decrementAndGet();
return false;
}
}
}
@@ -0,0 +1,52 @@
package io.destiny.gateway.filter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
@Component
@Order(3)
public class SecurityHeadersFilter implements WebFilter {
private static final Logger logger = LoggerFactory.getLogger(SecurityHeadersFilter.class);
private static final String X_FRAME_OPTIONS = "X-Frame-Options";
private static final String X_CONTENT_TYPE_OPTIONS = "X-Content-Type-Options";
private static final String X_XSS_PROTECTION = "X-XSS-Protection";
private static final String CONTENT_SECURITY_POLICY = "Content-Security-Policy";
private static final String STRICT_TRANSPORT_SECURITY = "Strict-Transport-Security";
private static final String REFERRER_POLICY = "Referrer-Policy";
private static final String PERMISSIONS_POLICY = "Permissions-Policy";
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
ServerHttpResponse response = exchange.getResponse();
HttpHeaders headers = response.getHeaders();
headers.set(X_FRAME_OPTIONS, "DENY");
headers.set(X_CONTENT_TYPE_OPTIONS, "nosniff");
headers.set(X_XSS_PROTECTION, "1; mode=block");
headers.set(CONTENT_SECURITY_POLICY, "default-src 'self'");
headers.set(STRICT_TRANSPORT_SECURITY, "max-age=31536000; includeSubDomains");
headers.set(REFERRER_POLICY, "no-referrer-when-downgrade");
headers.set(PERMISSIONS_POLICY, "geolocation 'none', microphone 'none'");
headers.set("X-Request-ID", generateRequestId());
headers.set("X-Response-Time", String.valueOf(System.currentTimeMillis()));
logger.debug("Security headers added for request: {}", exchange.getRequest().getPath().value());
return chain.filter(exchange);
}
private String generateRequestId() {
return java.util.UUID.randomUUID().toString();
}
}
@@ -0,0 +1,177 @@
package io.destiny.gateway.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.destiny.common.utils.XssUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@Component
@Order(2)
public class XssFilter implements WebFilter {
private static final Logger logger = LoggerFactory.getLogger(XssFilter.class);
private final ObjectMapper objectMapper;
public XssFilter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getPath().value();
String contentType = request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
if (contentType == null) {
return chain.filter(exchange);
}
if (!contentType.contains(MediaType.APPLICATION_JSON_VALUE) &&
!contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE) &&
!contentType.contains(MediaType.TEXT_PLAIN_VALUE)) {
return chain.filter(exchange);
}
String method = request.getMethod().name();
if (!"POST".equals(method) && !"PUT".equals(method) && !"PATCH".equals(method)) {
return chain.filter(exchange);
}
if (shouldSkipXssCleaning(path)) {
logger.debug("Skipping XSS cleaning for path: {}", path);
return chain.filter(exchange);
}
return DataBufferUtils.join(request.getBody())
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
String body = new String(bytes, StandardCharsets.UTF_8);
String cleanedBody = cleanRequestBody(body, contentType);
ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(request) {
@Override
public Flux<DataBuffer> getBody() {
return Flux.defer(() -> {
byte[] cleanedBytes = cleanedBody.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(cleanedBytes);
return Flux.just(buffer);
});
}
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.putAll(super.getHeaders());
headers.setContentLength(cleanedBody.getBytes(StandardCharsets.UTF_8).length);
return headers;
}
};
logger.debug("XSS filter applied for request: {}", request.getPath().value());
return chain.filter(exchange.mutate().request(mutatedRequest).build());
})
.switchIfEmpty(chain.filter(exchange));
}
private boolean shouldSkipXssCleaning(String path) {
return path.startsWith("/sys/auth/login") ||
path.startsWith("/sys/auth/register") ||
path.startsWith("/sys/auth/refresh") ||
path.startsWith("/sys/auth/logout") ||
path.startsWith("/api/sys/auth/login") ||
path.startsWith("/api/sys/auth/register") ||
path.startsWith("/api/sys/auth/refresh") ||
path.startsWith("/api/sys/auth/logout") ||
path.startsWith("/api/client/auth/login") ||
path.startsWith("/api/client/auth/register");
}
private String cleanRequestBody(String body, String contentType) {
if (body == null || body.isEmpty()) {
return body;
}
try {
if (contentType.contains(MediaType.APPLICATION_JSON_VALUE)) {
return cleanJsonBody(body);
} else if (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE)) {
return cleanFormDataBody(body);
} else {
return XssUtils.clean(body);
}
} catch (Exception e) {
logger.warn("Failed to clean request body, returning original: {}", e.getMessage());
return body;
}
}
@SuppressWarnings("unchecked")
private String cleanJsonBody(String json) {
try {
Map<String, Object> map = objectMapper.readValue(json, Map.class);
cleanMapValues(map);
return objectMapper.writeValueAsString(map);
} catch (Exception e) {
logger.warn("Failed to parse JSON, applying simple XSS cleaning: {}", e.getMessage());
return XssUtils.clean(json);
}
}
@SuppressWarnings("unchecked")
private void cleanMapValues(Map<String, Object> map) {
map.forEach((key, value) -> {
if (value instanceof String) {
map.put(key, XssUtils.clean((String) value));
} else if (value instanceof Map) {
cleanMapValues((Map<String, Object>) value);
}
});
}
private String cleanFormDataBody(String formData) {
String[] pairs = formData.split("&");
StringBuilder cleaned = new StringBuilder();
for (int i = 0; i < pairs.length; i++) {
String[] keyValue = pairs[i].split("=", 2);
if (keyValue.length == 2) {
String key = keyValue[0];
String value = java.net.URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8);
String cleanedValue = XssUtils.clean(value);
String encodedValue = java.net.URLEncoder.encode(cleanedValue, StandardCharsets.UTF_8);
if (i > 0) {
cleaned.append("&");
}
cleaned.append(key).append("=").append(encodedValue);
} else if (keyValue.length == 1) {
if (i > 0) {
cleaned.append("&");
}
cleaned.append(keyValue[0]);
}
}
return cleaned.toString();
}
}
@@ -0,0 +1,139 @@
package io.destiny.gateway.handler;
import io.destiny.common.response.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import static org.springframework.http.MediaType.APPLICATION_JSON;
@Component
@Tag(name = "客户端接口", description = "客户端用户相关操作接口")
public class ClientHandler {
private static final Logger logger = LoggerFactory.getLogger(ClientHandler.class);
@Operation(summary = "用户注册", description = "客户端用户注册接口,转发到客户端模块处理")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "注册成功", content = @Content(schema = @Schema(implementation = Result.class)))
})
public Mono<ServerResponse> register(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
logger.info("Gateway forwarding register request to client module");
return ServerResponse.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue(Result.success("Register request forwarded to client module", null));
}
@Operation(summary = "用户登录", description = "客户端用户登录接口,转发到客户端模块处理")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "登录成功", content = @Content(schema = @Schema(implementation = Result.class)))
})
public Mono<ServerResponse> login(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
logger.info("Gateway forwarding login request to client module");
return ServerResponse.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue(Result.success("Login request forwarded to client module", null));
}
@Operation(summary = "用户登出", description = "客户端用户登出接口,转发到客户端模块处理")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "登出成功", content = @Content(schema = @Schema(implementation = Result.class)))
})
public Mono<ServerResponse> logout(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
logger.info("Gateway forwarding logout request to client module");
return ServerResponse.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue(Result.success("Logout request forwarded to client module", null));
}
@Operation(summary = "检查登录状态", description = "检查用户登录状态接口,转发到客户端模块处理")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "检查成功", content = @Content(schema = @Schema(implementation = Result.class)))
})
public Mono<ServerResponse> checkLoginStatus(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
logger.info("Gateway forwarding check status request to client module");
return ServerResponse.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue(Result.success("Check status request forwarded to client module", null));
}
@Operation(summary = "客户端GET请求", description = "处理客户端模块的GET请求")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "请求成功", content = @Content(schema = @Schema(implementation = Result.class)))
})
public Mono<ServerResponse> handleGet(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String path = request.path();
logger.info("Client GET request: {}", path);
return ServerResponse.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue(Result.success("Client GET request received", path));
}
@Operation(summary = "客户端POST请求", description = "处理客户端模块的POST请求")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "请求成功", content = @Content(schema = @Schema(implementation = Result.class)))
})
public Mono<ServerResponse> handlePost(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String path = request.path();
logger.info("Client POST request: {}", path);
return request.bodyToMono(String.class)
.defaultIfEmpty("")
.flatMap(body -> {
logger.info("Client POST body: {}", body);
return ServerResponse.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue(Result.success("Client POST request received", path));
});
}
@Operation(summary = "客户端PUT请求", description = "处理客户端模块的PUT请求")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "请求成功", content = @Content(schema = @Schema(implementation = Result.class)))
})
public Mono<ServerResponse> handlePut(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String path = request.path();
logger.info("Client PUT request: {}", path);
return request.bodyToMono(String.class)
.defaultIfEmpty("")
.flatMap(body -> {
logger.info("Client PUT body: {}", body);
return ServerResponse.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue(Result.success("Client PUT request received", path));
});
}
@Operation(summary = "客户端DELETE请求", description = "处理客户端模块的DELETE请求")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "请求成功", content = @Content(schema = @Schema(implementation = Result.class)))
})
public Mono<ServerResponse> handleDelete(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String path = request.path();
logger.info("Client DELETE request: {}", path);
return ServerResponse.ok()
.contentType(APPLICATION_JSON)
.bodyValue(Result.success("Client DELETE request received", path));
}
}
@@ -0,0 +1,55 @@
package io.destiny.gateway.handler;
import io.destiny.common.response.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import static org.springframework.http.MediaType.APPLICATION_JSON;
@Component
@Tag(name = "健康检查", description = "系统健康状态检查接口")
public class HealthHandler {
private static final Logger logger = LoggerFactory.getLogger(HealthHandler.class);
@Operation(
summary = "健康检查",
description = "检查系统健康状态,返回服务状态、时间戳和服务名称"
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "健康检查成功",
content = @Content(schema = @Schema(implementation = Result.class))
)
})
public Mono<ServerResponse> handleHealth(
@Parameter(description = "服务器请求对象", hidden = true)
ServerRequest request) {
logger.info("Health check requested");
Map<String, Object> healthInfo = new HashMap<>();
healthInfo.put("status", "UP");
healthInfo.put("timestamp", LocalDateTime.now().toString());
healthInfo.put("service", "everything-is-suitable-api");
return ServerResponse.ok()
.contentType(APPLICATION_JSON)
.bodyValue(Result.success(healthInfo));
}
}
@@ -0,0 +1,26 @@
package io.destiny.gateway.handler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
@Component
public class SwaggerHandler {
private static final Logger logger = LoggerFactory.getLogger(SwaggerHandler.class);
public Mono<ServerResponse> handleSwaggerRedirect(ServerRequest request) {
logger.info("Redirecting to Swagger UI");
return ServerResponse.permanentRedirect(
java.net.URI.create("/swagger-ui.html")).build();
}
public Mono<ServerResponse> handleApiDocsRedirect(ServerRequest request) {
logger.info("Redirecting to API Docs");
return ServerResponse.permanentRedirect(
java.net.URI.create("/v3/api-docs")).build();
}
}
@@ -0,0 +1,50 @@
spring:
application:
name: gateway
cloud:
gateway:
routes:
- id: client-app-route
uri: http://localhost:8081
predicates:
- Path=/api/client/**
filters:
- StripPrefix=2
- id: admin-app-route
uri: http://localhost:8082
predicates:
- Path=/api/admin/**
filters:
- StripPrefix=2
- id: client-auth-route
uri: http://localhost:8081
predicates:
- Path=/api/auth/**
filters:
- StripPrefix=1
- id: client-fortune-route
uri: http://localhost:8081
predicates:
- Path=/api/fortune/**
filters:
- StripPrefix=1
gateway:
public-paths: ${GATEWAY_PUBLIC_PATHS:/api/client/auth/register,/api/client/auth/login,/api/sys/auth/register,/api/sys/auth/login,/api/sys/auth/refresh,/api/sys/auth/logout,/sys/auth/register,/sys/auth/login,/sys/auth/refresh,/sys/auth/logout,/api/health,/api/swagger-ui,/api/swagger-ui/**,/api/v3/api-docs,/api/v3/api-docs/**,/api/webjars/swagger-ui/**,/api/webjars/**,/api/actuator/**,/api/almanac,/api/almanac/**,/actuator/**,/swagger-ui,/swagger-ui/**,/v3/api-docs,/v3/api-docs/**,/webjars/swagger-ui/**,/webjars/**,/almanac,/almanac/**}
springdoc:
api-docs:
enabled: ${SWAGGER_ENABLED:true}
path: /v3/api-docs
swagger-ui:
enabled: ${SWAGGER_ENABLED:true}
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
show-actuator: false
server:
port: 8080
@@ -0,0 +1,102 @@
package io.destiny.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.BodyInserters;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class EndToEndApiIntegrationTest {
@Autowired
private WebTestClient webTestClient;
private String jwtToken;
@BeforeEach
void setUp() {
jwtToken = null;
}
@Test
void testCompleteUserFlow() {
String username = "testuser_" + System.currentTimeMillis();
String password = "Test@123456";
registerUser(username, password);
loginUser(username, password);
accessProtectedEndpoint();
}
private void registerUser(String username, String password) {
String requestBody = String.format(
"{\"username\":\"%s\",\"password\":\"%s\",\"email\":\"%s@example.com\"}",
username, password, username
);
webTestClient.post()
.uri("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(requestBody))
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.message").exists();
}
private void loginUser(String username, String password) {
String requestBody = String.format(
"{\"username\":\"%s\",\"password\":\"%s\"}",
username, password
);
String response = webTestClient.post()
.uri("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(requestBody))
.exchange()
.expectStatus().isOk()
.expectBody(String.class)
.returnResult()
.getResponseBody();
if (response != null && response.contains("token")) {
jwtToken = extractTokenFromResponse(response);
}
}
private void accessProtectedEndpoint() {
if (jwtToken != null) {
webTestClient.get()
.uri("/api/fortune/daily")
.header("Authorization", "Bearer " + jwtToken)
.exchange()
.expectStatus().isOk();
}
}
private String extractTokenFromResponse(String response) {
return response;
}
@Test
void testUnauthorizedAccessWithoutToken() {
webTestClient.get()
.uri("/api/fortune/daily")
.exchange()
.expectStatus().isUnauthorized();
}
@Test
void testInvalidTokenReturnsUnauthorized() {
webTestClient.get()
.uri("/api/fortune/daily")
.header("Authorization", "Bearer invalid-token")
.exchange()
.expectStatus().isUnauthorized();
}
}
@@ -0,0 +1,50 @@
package io.destiny.gateway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class GatewayRoutingIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Test
void contextLoads() {
}
@Test
void healthCheck() {
webTestClient.get()
.uri("/actuator/health")
.exchange()
.expectStatus().isOk();
}
@Test
void gatewayRoutesToClientApp() {
webTestClient.get()
.uri("/api/auth/login")
.exchange()
.expectStatus().isOk();
}
@Test
void gatewayRoutesToAdminApp() {
webTestClient.get()
.uri("/api/admin/users")
.exchange()
.expectStatus().isOk();
}
@Test
void gatewayRoutesToFortuneEndpoint() {
webTestClient.get()
.uri("/api/fortune/daily")
.exchange()
.expectStatus().isOk();
}
}
@@ -0,0 +1,49 @@
package io.destiny.gateway.config;
import io.destiny.common.exception.ReactiveGlobalExceptionHandler;
import io.destiny.gateway.handler.ClientHandler;
import io.destiny.gateway.handler.HealthHandler;
import org.junit.jupiter.api.DisplayName;
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.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("网关配置测试")
class GatewayConfigTest {
@Mock
private ClientHandler clientHandler;
@Mock
private HealthHandler healthHandler;
@Test
@DisplayName("应该成功创建全局异常处理器")
void testReactiveGlobalExceptionHandlerCreation() {
GatewayConfig config = new GatewayConfig();
ReactiveGlobalExceptionHandler handler = config.reactiveGlobalExceptionHandler();
assertNotNull(handler, "全局异常处理器不应为null");
}
@Test
@DisplayName("应该成功创建客户端路由")
void testClientRouterCreation() {
GatewayConfig config = new GatewayConfig();
RouterFunction<ServerResponse> router = config.clientRouter(clientHandler);
assertNotNull(router, "客户端路由不应为null");
}
@Test
@DisplayName("应该成功创建健康检查路由")
void testHealthRouterCreation() {
GatewayConfig config = new GatewayConfig();
RouterFunction<ServerResponse> router = config.healthRouter(healthHandler);
assertNotNull(router, "健康检查路由不应为null");
}
}
@@ -0,0 +1,31 @@
package io.destiny.gateway.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("网关属性配置测试")
class GatewayPropertiesTest {
@Test
@DisplayName("应该成功创建属性配置")
void testGatewayPropertiesCreation() {
GatewayProperties properties = new GatewayProperties();
assertNotNull(properties, "属性配置不应为null");
}
@Test
@DisplayName("应该正确设置和获取公开路径")
void testPublicPathsGetterSetter() {
GatewayProperties properties = new GatewayProperties();
List<String> paths = List.of("/api/auth/login", "/api/health");
properties.setPublicPaths(paths);
assertNotNull(properties.getPublicPaths(), "公开路径不应为null");
assertEquals(2, properties.getPublicPaths().size(), "公开路径数量应该为2");
}
}
@@ -0,0 +1,17 @@
package io.destiny.gateway.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Swagger配置测试")
class SwaggerConfigTest {
@Test
@DisplayName("应该成功创建配置")
void testSwaggerConfigCreation() {
SwaggerConfig config = new SwaggerConfig();
assertNotNull(config, "配置不应为null");
}
}
@@ -0,0 +1,17 @@
package io.destiny.gateway.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Swagger安全配置测试")
class SwaggerSecurityConfigTest {
@Test
@DisplayName("应该成功创建配置")
void testSwaggerSecurityConfigCreation() {
SwaggerSecurityConfig config = new SwaggerSecurityConfig();
assertNotNull(config, "配置不应为null");
}
}
@@ -0,0 +1,242 @@
package io.destiny.gateway.filter;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
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 org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@DisplayName("JWT认证过滤器安全测试")
class JwtAuthenticationFilterSecurityTest {
private static final String SECRET = "mySecretKeyForJWTTokenGenerationShouldBeLongEnoughForSecurity256Bits";
private static final String DIFFERENT_SECRET = "differentSecretKeyForJWTTokenGenerationShouldBeLongEnoughForSecurity256Bits";
private static final SecretKey SIGNING_KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
@Test
@DisplayName("应该拒绝没有Authorization头的请求")
void testShouldRejectRequestWithoutAuthHeader() {
JwtAuthenticationFilter filter = createFilter(SECRET);
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/sys/user/list").build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
assertEquals(HttpStatus.UNAUTHORIZED, exchange.getResponse().getStatusCode());
verify(chain, never()).filter(any());
}
@Test
@DisplayName("应该拒绝格式错误的Authorization头")
void testShouldRejectMalformedAuthHeader() {
JwtAuthenticationFilter filter = createFilter(SECRET);
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/sys/user/list")
.header(HttpHeaders.AUTHORIZATION, "InvalidFormat token")
.build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
assertEquals(HttpStatus.UNAUTHORIZED, exchange.getResponse().getStatusCode());
verify(chain, never()).filter(any());
}
@Test
@DisplayName("应该拒绝无效的JWT令牌")
void testShouldRejectInvalidToken() {
JwtAuthenticationFilter filter = createFilter(SECRET);
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/sys/user/list")
.header(HttpHeaders.AUTHORIZATION, "Bearer invalid_token")
.build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
assertEquals(HttpStatus.UNAUTHORIZED, exchange.getResponse().getStatusCode());
verify(chain, never()).filter(any());
}
@Test
@DisplayName("应该拒绝过期的JWT令牌")
void testShouldRejectExpiredToken() {
String expiredToken = generateExpiredToken();
JwtAuthenticationFilter filter = createFilter(SECRET);
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/sys/user/list")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + expiredToken)
.build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
assertEquals(HttpStatus.UNAUTHORIZED, exchange.getResponse().getStatusCode());
verify(chain, never()).filter(any());
}
@Test
@DisplayName("应该拒绝缺少用户ID的令牌")
void testShouldRejectTokenWithoutUserId() {
String token = generateTokenWithoutUserId();
JwtAuthenticationFilter filter = createFilter(SECRET);
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/sys/user/list")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
assertEquals(HttpStatus.UNAUTHORIZED, exchange.getResponse().getStatusCode());
verify(chain, never()).filter(any());
}
@Test
@DisplayName("应该拒绝缺少用户名的令牌")
void testShouldRejectTokenWithoutUsername() {
String token = generateTokenWithoutUsername();
JwtAuthenticationFilter filter = createFilter(SECRET);
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/sys/user/list")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
assertEquals(HttpStatus.UNAUTHORIZED, exchange.getResponse().getStatusCode());
verify(chain, never()).filter(any());
}
@Test
@DisplayName("应该接受有效的JWT令牌")
void testShouldAcceptValidToken() {
String validToken = generateValidToken();
JwtAuthenticationFilter filter = createFilter(SECRET);
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/sys/user/list")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
.build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
verify(chain, times(1)).filter(any());
}
@Test
@DisplayName("应该允许公开路径访问")
void testShouldAllowPublicPath() {
JwtAuthenticationFilter filter = createFilter(SECRET);
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/sys/auth/login").build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
verify(chain, times(1)).filter(any());
}
@Test
@DisplayName("应该拒绝使用错误密钥签名的令牌")
void testShouldRejectTokenWithWrongKey() {
String token = generateValidToken();
JwtAuthenticationFilter filter = createFilter(DIFFERENT_SECRET);
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/sys/user/list")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
verify(chain, never()).filter(any());
}
private String generateValidToken() {
return Jwts.builder()
.setSubject("admin")
.claim("userId", 1)
.signWith(SIGNING_KEY)
.compact();
}
private String generateExpiredToken() {
return Jwts.builder()
.setSubject("admin")
.claim("userId", 1)
.setExpiration(new java.util.Date(System.currentTimeMillis() - 1000))
.signWith(SIGNING_KEY)
.compact();
}
private String generateTokenWithoutUserId() {
return Jwts.builder()
.setSubject("admin")
.signWith(SIGNING_KEY)
.compact();
}
private String generateTokenWithoutUsername() {
return Jwts.builder()
.claim("userId", 1)
.signWith(SIGNING_KEY)
.compact();
}
private JwtAuthenticationFilter createFilter(String secret) {
return new JwtAuthenticationFilter(
secret,
"/api/client/auth/register,/api/client/auth/login,/api/sys/auth/register,/api/sys/auth/login,/api/sys/auth/refresh,/api/sys/auth/logout,/api/swagger-ui,/api/swagger-ui/**,/api/v3/api-docs,/api/v3/api-docs/**,/api/webjars/swagger-ui/**,/api/webjars/**,/api/actuator/health,/api/actuator/info"
);
}
}
@@ -0,0 +1,49 @@
package io.destiny.gateway.filter;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import static org.mockito.Mockito.*;
@DisplayName("日志过滤器测试")
class LoggingFilterTest {
@Test
@DisplayName("应该记录请求日志")
void testShouldLogRequest() {
LoggingFilter filter = new LoggingFilter();
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/test").build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
verify(chain, times(1)).filter(any());
}
@Test
@DisplayName("应该正确传递请求")
void testShouldPassRequest() {
LoggingFilter filter = new LoggingFilter();
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/test").build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
verify(chain, times(1)).filter(any());
}
}
@@ -0,0 +1,66 @@
package io.destiny.gateway.filter;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import static org.mockito.Mockito.*;
@DisplayName("限流过滤器测试")
class RateLimitFilterTest {
@Test
@DisplayName("应该允许正常请求")
void testShouldAllowNormalRequest() {
RateLimitFilter filter = new RateLimitFilter(100, 60, true, "");
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/test").build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
verify(chain, times(1)).filter(any());
}
@Test
@DisplayName("应该正确传递请求")
void testShouldPassRequest() {
RateLimitFilter filter = new RateLimitFilter(100, 60, true, "");
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/test").build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
verify(chain, times(1)).filter(any());
}
@Test
@DisplayName("应该跳过白名单IP")
void testShouldSkipWhitelistedIp() {
RateLimitFilter filter = new RateLimitFilter(100, 60, true, "127.0.0.1");
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/test").build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
verify(chain, times(1)).filter(any());
}
}
@@ -0,0 +1,54 @@
package io.destiny.gateway.filter;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@DisplayName("安全头过滤器测试")
class SecurityHeadersFilterTest {
@Test
@DisplayName("应该添加安全头")
void testShouldAddSecurityHeaders() {
SecurityHeadersFilter filter = new SecurityHeadersFilter();
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/test").build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
HttpHeaders headers = exchange.getResponse().getHeaders();
assertNotNull(headers.get("X-Content-Type-Options"), "应该包含X-Content-Type-Options头");
assertNotNull(headers.get("X-Frame-Options"), "应该包含X-Frame-Options头");
verify(chain, times(1)).filter(any());
}
@Test
@DisplayName("应该正确传递请求")
void testShouldPassRequest() {
SecurityHeadersFilter filter = new SecurityHeadersFilter();
ServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/test").build()
);
WebFilterChain chain = mock(WebFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
Mono<Void> result = filter.filter(exchange, chain);
result.block();
verify(chain, times(1)).filter(any());
}
}
@@ -0,0 +1,131 @@
package io.destiny.gateway.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.nio.charset.StandardCharsets;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("XSS过滤器测试")
class XssFilterTest {
private XssFilter xssFilter;
@Mock
private WebFilterChain chain;
@BeforeEach
void setUp() {
xssFilter = new XssFilter(new ObjectMapper());
}
@Test
@DisplayName("应该跳过非POST/PUT/PATCH请求")
void shouldSkipNonMutatingMethods() {
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
ServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
StepVerifier.create(xssFilter.filter(exchange, chain))
.verifyComplete();
}
@Test
@DisplayName("应该跳过非JSON请求")
void shouldSkipNonJsonRequests() {
MockServerHttpRequest request = MockServerHttpRequest.post("/test")
.header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_PNG_VALUE)
.build();
ServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
StepVerifier.create(xssFilter.filter(exchange, chain))
.verifyComplete();
}
@Test
@DisplayName("应该清理JSON请求中的XSS攻击")
void shouldCleanXssInJsonRequests() {
String maliciousJson = "{\"username\":\"<script>alert('xss')</script>\",\"email\":\"test@example.com\"}";
DataBuffer buffer = DefaultDataBufferFactory.sharedInstance.wrap(maliciousJson.getBytes(StandardCharsets.UTF_8));
MockServerHttpRequest request = MockServerHttpRequest.post("/test")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(Flux.just(buffer));
ServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
StepVerifier.create(xssFilter.filter(exchange, chain))
.verifyComplete();
}
@Test
@DisplayName("应该清理表单数据中的XSS攻击")
void shouldCleanXssInFormDataRequests() {
String maliciousData = "username=<script>alert('xss')</script>&email=test@example.com";
DataBuffer buffer = DefaultDataBufferFactory.sharedInstance.wrap(maliciousData.getBytes(StandardCharsets.UTF_8));
MockServerHttpRequest request = MockServerHttpRequest.post("/test")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.body(Flux.just(buffer));
ServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
StepVerifier.create(xssFilter.filter(exchange, chain))
.verifyComplete();
}
@Test
@DisplayName("应该处理空请求体")
void shouldHandleEmptyRequestBody() {
MockServerHttpRequest request = MockServerHttpRequest.post("/test")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(Flux.empty());
ServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
StepVerifier.create(xssFilter.filter(exchange, chain))
.verifyComplete();
}
@Test
@DisplayName("应该处理无效JSON")
void shouldHandleInvalidJson() {
String invalidJson = "{invalid json}";
DataBuffer buffer = DefaultDataBufferFactory.sharedInstance.wrap(invalidJson.getBytes(StandardCharsets.UTF_8));
MockServerHttpRequest request = MockServerHttpRequest.post("/test")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(Flux.just(buffer));
ServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
StepVerifier.create(xssFilter.filter(exchange, chain))
.verifyComplete();
}
}
@@ -0,0 +1,20 @@
package io.destiny.gateway.handler;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("客户端处理器测试")
class ClientHandlerTest {
@Test
@DisplayName("应该成功创建处理器")
void testClientHandlerCreation() {
ClientHandler handler = new ClientHandler();
assertNotNull(handler, "处理器不应为null");
}
}
@@ -0,0 +1,17 @@
package io.destiny.gateway.handler;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("健康检查处理器测试")
class HealthHandlerTest {
@Test
@DisplayName("应该成功创建处理器")
void testHealthHandlerCreation() {
HealthHandler handler = new HealthHandler();
assertNotNull(handler, "处理器不应为null");
}
}
@@ -0,0 +1,17 @@
package io.destiny.gateway.handler;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Swagger处理器测试")
class SwaggerHandlerTest {
@Test
@DisplayName("应该成功创建处理器")
void testSwaggerHandlerCreation() {
SwaggerHandler handler = new SwaggerHandler();
assertNotNull(handler, "处理器不应为null");
}
}