Kaynağa Gözat

add 新增 gateway Servlet MVC版本(建议在虚拟线程下使用)

疯狂的狮子Li 4 ay önce
ebeveyn
işleme
fbddd6d027
20 değiştirilmiş dosya ile 1134 ekleme ve 0 silme
  1. 1 0
      pom.xml
  2. 26 0
      ruoyi-gateway-mvc/Dockerfile
  3. 7 0
      ruoyi-gateway-mvc/README.md
  4. 134 0
      ruoyi-gateway-mvc/pom.xml
  5. 23 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/RuoYiGatewayMvcApplication.java
  6. 71 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/GatewayConfig.java
  7. 75 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/UndertowConfig.java
  8. 29 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/ApiDecryptProperties.java
  9. 24 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/CustomGatewayProperties.java
  10. 30 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/IgnoreWhiteProperties.java
  11. 89 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/AuthFilter.java
  12. 65 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/ForwardAuthFilter.java
  13. 150 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/GlobalLogFilter.java
  14. 70 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/support/CachedBodyHttpServletRequest.java
  15. 56 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/support/MutableHttpServletRequest.java
  16. 41 0
      ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/handler/GatewayExceptionHandler.java
  17. 35 0
      ruoyi-gateway-mvc/src/main/resources/application.yml
  18. 10 0
      ruoyi-gateway-mvc/src/main/resources/banner.txt
  19. 114 0
      ruoyi-gateway-mvc/src/main/resources/logback-plus.xml
  20. 84 0
      script/config/nacos/ruoyi-gateway-mvc.yml

+ 1 - 0
pom.xml

@@ -388,6 +388,7 @@
     <modules>
         <module>ruoyi-auth</module>
         <module>ruoyi-gateway</module>
+        <module>ruoyi-gateway-mvc</module>
         <module>ruoyi-visual</module>
         <module>ruoyi-modules</module>
         <module>ruoyi-api</module>

+ 26 - 0
ruoyi-gateway-mvc/Dockerfile

@@ -0,0 +1,26 @@
+# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/
+FROM bellsoft/liberica-openjdk-rocky:17.0.16-cds
+#FROM bellsoft/liberica-openjdk-rocky:21.0.8-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-mvc.jar ./app.jar
+
+SHELL ["/bin/bash", "-c"]
+
+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

+ 7 - 0
ruoyi-gateway-mvc/README.md

@@ -0,0 +1,7 @@
+此服务为 spring-cloud-gateway 的 Servlet MVC 版本
+
+建议在JDK21启动虚拟线程的基础上使用 在JDK17下使用性能比原版差很多
+
+使用教程 将 ruoyi-gateway-mvc.yml 配置文件上传到 nacos 正常启动服务 与原先版本一样使用
+
+注意 原版与此版只能选择一个使用 不允许两个同时使用

+ 134 - 0
ruoyi-gateway-mvc/pom.xml

@@ -0,0 +1,134 @@
+<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">
+    <parent>
+        <groupId>org.dromara</groupId>
+        <artifactId>ruoyi-cloud-plus</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>ruoyi-gateway-mvc</artifactId>
+
+    <description>
+        ruoyi-gateway-mvc网关模块
+    </description>
+
+    <dependencies>
+
+        <!-- SpringCloud Gateway -->
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
+        </dependency>
+
+        <!-- SpringBoot Web容器 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+            <exclusions>
+                <exclusion>
+                    <artifactId>spring-boot-starter-tomcat</artifactId>
+                    <groupId>org.springframework.boot</groupId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <!-- web 容器使用 undertow 性能更强 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-undertow</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.dromara</groupId>
+            <artifactId>ruoyi-common-nacos</artifactId>
+        </dependency>
+
+        <!-- SpringBoot Actuator -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-http</artifactId>
+        </dependency>
+
+        <!-- Sa-Token 权限认证 -->
+        <dependency>
+            <groupId>cn.dev33</groupId>
+            <artifactId>sa-token-spring-boot3-starter</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.dromara</groupId>
+            <artifactId>ruoyi-common-satoken</artifactId>
+        </dependency>
+
+        <!-- RuoYi Common Redis-->
+        <dependency>
+            <groupId>org.dromara</groupId>
+            <artifactId>ruoyi-common-redis</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.dromara</groupId>
+            <artifactId>ruoyi-common-tenant</artifactId>
+        </dependency>
+
+        <!-- 自定义负载均衡(多团队开发使用) -->
+<!--        <dependency>-->
+<!--            <groupId>org.dromara</groupId>-->
+<!--            <artifactId>ruoyi-common-loadbalancer</artifactId>-->
+<!--        </dependency>-->
+
+        <!-- ELK 日志收集 -->
+<!--        <dependency>-->
+<!--            <groupId>org.dromara</groupId>-->
+<!--            <artifactId>ruoyi-common-logstash</artifactId>-->
+<!--        </dependency>-->
+
+        <!-- skywalking 日志收集 -->
+<!--        <dependency>-->
+<!--            <groupId>org.dromara</groupId>-->
+<!--            <artifactId>ruoyi-common-skylog</artifactId>-->
+<!--        </dependency>-->
+
+        <!-- prometheus 监控 -->
+<!--        <dependency>-->
+<!--            <groupId>org.dromara</groupId>-->
+<!--            <artifactId>ruoyi-common-prometheus</artifactId>-->
+<!--        </dependency>-->
+
+    </dependencies>
+
+    <build>
+        <finalName>${project.artifactId}</finalName>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>${spring-boot.version}</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>

+ 23 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/RuoYiGatewayMvcApplication.java

@@ -0,0 +1,23 @@
+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 Lion Li
+ */
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+public class RuoYiGatewayMvcApplication {
+
+    public static void main(String[] args) {
+        SpringApplication application = new SpringApplication(RuoYiGatewayMvcApplication.class);
+        application.setApplicationStartup(new BufferingApplicationStartup(2048));
+        application.run(args);
+        System.out.println("(♥◠‿◠)ノ゙  MVC网关启动成功   ლ(´ڡ`ლ)゙  ");
+    }
+
+}

+ 71 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/GatewayConfig.java

@@ -0,0 +1,71 @@
+package org.dromara.gateway.config;
+
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+import org.springframework.web.servlet.LocaleResolver;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.List;
+import java.util.Locale;
+import org.springframework.core.Ordered;
+
+/**
+ * 网关配置
+ *
+ * @author Lion Li
+ */
+@Configuration
+public class GatewayConfig {
+
+    @Bean
+    public LocaleResolver localeResolver() {
+        return new LocaleResolver() {
+            @Override
+            public Locale resolveLocale(HttpServletRequest request) {
+                String language = request.getHeader("content-language");
+                Locale locale = Locale.getDefault();
+                if (language == null || language.isBlank()) {
+                    return locale;
+                }
+                String[] split = language.split("_");
+                if (split.length == 2) {
+                    return new Locale(split[0], split[1]);
+                }
+                return Locale.forLanguageTag(language.replace('_', '-'));
+            }
+
+            @Override
+            public void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale) {
+                // noop
+            }
+        };
+    }
+
+    @Bean
+    public FilterRegistrationBean<CorsFilter> corsFilterRegistrationBean() {
+        CorsConfiguration config = new CorsConfiguration();
+        config.setAllowedOriginPatterns(List.of("*"));
+        config.setAllowCredentials(true);
+        config.setAllowedHeaders(List.of(
+            "X-Requested-With", "Content-Language", "Content-Type",
+            "Authorization", "clientid", "credential", "X-XSRF-TOKEN",
+            "isToken", "token", "Admin-Token", "App-Token", "Encrypt-Key", "isEncrypt"
+        ));
+        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"));
+        config.setExposedHeaders(List.of("*"));
+        config.setMaxAge(18000L);
+
+        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+        source.registerCorsConfiguration("/**", config);
+
+        FilterRegistrationBean<CorsFilter> registration = new FilterRegistrationBean<>(new CorsFilter(source));
+        registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
+        return registration;
+    }
+
+}

+ 75 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/UndertowConfig.java

@@ -0,0 +1,75 @@
+package org.dromara.gateway.config;
+
+import io.undertow.UndertowOptions;
+import io.undertow.server.DefaultByteBufferPool;
+import io.undertow.server.handlers.DisallowedMethodsHandler;
+import io.undertow.util.HttpString;
+import io.undertow.websockets.jsr.WebSocketDeploymentInfo;
+import org.dromara.common.core.utils.SpringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.web.ServerProperties;
+import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.VirtualThreadTaskExecutor;
+
+/**
+ * Undertow 自定义配置
+ *
+ * @author Lion Li
+ */
+@Configuration
+public class UndertowConfig implements WebServerFactoryCustomizer<UndertowServletWebServerFactory> {
+
+    @Autowired
+    private ServerProperties serverProperties;
+
+    /**
+     * 自定义 Undertow 配置
+     * <p>
+     * 主要配置内容包括:
+     * 1. 配置 WebSocket 部署信息
+     * 2. 在虚拟线程模式下使用虚拟线程池
+     * 3. 禁用不安全的 HTTP 方法,如 CONNECT、TRACE、TRACK
+     * </p>
+     *
+     * @param factory Undertow 的 Web 服务器工厂
+     */
+    @Override
+    public void customize(UndertowServletWebServerFactory factory) {
+        long bytes = serverProperties.getUndertow().getMaxHttpPostSize().toBytes();
+        factory.addBuilderCustomizers(builder -> {
+            builder.setServerOption(UndertowOptions.MULTIPART_MAX_ENTITY_SIZE, bytes);
+        });
+
+        // 默认不直接分配内存 如果项目中使用了 websocket 建议直接分配
+        factory.addDeploymentInfoCustomizers(deploymentInfo -> {
+            // 配置 WebSocket 部署信息,设置 WebSocket 使用的缓冲区池
+            WebSocketDeploymentInfo webSocketDeploymentInfo = new WebSocketDeploymentInfo();
+            webSocketDeploymentInfo.setBuffers(new DefaultByteBufferPool(true, 1024));
+            deploymentInfo.addServletContextAttribute("io.undertow.websockets.jsr.WebSocketDeploymentInfo", webSocketDeploymentInfo);
+
+            // 如果启用了虚拟线程,配置 Undertow 使用虚拟线程池
+            if (SpringUtils.isVirtual()) {
+                // 创建虚拟线程池,线程池前缀为 "undertow-"
+                VirtualThreadTaskExecutor executor = new VirtualThreadTaskExecutor("undertow-");
+                // 设置虚拟线程池为执行器和异步执行器
+                deploymentInfo.setExecutor(executor);
+                deploymentInfo.setAsyncExecutor(executor);
+            }
+
+            // 配置禁止某些不安全的 HTTP 方法(如 CONNECT、TRACE、TRACK)
+            deploymentInfo.addInitialHandlerChainWrapper(handler -> {
+                // 禁止三个方法 CONNECT/TRACE/TRACK 也是不安全的 避免爬虫骚扰
+                HttpString[] disallowedHttpMethods = {
+                    HttpString.tryFromString("CONNECT"),
+                    HttpString.tryFromString("TRACE"),
+                    HttpString.tryFromString("TRACK")
+                };
+                // 使用 DisallowedMethodsHandler 拦截并拒绝这些方法的请求
+                return new DisallowedMethodsHandler(handler, disallowedHttpMethods);
+            });
+        });
+    }
+
+}

+ 29 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/ApiDecryptProperties.java

@@ -0,0 +1,29 @@
+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;
+
+}

+ 24 - 0
ruoyi-gateway-mvc/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;
+
+}

+ 30 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/IgnoreWhiteProperties.java

@@ -0,0 +1,30 @@
+package org.dromara.gateway.config.properties;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+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<String> whites = new ArrayList<>();
+
+}

+ 89 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/AuthFilter.java

@@ -0,0 +1,89 @@
+package org.dromara.gateway.filter;
+
+import cn.dev33.satoken.exception.NotLoginException;
+import cn.dev33.satoken.filter.SaServletFilter;
+import cn.dev33.satoken.httpauth.basic.SaHttpBasicUtil;
+import cn.dev33.satoken.interceptor.SaInterceptor;
+import cn.dev33.satoken.router.SaRouter;
+import cn.dev33.satoken.stp.StpUtil;
+import cn.dev33.satoken.util.SaResult;
+import cn.dev33.satoken.util.SaTokenConsts;
+import org.dromara.common.core.constant.HttpStatus;
+import org.dromara.common.core.utils.ServletUtils;
+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.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+/**
+ * [Sa-Token 权限认证] 拦截器配置
+ *
+ * @author Lion Li
+ */
+@Configuration
+public class AuthFilter implements WebMvcConfigurer {
+
+    private final IgnoreWhiteProperties ignoreWhite;
+
+    public AuthFilter(IgnoreWhiteProperties ignoreWhite) {
+        this.ignoreWhite = ignoreWhite;
+    }
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(new SaInterceptor(handler -> SaRouter.match("/**")
+            .notMatch(ignoreWhite.getWhites())
+            .check(() -> {
+                HttpServletRequest request = ServletUtils.getRequest();
+                HttpServletResponse response = ServletUtils.getResponse();
+                if (response != null) {
+                    response.setContentType(SaTokenConsts.CONTENT_TYPE_APPLICATION_JSON);
+                }
+
+                StpUtil.checkLogin();
+
+                String headerCid = request.getHeader(LoginHelper.CLIENT_KEY);
+                String paramCid = ServletUtils.getParameter(LoginHelper.CLIENT_KEY);
+                Object extra = StpUtil.getExtra(LoginHelper.CLIENT_KEY);
+                String clientId = extra == null ? null : extra.toString();
+                if (!StringUtils.equalsAny(clientId, headerCid, paramCid)) {
+                    throw NotLoginException.newInstance(StpUtil.getLoginType(),
+                        "-100", "客户端ID与Token不匹配",
+                        StpUtil.getTokenValue());
+                }
+            })))
+            .addPathPatterns("/**")
+            .excludePathPatterns("/favicon.ico", "/actuator", "/actuator/**", "/resource/sse");
+    }
+
+    /**
+     * 为 actuator 健康检查接口配置 Basic Auth 鉴权过滤器。
+     *
+     * @return Sa-Token Servlet 过滤器
+     */
+    @Bean
+    public SaServletFilter getSaServletFilter() {
+        String username = SpringUtils.getProperty("spring.cloud.nacos.discovery.metadata.username");
+        String password = SpringUtils.getProperty("spring.cloud.nacos.discovery.metadata.userpassword");
+        return new SaServletFilter()
+            .addInclude("/actuator", "/actuator/**")
+            .setAuth(obj -> {
+                SaHttpBasicUtil.check(username + ":" + password);
+            })
+            .setError(e -> {
+                HttpServletResponse response = ServletUtils.getResponse();
+                response.setContentType(SaTokenConsts.CONTENT_TYPE_APPLICATION_JSON);
+                return SaResult.error(e.getMessage()).setCode(HttpStatus.UNAUTHORIZED);
+            });
+    }
+
+}

+ 65 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/ForwardAuthFilter.java

@@ -0,0 +1,65 @@
+package org.dromara.gateway.filter;
+
+import cn.dev33.satoken.SaManager;
+import cn.dev33.satoken.same.SaSameUtil;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.dromara.gateway.filter.support.MutableHttpServletRequest;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/**
+ * 转发请求头过滤器:
+ * 1. 动态透传 X-Forwarded-Prefix
+ * 2. 转发内部 same-token
+ *
+ * @author Lion Li
+ */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE + 15)
+public class ForwardAuthFilter extends OncePerRequestFilter {
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        return request.getRequestURI().startsWith("/actuator");
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+        throws ServletException, IOException {
+        MutableHttpServletRequest newRequest = null;
+
+        String forwardedPrefix = resolveForwardedPrefix(request);
+        if (forwardedPrefix != null) {
+            newRequest = getOrCreateMutableRequest(request, newRequest);
+            newRequest.putHeader("X-Forwarded-Prefix", forwardedPrefix);
+        }
+
+        if (SaManager.getConfig().getCheckSameToken()) {
+            newRequest = getOrCreateMutableRequest(request, newRequest);
+            newRequest.putHeader(SaSameUtil.SAME_TOKEN, SaSameUtil.getToken());
+        }
+
+        filterChain.doFilter(newRequest == null ? request : newRequest, response);
+    }
+
+    private String resolveForwardedPrefix(HttpServletRequest request) {
+        String[] pathSegments = StringUtils.tokenizeToStringArray(request.getRequestURI(), "/");
+        if (pathSegments.length == 0) {
+            return null;
+        }
+        return "/" + pathSegments[0];
+    }
+
+    private MutableHttpServletRequest getOrCreateMutableRequest(HttpServletRequest request,
+                                                                MutableHttpServletRequest currentRequest) {
+        return currentRequest != null ? currentRequest : new MutableHttpServletRequest(request);
+    }
+}

+ 150 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/GlobalLogFilter.java

@@ -0,0 +1,150 @@
+package org.dromara.gateway.filter;
+
+import cn.hutool.core.map.MapUtil;
+import cn.hutool.core.util.ArrayUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.constant.SystemConstants;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.json.utils.JsonUtils;
+import org.dromara.gateway.config.properties.ApiDecryptProperties;
+import org.dromara.gateway.config.properties.CustomGatewayProperties;
+import org.dromara.gateway.filter.support.CachedBodyHttpServletRequest;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 全局日志过滤器
+ * <p>
+ * 用于打印请求执行参数与响应时间等等
+ *
+ * @author Lion Li
+ */
+@Slf4j
+@Component
+@Order(Ordered.LOWEST_PRECEDENCE - 10)
+public class GlobalLogFilter extends OncePerRequestFilter {
+
+    private final CustomGatewayProperties customGatewayProperties;
+    private final ApiDecryptProperties apiDecryptProperties;
+
+    public GlobalLogFilter(CustomGatewayProperties customGatewayProperties, ApiDecryptProperties apiDecryptProperties) {
+        this.customGatewayProperties = customGatewayProperties;
+        this.apiDecryptProperties = apiDecryptProperties;
+    }
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        return !Boolean.TRUE.equals(customGatewayProperties.getRequestLog());
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+        throws ServletException, IOException {
+        HttpServletRequest requestToUse = request;
+        if (isJsonRequest(request) && !(request instanceof CachedBodyHttpServletRequest)) {
+            requestToUse = new CachedBodyHttpServletRequest(request);
+        }
+
+        String url = requestToUse.getMethod() + " " + requestToUse.getRequestURI();
+        logRequest(requestToUse, url);
+
+        long startTime = System.currentTimeMillis();
+        try {
+            filterChain.doFilter(requestToUse, response);
+        } finally {
+            log.info("[PLUS]结束请求 => URL[{}],耗时:[{}]毫秒", url, System.currentTimeMillis() - startTime);
+        }
+    }
+
+    private void logRequest(HttpServletRequest request, String url) {
+        if (isJsonRequest(request)) {
+            if (Boolean.TRUE.equals(apiDecryptProperties.getEnabled())
+                && ObjectUtil.isNotNull(request.getHeader(apiDecryptProperties.getHeaderFlag()))) {
+                log.info("[PLUS]开始请求 => URL[{}],参数类型[encrypt]", url);
+                return;
+            }
+            String jsonParam = resolveBody(request);
+            if (StringUtils.isNotBlank(jsonParam)) {
+                jsonParam = removeSensitiveFields(jsonParam);
+            }
+            log.info("[PLUS]开始请求 => URL[{}],参数类型[json],参数:[{}]", url, jsonParam);
+            return;
+        }
+
+        MultiValueMap<String, String> parameterMap = UriComponentsBuilder.newInstance()
+            .query(request.getQueryString())
+            .build()
+            .getQueryParams();
+        if (MapUtil.isNotEmpty(parameterMap)) {
+            LinkedMultiValueMap<String, String> map = new LinkedMultiValueMap<>(parameterMap);
+            MapUtil.removeAny(map, SystemConstants.EXCLUDE_PROPERTIES);
+            log.info("[PLUS]开始请求 => URL[{}],参数类型[param],参数:[{}]", url, JsonUtils.toJsonString(map));
+        } else {
+            log.info("[PLUS]开始请求 => URL[{}],无参数", url);
+        }
+    }
+
+    private boolean isJsonRequest(HttpServletRequest request) {
+        return StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE);
+    }
+
+    private String resolveBody(HttpServletRequest request) {
+        if (request instanceof CachedBodyHttpServletRequest cachedRequest) {
+            return cachedRequest.getCachedBodyAsString();
+        }
+        return null;
+    }
+
+    private String removeSensitiveFields(String jsonParam) {
+        try {
+            ObjectMapper objectMapper = JsonUtils.getObjectMapper();
+            JsonNode rootNode = objectMapper.readTree(jsonParam);
+            removeSensitiveFields(rootNode, SystemConstants.EXCLUDE_PROPERTIES);
+            return rootNode.toString();
+        } catch (Exception ex) {
+            return jsonParam;
+        }
+    }
+
+    private void removeSensitiveFields(JsonNode node, String[] excludeProperties) {
+        if (node == null) {
+            return;
+        }
+        if (node.isObject()) {
+            ObjectNode objectNode = (ObjectNode) node;
+            Set<String> fieldsToRemove = new HashSet<>();
+            objectNode.fieldNames().forEachRemaining(fieldName -> {
+                if (ArrayUtil.contains(excludeProperties, fieldName)) {
+                    fieldsToRemove.add(fieldName);
+                }
+            });
+            fieldsToRemove.forEach(objectNode::remove);
+            objectNode.elements().forEachRemaining(child -> removeSensitiveFields(child, excludeProperties));
+        } else if (node.isArray()) {
+            ArrayNode arrayNode = (ArrayNode) node;
+            for (JsonNode child : arrayNode) {
+                removeSensitiveFields(child, excludeProperties);
+            }
+        }
+    }
+
+}

+ 70 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/support/CachedBodyHttpServletRequest.java

@@ -0,0 +1,70 @@
+package org.dromara.gateway.filter.support;
+
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import org.springframework.util.StreamUtils;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 可重复读取请求体的包装类
+ *
+ * @author Lion Li
+ */
+public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
+
+    private final byte[] cachedBody;
+
+    public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
+        super(request);
+        this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
+    }
+
+    @Override
+    public ServletInputStream getInputStream() {
+        ByteArrayInputStream inputStream = new ByteArrayInputStream(cachedBody);
+        return new ServletInputStream() {
+            @Override
+            public int read() {
+                return inputStream.read();
+            }
+
+            @Override
+            public boolean isFinished() {
+                return inputStream.available() == 0;
+            }
+
+            @Override
+            public boolean isReady() {
+                return true;
+            }
+
+            @Override
+            public void setReadListener(ReadListener listener) {
+                // noop
+            }
+        };
+    }
+
+    @Override
+    public BufferedReader getReader() {
+        Charset charset = getCharacterEncoding() == null
+            ? StandardCharsets.UTF_8
+            : Charset.forName(getCharacterEncoding());
+        return new BufferedReader(new InputStreamReader(getInputStream(), charset));
+    }
+
+    public String getCachedBodyAsString() {
+        Charset charset = getCharacterEncoding() == null
+            ? StandardCharsets.UTF_8
+            : Charset.forName(getCharacterEncoding());
+        return new String(cachedBody, charset);
+    }
+}

+ 56 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/support/MutableHttpServletRequest.java

@@ -0,0 +1,56 @@
+package org.dromara.gateway.filter.support;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.Vector;
+
+/**
+ * 可追加请求头的包装类
+ *
+ * @author Lion Li
+ */
+public class MutableHttpServletRequest extends HttpServletRequestWrapper {
+
+    private final Map<String, String> customHeaders = new LinkedHashMap<>();
+
+    public MutableHttpServletRequest(HttpServletRequest request) {
+        super(request);
+    }
+
+    public void putHeader(String name, String value) {
+        customHeaders.put(name, value);
+    }
+
+    @Override
+    public String getHeader(String name) {
+        String value = customHeaders.get(name);
+        return value != null ? value : super.getHeader(name);
+    }
+
+    @Override
+    public Enumeration<String> getHeaders(String name) {
+        String value = customHeaders.get(name);
+        if (value != null) {
+            return Collections.enumeration(Collections.singletonList(value));
+        }
+        return super.getHeaders(name);
+    }
+
+    @Override
+    public Enumeration<String> getHeaderNames() {
+        Set<String> names = new LinkedHashSet<>();
+        Enumeration<String> headerNames = super.getHeaderNames();
+        while (headerNames.hasMoreElements()) {
+            names.add(headerNames.nextElement());
+        }
+        names.addAll(customHeaders.keySet());
+        return new Vector<>(names).elements();
+    }
+}

+ 41 - 0
ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/handler/GatewayExceptionHandler.java

@@ -0,0 +1,41 @@
+package org.dromara.gateway.handler;
+
+import cn.dev33.satoken.exception.NotLoginException;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.constant.HttpStatus;
+import org.dromara.common.core.domain.R;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.server.ResponseStatusException;
+
+/**
+ * 网关统一异常处理
+ *
+ * @author Lion Li
+ */
+@Slf4j
+@RestControllerAdvice
+public class GatewayExceptionHandler {
+
+    @ExceptionHandler(NotLoginException.class)
+    public R<Void> handleNotLogin(HttpServletRequest request, NotLoginException ex) {
+        log.warn("[网关认证失败]请求路径:{},异常信息:{}", request.getRequestURI(), ex.getMessage());
+        return R.fail(HttpStatus.UNAUTHORIZED, ex.getMessage());
+    }
+
+    @ExceptionHandler(Throwable.class)
+    public R<Void> handle(HttpServletRequest request, Throwable ex) {
+        String msg;
+        if ("NotFoundException".equals(ex.getClass().getSimpleName())) {
+            msg = "服务未找到";
+        } else if (ex instanceof ResponseStatusException responseStatusException) {
+            msg = responseStatusException.getMessage();
+        } else {
+            msg = "内部服务器错误";
+        }
+
+        log.error("[网关异常处理]请求路径:{},异常信息:{}", request.getRequestURI(), ex.getMessage(), ex);
+        return R.fail(msg);
+    }
+}

+ 35 - 0
ruoyi-gateway-mvc/src/main/resources/application.yml

@@ -0,0 +1,35 @@
+# Tomcat
+server:
+  port: 8080
+  servlet:
+    context-path: /
+
+# Spring
+spring:
+  application:
+    # 应用名称
+    name: ruoyi-gateway-mvc
+  profiles:
+    # 环境配置
+    active: @profiles.active@
+
+--- # nacos 配置
+spring:
+  cloud:
+    nacos:
+      # nacos 服务地址
+      server-addr: @nacos.server@
+      username: @nacos.username@
+      password: @nacos.password@
+      discovery:
+        # 注册组
+        group: @nacos.discovery.group@
+        namespace: ${spring.profiles.active}
+      config:
+        # 配置组
+        group: @nacos.config.group@
+        namespace: ${spring.profiles.active}
+  config:
+    import:
+      - optional:nacos:application-common.yml
+      - optional:nacos:${spring.application.name}.yml

+ 10 - 0
ruoyi-gateway-mvc/src/main/resources/banner.txt

@@ -0,0 +1,10 @@
+Spring Boot Version: ${spring-boot.version}
+Spring Application Name: ${spring.application.name}
+                            _                        _                                 
+                           (_)                      | |                                
+ _ __  _   _   ___   _   _  _  ______   __ _   __ _ | |_   ___ __      __  __ _  _   _ 
+| '__|| | | | / _ \ | | | || ||______| / _` | / _` || __| / _ \\ \ /\ / / / _` || | | |
+| |   | |_| || (_) || |_| || |        | (_| || (_| || |_ |  __/ \ V  V / | (_| || |_| |
+|_|    \__,_| \___/  \__, ||_|         \__, | \__,_| \__| \___|  \_/\_/   \__,_| \__, |
+                      __/ |             __/ |                                     __/ |
+                     |___/             |___/                                     |___/ 

+ 114 - 0
ruoyi-gateway-mvc/src/main/resources/logback-plus.xml

@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration scan="true" scanPeriod="60 seconds" debug="false">
+    <!-- 日志存放路径 -->
+    <property name="log.path" value="logs/${project.artifactId}"/>
+    <!-- 日志输出格式 -->
+    <property name="console.log.pattern"
+              value="%cyan(%d{yyyy-MM-dd HH:mm:ss}) %green([%thread]) %highlight(%-5level) %boldMagenta(%logger{36}%n) - %msg%n"/>
+    <property name="log.pattern" value="%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"/>
+
+    <!-- 控制台输出 -->
+    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>${console.log.pattern}</pattern>
+            <charset>utf-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- 控制台输出 -->
+    <appender name="file_console" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/console.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/console.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 日志最大 1天 -->
+            <maxHistory>1</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+            <charset>utf-8</charset>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <!-- 过滤的级别 -->
+            <level>INFO</level>
+        </filter>
+    </appender>
+
+    <!-- 系统日志输出 -->
+    <appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/info.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/info.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 日志最大的历史 60天 -->
+            <maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>INFO</level>
+            <!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+            <!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/error.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/error.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 日志最大的历史 60天 -->
+            <maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>ERROR</level>
+            <!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+            <!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- info异步输出 -->
+    <appender name="async_info" class="ch.qos.logback.classic.AsyncAppender">
+        <!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
+        <discardingThreshold>0</discardingThreshold>
+        <!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
+        <queueSize>512</queueSize>
+        <!-- 添加附加的appender,最多只能添加一个 -->
+        <appender-ref ref="file_info"/>
+    </appender>
+
+    <!-- error异步输出 -->
+    <appender name="async_error" class="ch.qos.logback.classic.AsyncAppender">
+        <!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
+        <discardingThreshold>0</discardingThreshold>
+        <!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
+        <queueSize>512</queueSize>
+        <!-- 添加附加的appender,最多只能添加一个 -->
+        <appender-ref ref="file_error"/>
+    </appender>
+
+    <include resource="logback-logstash.xml" />
+
+    <!-- 开启 skywalking 日志收集 -->
+    <include resource="logback-skylog.xml" />
+
+    <!--系统操作日志-->
+    <root level="info">
+        <appender-ref ref="console"/>
+        <appender-ref ref="async_info"/>
+        <appender-ref ref="async_error"/>
+        <appender-ref ref="file_console"/>
+    </root>
+</configuration>

+ 84 - 0
script/config/nacos/ruoyi-gateway-mvc.yml

@@ -0,0 +1,84 @@
+# 安全配置
+security:
+  # 不校验白名单
+  ignore:
+    whites:
+      - /auth/code
+      - /auth/logout
+      - /auth/login
+      - /auth/binding/*
+      - /auth/register
+      - /auth/tenant/list
+      - /resource/sms/code
+      - /resource/sse/close
+      - /*/v3/api-docs
+      - /*/error
+      - /csrf
+      - /warm-flow-ui/**
+
+spring:
+  cloud:
+    gateway:
+      # 打印请求日志(自定义)
+      requestLog: true
+      server:
+        webmvc:
+          discovery:
+            locator:
+              lowerCaseServiceId: true
+              enabled: true
+          routes:
+            # 认证中心
+            - id: ruoyi-auth
+              uri: lb://ruoyi-auth
+              predicates:
+                - Path=/auth/**
+              filters:
+                - StripPrefix=1
+            # 代码生成
+            - id: ruoyi-gen
+              uri: lb://ruoyi-gen
+              predicates:
+                - Path=/tool/**
+              filters:
+                - StripPrefix=1
+            # 系统模块
+            - id: ruoyi-system
+              uri: lb://ruoyi-system
+              predicates:
+                - Path=/system/**,/monitor/**
+              filters:
+                - StripPrefix=1
+            # 资源服务
+            - id: ruoyi-resource
+              uri: lb://ruoyi-resource
+              predicates:
+                - Path=/resource/**
+              filters:
+                - StripPrefix=1
+            # workflow服务
+            - id: ruoyi-workflow
+              uri: lb://ruoyi-workflow
+              predicates:
+                - Path=/workflow/**
+              filters:
+                - StripPrefix=1
+            # warm-flow服务
+            - id: warm-flow
+              uri: lb://ruoyi-workflow
+              predicates:
+                - Path=/warm-flow-ui/**,/warm-flow/**
+            # 演示服务
+            - id: ruoyi-demo
+              uri: lb://ruoyi-demo
+              predicates:
+                - Path=/demo/**
+              filters:
+                - StripPrefix=1
+            # MQ演示服务
+            - id: ruoyi-test-mq
+              uri: lb://ruoyi-test-mq
+              predicates:
+                - Path=/test-mq/**
+              filters:
+                - StripPrefix=1