From 345019b4f9213769e3c2d3af728671eeb9abf153 Mon Sep 17 00:00:00 2001 From: Github_lizhw <171332584@qq.com> Date: Wed, 1 Jan 2025 20:21:41 +0800 Subject: [PATCH] saas gateway --- ruoyi-common/pom.xml | 1 + ruoyi-common/ruoyi-common-bom/pom.xml | 7 + ruoyi-common/ruoyi-common-nacos/pom.xml | 43 +++++ ruoyi-common/ruoyi-common-satoken/pom.xml | 10 +- ruoyi-site/pom.xml | 1 + ruoyi-site/ruoyi-gateway/Dockerfile | 24 +++ ruoyi-site/ruoyi-gateway/pom.xml | 106 +++++++++++++ .../gateway/RuoYiGatewayApplication.java | 22 +++ .../dromara/gateway/config/GatewayConfig.java | 13 ++ .../properties/ApiDecryptProperties.java | 28 ++++ .../properties/CustomGatewayProperties.java | 24 +++ .../properties/IgnoreWhiteProperties.java | 29 ++++ .../dromara/gateway/filter/AuthFilter.java | 94 +++++++++++ .../gateway/filter/BlackListUrlFilter.java | 58 +++++++ .../gateway/filter/ForwardAuthFilter.java | 41 +++++ .../filter/GlobalCacheRequestFilter.java | 38 +++++ .../gateway/filter/GlobalCorsFilter.java | 86 ++++++++++ .../gateway/filter/GlobalI18nFilter.java | 41 +++++ .../gateway/filter/GlobalLogFilter.java | 84 ++++++++++ .../dromara/gateway/filter/SseException.java | 62 ++++++++ .../handler/GatewayExceptionHandler.java | 46 ++++++ .../dromara/gateway/utils/WebFluxUtils.java | 149 ++++++++++++++++++ .../src/main/resources/application.yml | 33 ++++ .../src/main/resources/banner.txt | 10 ++ .../src/main/resources/logback-plus.xml | 114 ++++++++++++++ 25 files changed, 1159 insertions(+), 5 deletions(-) create mode 100644 ruoyi-common/ruoyi-common-nacos/pom.xml create mode 100644 ruoyi-site/ruoyi-gateway/Dockerfile create mode 100644 ruoyi-site/ruoyi-gateway/pom.xml create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/RuoYiGatewayApplication.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/GatewayConfig.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/ApiDecryptProperties.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/CustomGatewayProperties.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/IgnoreWhiteProperties.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/AuthFilter.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/BlackListUrlFilter.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/ForwardAuthFilter.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalCacheRequestFilter.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalCorsFilter.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalI18nFilter.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalLogFilter.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/SseException.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/handler/GatewayExceptionHandler.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/utils/WebFluxUtils.java create mode 100644 ruoyi-site/ruoyi-gateway/src/main/resources/application.yml create mode 100644 ruoyi-site/ruoyi-gateway/src/main/resources/banner.txt create mode 100644 ruoyi-site/ruoyi-gateway/src/main/resources/logback-plus.xml diff --git a/ruoyi-common/pom.xml b/ruoyi-common/pom.xml index 2930fd0b0..221749949 100644 --- a/ruoyi-common/pom.xml +++ b/ruoyi-common/pom.xml @@ -34,6 +34,7 @@ ruoyi-common-tenant ruoyi-common-websocket ruoyi-common-sse + ruoyi-common-nacos ruoyi-common diff --git a/ruoyi-common/ruoyi-common-bom/pom.xml b/ruoyi-common/ruoyi-common-bom/pom.xml index d74768fdb..ac9a44baa 100644 --- a/ruoyi-common/ruoyi-common-bom/pom.xml +++ b/ruoyi-common/ruoyi-common-bom/pom.xml @@ -179,6 +179,13 @@ ${revision} + + + org.dromara + ruoyi-common-nacos + ${revision} + + diff --git a/ruoyi-common/ruoyi-common-nacos/pom.xml b/ruoyi-common/ruoyi-common-nacos/pom.xml new file mode 100644 index 000000000..9a87e45b2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-nacos/pom.xml @@ -0,0 +1,43 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-nacos + + + ruoyi-common-nacos 配置中心 + + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + com.alibaba.nacos + logback-adapter + 1.0.0 + + + com.alibaba.nacos + nacos-common + + + + + + diff --git a/ruoyi-common/ruoyi-common-satoken/pom.xml b/ruoyi-common/ruoyi-common-satoken/pom.xml index ed16fe796..c4754349b 100644 --- a/ruoyi-common/ruoyi-common-satoken/pom.xml +++ b/ruoyi-common/ruoyi-common-satoken/pom.xml @@ -24,11 +24,11 @@ ruoyi-common-redis - - - cn.dev33 - sa-token-spring-boot3-starter - + + + + + diff --git a/ruoyi-site/pom.xml b/ruoyi-site/pom.xml index e84b50e52..ce4d87f4d 100644 --- a/ruoyi-site/pom.xml +++ b/ruoyi-site/pom.xml @@ -12,6 +12,7 @@ ruoyi-sso-server ruoyi-h5 + ruoyi-gateway ruoyi-site diff --git a/ruoyi-site/ruoyi-gateway/Dockerfile b/ruoyi-site/ruoyi-gateway/Dockerfile new file mode 100644 index 000000000..728985672 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/Dockerfile @@ -0,0 +1,24 @@ +# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/ +FROM bellsoft/liberica-openjdk-debian:17.0.11-cds +#FROM bellsoft/liberica-openjdk-debian:21.0.3-cds +#FROM findepi/graalvm:java17-native + +LABEL maintainer="Lion Li" + +RUN mkdir -p /ruoyi/gateway/logs \ + /ruoyi/gateway/temp \ + /ruoyi/skywalking/agent + +WORKDIR /ruoyi/gateway + +ENV SERVER_PORT=8080 LANG=C.UTF-8 LC_ALL=C.UTF-8 JAVA_OPTS="" + +EXPOSE ${SERVER_PORT} + +ADD ./target/ruoyi-gateway.jar ./app.jar + +ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -Dserver.port=${SERVER_PORT} \ + #-Dskywalking.agent.service_name=ruoyi-gateway \ + #-javaagent:/ruoyi/skywalking/agent/skywalking-agent.jar \ + -XX:+HeapDumpOnOutOfMemoryError -XX:+UseZGC ${JAVA_OPTS} \ + -jar app.jar diff --git a/ruoyi-site/ruoyi-gateway/pom.xml b/ruoyi-site/ruoyi-gateway/pom.xml new file mode 100644 index 000000000..7b5bc93ed --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/pom.xml @@ -0,0 +1,106 @@ + + + org.dromara + ruoyi-site + ${revision} + + 4.0.0 + + ruoyi-gateway + + + ruoyi-gateway网关模块 + + + + + + + org.springframework.cloud + spring-cloud-starter-gateway + + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + + com.github.ben-manes.caffeine + caffeine + + + + org.dromara + ruoyi-common-nacos + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.cloud + spring-cloud-loadbalancer + + + + cn.hutool + hutool-http + + + + + cn.dev33 + sa-token-reactor-spring-boot3-starter + ${satoken.version} + + + + org.dromara + ruoyi-common-satoken + + + + + org.dromara + ruoyi-common-redis + + + + org.dromara + ruoyi-common-tenant + + + org.dromara + ruoyi-common-mybatis + + + + + + + + ${project.artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + + + diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/RuoYiGatewayApplication.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/RuoYiGatewayApplication.java new file mode 100644 index 000000000..e1f4b9680 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/RuoYiGatewayApplication.java @@ -0,0 +1,22 @@ +package org.dromara.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup; + +/** + * 网关启动程序 + * + * @author ruoyi + */ +@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) +public class RuoYiGatewayApplication { + public static void main(String[] args) { + // 标记 sentinel 类型为 网关 + SpringApplication application = new SpringApplication(RuoYiGatewayApplication.class); + application.setApplicationStartup(new BufferingApplicationStartup(2048)); + application.run(args); + System.out.println("(♥◠‿◠)ノ゙ 网关启动成功 ლ(´ڡ`ლ)゙ "); + } +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/GatewayConfig.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/GatewayConfig.java new file mode 100644 index 000000000..99211d9a2 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/GatewayConfig.java @@ -0,0 +1,13 @@ +package org.dromara.gateway.config; + +import org.springframework.context.annotation.Configuration; + +/** + * 网关限流配置 + * + * @author ruoyi + */ +@Configuration +public class GatewayConfig { + +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/ApiDecryptProperties.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/ApiDecryptProperties.java new file mode 100644 index 000000000..c038bd4f6 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/ApiDecryptProperties.java @@ -0,0 +1,28 @@ +package org.dromara.gateway.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Component; + +/** + * api解密属性配置类 + * @author wdhcr + */ +@Data +@Component +@RefreshScope +@ConfigurationProperties(prefix = "api-decrypt") +public class ApiDecryptProperties { + + /** + * 加密开关 + */ + private Boolean enabled; + + /** + * 头部标识 + */ + private String headerFlag; + +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/CustomGatewayProperties.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/CustomGatewayProperties.java new file mode 100644 index 000000000..599603025 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/CustomGatewayProperties.java @@ -0,0 +1,24 @@ +package org.dromara.gateway.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; + +/** + * 自定义gateway参数配置 + * + * @author Lion Li + */ +@Data +@Configuration +@RefreshScope +@ConfigurationProperties(prefix = "spring.cloud.gateway") +public class CustomGatewayProperties { + + /** + * 请求日志 + */ + private Boolean requestLog; + +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/IgnoreWhiteProperties.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/IgnoreWhiteProperties.java new file mode 100644 index 000000000..43b207b51 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/config/properties/IgnoreWhiteProperties.java @@ -0,0 +1,29 @@ +package org.dromara.gateway.config.properties; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.List; + +/** + * 放行白名单配置 + * + * @author ruoyi + */ +@Data +@NoArgsConstructor +@Configuration +@RefreshScope +@ConfigurationProperties(prefix = "security.ignore") +public class IgnoreWhiteProperties { + + /** + * 放行白名单配置,网关不校验此处的白名单 + */ + private List whites = new ArrayList<>(); + +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/AuthFilter.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/AuthFilter.java new file mode 100644 index 000000000..b84d8117d --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/AuthFilter.java @@ -0,0 +1,94 @@ +package org.dromara.gateway.filter; + +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.httpauth.basic.SaHttpBasicUtil; +import cn.dev33.satoken.reactor.context.SaReactorSyncHolder; +import cn.dev33.satoken.reactor.filter.SaReactorFilter; +import cn.dev33.satoken.router.SaRouter; +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.util.SaResult; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.gateway.config.properties.IgnoreWhiteProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.reactive.ServerHttpRequest; + +/** + * [Sa-Token 权限认证] 拦截器 + * + * @author Lion Li + */ +@Configuration +public class AuthFilter { + + /** + * 注册 Sa-Token 全局过滤器 + */ + @Bean + public SaReactorFilter getSaReactorFilter(IgnoreWhiteProperties ignoreWhite) { + return new SaReactorFilter() + // 拦截地址 + .addInclude("/**") + .addExclude("/favicon.ico", "/actuator", "/actuator/**") + // 鉴权方法:每次访问进入 + .setAuth(obj -> { + // 登录校验 -- 拦截所有路由 + SaRouter.match("/**") + .notMatch(ignoreWhite.getWhites()) + .check(r -> { + ServerHttpRequest request = SaReactorSyncHolder.getContext().getRequest(); + // 检查是否登录 是否有token + try { + StpUtil.checkLogin(); + } catch (NotLoginException e) { + if (request.getURI().getPath().contains("sse")) { + throw new SseException(e.getMessage(), e.getCode()); + } else { + throw e; + } + } + + // 检查 header 与 param 里的 clientid 与 token 里的是否一致 + String headerCid = request.getHeaders().getFirst(LoginHelper.CLIENT_KEY); + String paramCid = request.getQueryParams().getFirst(LoginHelper.CLIENT_KEY); + String clientId = StpUtil.getExtra(LoginHelper.CLIENT_KEY).toString(); + if (!StringUtils.equalsAny(clientId, headerCid, paramCid)) { + // token 无效 + throw NotLoginException.newInstance(StpUtil.getLoginType(), + "-100", "客户端ID与Token不匹配", + StpUtil.getTokenValue()); + } + + // 有效率影响 用于临时测试 + // if (log.isDebugEnabled()) { + // log.debug("剩余有效时间: {}", StpUtil.getTokenTimeout()); + // log.debug("临时有效时间: {}", StpUtil.getTokenActivityTimeout()); + // } + }); + }).setError(e -> { + if (e instanceof NotLoginException) { + return SaResult.error(e.getMessage()).setCode(HttpStatus.UNAUTHORIZED); + } + return SaResult.error("认证失败,无法访问系统资源").setCode(HttpStatus.UNAUTHORIZED); + }); + } + + /** + * 对 actuator 健康检查接口 做账号密码鉴权 + */ + @Bean + public SaReactorFilter actuatorFilter() { + String username = SpringUtils.getProperty("spring.cloud.nacos.discovery.metadata.username"); + String password = SpringUtils.getProperty("spring.cloud.nacos.discovery.metadata.userpassword"); + return new SaReactorFilter() + .addInclude("/actuator", "/actuator/**") + .setAuth(obj -> { + SaHttpBasicUtil.check(username + ":" + password); + }) + .setError(e -> SaResult.error(e.getMessage()).setCode(HttpStatus.UNAUTHORIZED)); + } + +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/BlackListUrlFilter.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/BlackListUrlFilter.java new file mode 100644 index 000000000..177e93cce --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/BlackListUrlFilter.java @@ -0,0 +1,58 @@ +package org.dromara.gateway.filter; + +import org.dromara.gateway.utils.WebFluxUtils; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 黑名单过滤器 + * + * @author ruoyi + */ +@Component +public class BlackListUrlFilter extends AbstractGatewayFilterFactory { + @Override + public GatewayFilter apply(Config config) { + return (exchange, chain) -> { + + String url = exchange.getRequest().getURI().getPath(); + if (config.matchBlacklist(url)) { + return WebFluxUtils.webFluxResponseWriter(exchange.getResponse(), "请求地址不允许访问"); + } + + return chain.filter(exchange); + }; + } + + public BlackListUrlFilter() { + super(Config.class); + } + + public static class Config { + private List blacklistUrl; + + private List blacklistUrlPattern = new ArrayList<>(); + + public boolean matchBlacklist(String url) { + return !blacklistUrlPattern.isEmpty() && blacklistUrlPattern.stream().anyMatch(p -> p.matcher(url).find()); + } + + public List getBlacklistUrl() { + return blacklistUrl; + } + + public void setBlacklistUrl(List blacklistUrl) { + this.blacklistUrl = blacklistUrl; + this.blacklistUrlPattern.clear(); + this.blacklistUrl.forEach(url -> { + this.blacklistUrlPattern.add(Pattern.compile(url.replaceAll("\\*\\*", "(.*?)"), Pattern.CASE_INSENSITIVE)); + }); + } + } + +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/ForwardAuthFilter.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/ForwardAuthFilter.java new file mode 100644 index 000000000..fe0348b82 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/ForwardAuthFilter.java @@ -0,0 +1,41 @@ +package org.dromara.gateway.filter; + +import cn.dev33.satoken.SaManager; +import cn.dev33.satoken.same.SaSameUtil; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * 转发认证过滤器(内部服务外网隔离) + * + * @author Lion Li + */ +@Component +public class ForwardAuthFilter implements GlobalFilter, Ordered { + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + // 未开启配置则直接跳过 + if (!SaManager.getConfig().getCheckSameToken()) { + return chain.filter(exchange); + } + ServerHttpRequest newRequest = exchange + .getRequest() + .mutate() + // 为请求追加 Same-Token 参数 + .header(SaSameUtil.SAME_TOKEN, SaSameUtil.getToken()) + .build(); + ServerWebExchange newExchange = exchange.mutate().request(newRequest).build(); + return chain.filter(newExchange); + } + + @Override + public int getOrder() { + return -100; + } +} + diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalCacheRequestFilter.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalCacheRequestFilter.java new file mode 100644 index 000000000..09e50ff2c --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalCacheRequestFilter.java @@ -0,0 +1,38 @@ +package org.dromara.gateway.filter; + +import org.dromara.gateway.utils.WebFluxUtils; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; +import org.springframework.core.Ordered; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * 全局缓存获取body请求数据(解决流不能重复读取问题) + * + * @author Lion Li + */ +@Component +public class GlobalCacheRequestFilter implements GlobalFilter, Ordered { + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + // 只缓存json类型请求 + if (!WebFluxUtils.isJsonRequest(exchange)) { + return chain.filter(exchange); + } + return ServerWebExchangeUtils.cacheRequestBody(exchange, (serverHttpRequest) -> { + if (serverHttpRequest == exchange.getRequest()) { + return chain.filter(exchange); + } + return chain.filter(exchange.mutate().request(serverHttpRequest).build()); + }); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE + 1; + } +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalCorsFilter.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalCorsFilter.java new file mode 100644 index 000000000..a2482e797 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalCorsFilter.java @@ -0,0 +1,86 @@ +package org.dromara.gateway.filter; + +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.cors.reactive.CorsUtils; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + + +/** + * 跨域配置 + * + * @author Lion Li + */ +@Component +public class GlobalCorsFilter implements WebFilter, Ordered { + + /** + * 这里为支持的请求头,如果有自定义的header字段请自己添加 + */ + private static final String ALLOWED_HEADERS = + "X-Requested-With, Content-Language, Content-Type, " + + "Authorization, clientid, credential, X-XSRF-TOKEN, " + + "isToken, token, Admin-Token, App-Token, Encrypt-Key, isEncrypt"; + + /** + * 允许的请求方法 + */ + private static final String ALLOWED_METHODS = "GET,POST,PUT,DELETE,OPTIONS,HEAD"; + + /** + * 允许的请求来源,使用 * 表示允许任何来源 + */ + private static final String ALLOWED_ORIGIN = "*"; + + /** + * 允许前端访问的响应头,使用 * 表示允许任何响应头 + */ + private static final String ALLOWED_EXPOSE = "*"; + + /** + * 预检请求的缓存时间,单位为秒(此处设置为 5 小时) + */ + private static final String MAX_AGE = "18000L"; + + /** + * 实现跨域配置的 Web 过滤器 + * + * @param exchange ServerWebExchange 对象,表示一次 Web 交换 + * @param chain WebFilterChain 对象,表示一组 Web 过滤器链 + * @return Mono 表示异步的过滤器链处理结果 + */ + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + // 判断请求是否为跨域请求 + if (CorsUtils.isCorsRequest(request)) { + ServerHttpResponse response = exchange.getResponse(); + HttpHeaders headers = response.getHeaders(); + headers.add("Access-Control-Allow-Headers", ALLOWED_HEADERS); + headers.add("Access-Control-Allow-Methods", ALLOWED_METHODS); + headers.add("Access-Control-Allow-Origin", ALLOWED_ORIGIN); + headers.add("Access-Control-Expose-Headers", ALLOWED_EXPOSE); + headers.add("Access-Control-Max-Age", MAX_AGE); + headers.add("Access-Control-Allow-Credentials", "true"); + // 处理预检请求的 OPTIONS 方法,直接返回成功状态码 + if (request.getMethod() == HttpMethod.OPTIONS) { + response.setStatusCode(HttpStatus.OK); + return Mono.empty(); + } + } + return chain.filter(exchange); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalI18nFilter.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalI18nFilter.java new file mode 100644 index 000000000..bad65bf4d --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalI18nFilter.java @@ -0,0 +1,41 @@ +package org.dromara.gateway.filter; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.i18n.SimpleLocaleContext; +import org.springframework.core.Ordered; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.Locale; + +/** + * 全局国际化处理 + * + * @author Lion Li + */ +@Slf4j +@Component +public class GlobalI18nFilter implements GlobalFilter, Ordered { + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + String language = exchange.getRequest().getHeaders().getFirst("content-language"); + Locale locale = Locale.getDefault(); + if (language != null && language.length() > 0) { + String[] split = language.split("_"); + locale = new Locale(split[0], split[1]); + } + LocaleContextHolder.setLocaleContext(new SimpleLocaleContext(locale), true); + return chain.filter(exchange); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalLogFilter.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalLogFilter.java new file mode 100644 index 000000000..49646793e --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/GlobalLogFilter.java @@ -0,0 +1,84 @@ +package org.dromara.gateway.filter; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.json.JSONUtil; +import lombok.extern.slf4j.Slf4j; +import org.dromara.gateway.config.properties.ApiDecryptProperties; +import org.dromara.gateway.config.properties.CustomGatewayProperties; +import org.dromara.gateway.utils.WebFluxUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * 全局日志过滤器 + *

+ * 用于打印请求执行参数与响应时间等等 + * + * @author Lion Li + */ +@Slf4j +@Component +public class GlobalLogFilter implements GlobalFilter, Ordered { + + @Autowired + private CustomGatewayProperties customGatewayProperties; + @Autowired + private ApiDecryptProperties apiDecryptProperties; + + private static final String START_TIME = "startTime"; + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (!customGatewayProperties.getRequestLog()) { + return chain.filter(exchange); + } + ServerHttpRequest request = exchange.getRequest(); + String path = WebFluxUtils.getOriginalRequestUrl(exchange); + String url = request.getMethod().name() + " " + path; + + // 打印请求参数 + if (WebFluxUtils.isJsonRequest(exchange)) { + if (apiDecryptProperties.getEnabled() + && ObjectUtil.isNotNull(request.getHeaders().getFirst(apiDecryptProperties.getHeaderFlag()))) { + log.info("[PLUS]开始请求 => URL[{}],参数类型[encrypt]", url); + } else { + String jsonParam = WebFluxUtils.resolveBodyFromCacheRequest(exchange); + log.info("[PLUS]开始请求 => URL[{}],参数类型[json],参数:[{}]", url, jsonParam); + } + } else { + MultiValueMap parameterMap = request.getQueryParams(); + if (MapUtil.isNotEmpty(parameterMap)) { + String parameters = JSONUtil.toJsonStr(parameterMap); + log.info("[PLUS]开始请求 => URL[{}],参数类型[param],参数:[{}]", url, parameters); + } else { + log.info("[PLUS]开始请求 => URL[{}],无参数", url); + } + } + + exchange.getAttributes().put(START_TIME, System.currentTimeMillis()); + return chain.filter(exchange).then(Mono.fromRunnable(() -> { + Long startTime = exchange.getAttribute(START_TIME); + if (startTime != null) { + long executeTime = (System.currentTimeMillis() - startTime); + log.info("[PLUS]结束请求 => URL[{}],耗时:[{}]毫秒", url, executeTime); + } + })); + } + + @Override + public int getOrder() { + // 日志处理器在负载均衡器之后执行 负载均衡器会导致线程切换 无法获取上下文内容 + // 如需在日志内操作线程上下文 例如获取登录用户数据等 可以打开下方注释代码 + // return ReactiveLoadBalancerClientFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER - 1; + return Ordered.LOWEST_PRECEDENCE; + } + +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/SseException.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/SseException.java new file mode 100644 index 000000000..6c460aaa6 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/filter/SseException.java @@ -0,0 +1,62 @@ +package org.dromara.gateway.filter; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serial; + +/** + * sse 特制异常 + * + * @author LionLi + */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public final class SseException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 错误码 + */ + private Integer code; + + /** + * 错误提示 + */ + private String message; + + /** + * 错误明细,内部调试错误 + */ + private String detailMessage; + + public SseException(String message) { + this.message = message; + } + + public SseException(String message, Integer code) { + this.message = message; + this.code = code; + } + + @Override + public String getMessage() { + return message; + } + + public SseException setMessage(String message) { + this.message = message; + return this; + } + + public SseException setDetailMessage(String detailMessage) { + this.detailMessage = detailMessage; + return this; + } +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/handler/GatewayExceptionHandler.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/handler/GatewayExceptionHandler.java new file mode 100644 index 000000000..375542811 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/handler/GatewayExceptionHandler.java @@ -0,0 +1,46 @@ +package org.dromara.gateway.handler; + +import lombok.extern.slf4j.Slf4j; +import org.dromara.gateway.utils.WebFluxUtils; +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; +import org.springframework.cloud.gateway.support.NotFoundException; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * 网关统一异常处理 + * + * @author ruoyi + */ +@Slf4j +@Order(-1) +@Configuration +public class GatewayExceptionHandler implements ErrorWebExceptionHandler { + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + ServerHttpResponse response = exchange.getResponse(); + + if (exchange.getResponse().isCommitted()) { + return Mono.error(ex); + } + + String msg; + + if (ex instanceof NotFoundException) { + msg = "服务未找到"; + } else if (ex instanceof ResponseStatusException responseStatusException) { + msg = responseStatusException.getMessage(); + } else { + msg = "内部服务器错误"; + } + + log.error("[网关异常处理]请求路径:{},异常信息:{}", exchange.getRequest().getPath(), ex.getMessage()); + + return WebFluxUtils.webFluxResponseWriter(response, msg); + } +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/utils/WebFluxUtils.java b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/utils/WebFluxUtils.java new file mode 100644 index 000000000..8af32bd80 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/java/org/dromara/gateway/utils/WebFluxUtils.java @@ -0,0 +1,149 @@ +package org.dromara.gateway.utils; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.json.JSONUtil; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.gateway.filter.GlobalCacheRequestFilter; +import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashSet; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ORIGINAL_REQUEST_URL_ATTR; + +/** + * WebFlux 工具类 + * + * @author Lion Li + */ +public class WebFluxUtils { + + /** + * 获取原请求路径 + */ + public static String getOriginalRequestUrl(ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + LinkedHashSet uris = exchange.getAttributeOrDefault(GATEWAY_ORIGINAL_REQUEST_URL_ATTR, new LinkedHashSet<>()); + URI requestUri = uris.stream().findFirst().orElse(request.getURI()); + return UriComponentsBuilder.fromPath(requestUri.getRawPath()).build().toUriString(); + } + + /** + * 是否是Json请求 + * + * @param exchange HTTP请求 + */ + public static boolean isJsonRequest(ServerWebExchange exchange) { + String header = exchange.getRequest().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE); + return StringUtils.startsWithIgnoreCase(header, MediaType.APPLICATION_JSON_VALUE); + } + + /** + * 读取request内的body + * + * 注意一个request只能读取一次 读取之后需要重新包装 + */ + public static String resolveBodyFromRequest(ServerHttpRequest serverHttpRequest) { + // 获取请求体 + Flux body = serverHttpRequest.getBody(); + AtomicReference bodyRef = new AtomicReference<>(); + body.subscribe(buffer -> { + try (DataBuffer.ByteBufferIterator iterator = buffer.readableByteBuffers()) { + CharBuffer charBuffer = StandardCharsets.UTF_8.decode(iterator.next()); + DataBufferUtils.release(buffer); + bodyRef.set(charBuffer.toString()); + } + }); + return bodyRef.get(); + } + + /** + * 从缓存中读取request内的body + * + * 注意要求经过 {@link ServerWebExchangeUtils#cacheRequestBody(ServerWebExchange, Function)} 此方法创建缓存 + * 框架内已经使用 {@link GlobalCacheRequestFilter} 全局创建了body缓存 + * + * @return body + */ + public static String resolveBodyFromCacheRequest(ServerWebExchange exchange) { + Object obj = exchange.getAttributes().get(ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR); + if (ObjectUtil.isNull(obj)) { + return null; + } + DataBuffer buffer = (DataBuffer) obj; + try (DataBuffer.ByteBufferIterator iterator = buffer.readableByteBuffers()) { + CharBuffer charBuffer = StandardCharsets.UTF_8.decode(iterator.next()); + return charBuffer.toString(); + } + } + + /** + * 设置webflux模型响应 + * + * @param response ServerHttpResponse + * @param value 响应内容 + * @return Mono + */ + public static Mono webFluxResponseWriter(ServerHttpResponse response, Object value) { + return webFluxResponseWriter(response, HttpStatus.OK, value, R.FAIL); + } + + /** + * 设置webflux模型响应 + * + * @param response ServerHttpResponse + * @param code 响应状态码 + * @param value 响应内容 + * @return Mono + */ + public static Mono webFluxResponseWriter(ServerHttpResponse response, Object value, int code) { + return webFluxResponseWriter(response, HttpStatus.OK, value, code); + } + + /** + * 设置webflux模型响应 + * + * @param response ServerHttpResponse + * @param status http状态码 + * @param code 响应状态码 + * @param value 响应内容 + * @return Mono + */ + public static Mono webFluxResponseWriter(ServerHttpResponse response, HttpStatus status, Object value, int code) { + return webFluxResponseWriter(response, MediaType.APPLICATION_JSON_VALUE, status, value, code); + } + + /** + * 设置webflux模型响应 + * + * @param response ServerHttpResponse + * @param contentType content-type + * @param status http状态码 + * @param code 响应状态码 + * @param value 响应内容 + * @return Mono + */ + public static Mono webFluxResponseWriter(ServerHttpResponse response, String contentType, HttpStatus status, Object value, int code) { + response.setStatusCode(status); + response.getHeaders().add(HttpHeaders.CONTENT_TYPE, contentType); + R result = R.fail(code, value.toString()); + DataBuffer dataBuffer = response.bufferFactory().wrap(JSONUtil.toJsonStr(result).getBytes()); + return response.writeWith(Mono.just(dataBuffer)); + } +} diff --git a/ruoyi-site/ruoyi-gateway/src/main/resources/application.yml b/ruoyi-site/ruoyi-gateway/src/main/resources/application.yml new file mode 100644 index 000000000..c64e50ec3 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/resources/application.yml @@ -0,0 +1,33 @@ +# Tomcat +server: + port: 8080 + servlet: + context-path: / + +# Spring +spring: + application: + # 应用名称 + name: ruoyi-gateway + profiles: + # 环境配置 + active: @profiles.active@ + +--- # nacos 配置 +spring: + cloud: + nacos: + # nacos 服务地址 + server-addr: http://localhost:8848 + discovery: + # 注册组 + group: dev + namespace: c5c00044-93e6-4e66-8060-3f39aa7ab82b + config: + # 配置组 + group: dev + namespace: c5c00044-93e6-4e66-8060-3f39aa7ab82b + config: + import: + - optional:nacos:application-common.yml + - optional:nacos:${spring.application.name}.yml diff --git a/ruoyi-site/ruoyi-gateway/src/main/resources/banner.txt b/ruoyi-site/ruoyi-gateway/src/main/resources/banner.txt new file mode 100644 index 000000000..ceced29f4 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/resources/banner.txt @@ -0,0 +1,10 @@ +Spring Boot Version: ${spring-boot.version} +Spring Application Name: ${spring.application.name} + _ _ + (_) | | + _ __ _ _ ___ _ _ _ ______ __ _ __ _ | |_ ___ __ __ __ _ _ _ +| '__|| | | | / _ \ | | | || ||______| / _` | / _` || __| / _ \\ \ /\ / / / _` || | | | +| | | |_| || (_) || |_| || | | (_| || (_| || |_ | __/ \ V V / | (_| || |_| | +|_| \__,_| \___/ \__, ||_| \__, | \__,_| \__| \___| \_/\_/ \__,_| \__, | + __/ | __/ | __/ | + |___/ |___/ |___/ \ No newline at end of file diff --git a/ruoyi-site/ruoyi-gateway/src/main/resources/logback-plus.xml b/ruoyi-site/ruoyi-gateway/src/main/resources/logback-plus.xml new file mode 100644 index 000000000..4d66014c6 --- /dev/null +++ b/ruoyi-site/ruoyi-gateway/src/main/resources/logback-plus.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + ${console.log.pattern} + utf-8 + + + + + + ${log.path}/console.log + + + ${log.path}/console.%d{yyyy-MM-dd}.log + + 1 + + + ${log.pattern} + utf-8 + + + + INFO + + + + + + ${log.path}/info.log + + + + ${log.path}/info.%d{yyyy-MM-dd}.log + + 60 + + + ${log.pattern} + + + + INFO + + ACCEPT + + DENY + + + + + ${log.path}/error.log + + + + ${log.path}/error.%d{yyyy-MM-dd}.log + + 60 + + + ${log.pattern} + + + + ERROR + + ACCEPT + + DENY + + + + + + + 0 + + 512 + + + + + + + + 0 + + 512 + + + + + + + + + + + + + + + + +