diff --git a/pom.xml b/pom.xml index 839dbeeb9..5ec539136 100644 --- a/pom.xml +++ b/pom.xml @@ -388,6 +388,7 @@ ruoyi-auth ruoyi-gateway + ruoyi-gateway-mvc ruoyi-visual ruoyi-modules ruoyi-api diff --git a/ruoyi-gateway-mvc/Dockerfile b/ruoyi-gateway-mvc/Dockerfile new file mode 100644 index 000000000..3bea2d839 --- /dev/null +++ b/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 diff --git a/ruoyi-gateway-mvc/README.md b/ruoyi-gateway-mvc/README.md new file mode 100644 index 000000000..c2f27b0d3 --- /dev/null +++ b/ruoyi-gateway-mvc/README.md @@ -0,0 +1,7 @@ +此服务为 spring-cloud-gateway 的 Servlet MVC 版本 + +建议在JDK21启动虚拟线程的基础上使用 在JDK17下使用性能比原版差很多 + +使用教程 将 ruoyi-gateway-mvc.yml 配置文件上传到 nacos 正常启动服务 与原先版本一样使用 + +注意 原版与此版只能选择一个使用 不允许两个同时使用 diff --git a/ruoyi-gateway-mvc/pom.xml b/ruoyi-gateway-mvc/pom.xml new file mode 100644 index 000000000..dc4522f60 --- /dev/null +++ b/ruoyi-gateway-mvc/pom.xml @@ -0,0 +1,134 @@ + + + org.dromara + ruoyi-cloud-plus + ${revision} + + 4.0.0 + + ruoyi-gateway-mvc + + + ruoyi-gateway-mvc网关模块 + + + + + + + org.springframework.cloud + spring-cloud-starter-gateway-server-webmvc + + + + + org.springframework.boot + spring-boot-starter-web + + + spring-boot-starter-tomcat + org.springframework.boot + + + + + + org.springframework.boot + spring-boot-starter-undertow + + + + 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 + + + + cn.hutool + hutool-http + + + + + cn.dev33 + sa-token-spring-boot3-starter + + + + org.dromara + ruoyi-common-satoken + + + + + org.dromara + ruoyi-common-redis + + + + org.dromara + ruoyi-common-tenant + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${project.artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + + + diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/RuoYiGatewayMvcApplication.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/RuoYiGatewayMvcApplication.java new file mode 100644 index 000000000..afff415a4 --- /dev/null +++ b/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网关启动成功 ლ(´ڡ`ლ)゙ "); + } + +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/GatewayConfig.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/GatewayConfig.java new file mode 100644 index 000000000..0eeaac4f7 --- /dev/null +++ b/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 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 registration = new FilterRegistrationBean<>(new CorsFilter(source)); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registration; + } + +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/UndertowConfig.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/UndertowConfig.java new file mode 100644 index 000000000..dd274b5c4 --- /dev/null +++ b/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 { + + @Autowired + private ServerProperties serverProperties; + + /** + * 自定义 Undertow 配置 + *

+ * 主要配置内容包括: + * 1. 配置 WebSocket 部署信息 + * 2. 在虚拟线程模式下使用虚拟线程池 + * 3. 禁用不安全的 HTTP 方法,如 CONNECT、TRACE、TRACK + *

+ * + * @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); + }); + }); + } + +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/ApiDecryptProperties.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/ApiDecryptProperties.java new file mode 100644 index 000000000..b44426226 --- /dev/null +++ b/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; + +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/CustomGatewayProperties.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/CustomGatewayProperties.java new file mode 100644 index 000000000..599603025 --- /dev/null +++ b/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; + +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/IgnoreWhiteProperties.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/config/properties/IgnoreWhiteProperties.java new file mode 100644 index 000000000..50d4799e5 --- /dev/null +++ b/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 whites = new ArrayList<>(); + +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/AuthFilter.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/AuthFilter.java new file mode 100644 index 000000000..9e9311ef5 --- /dev/null +++ b/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); + }); + } + +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/ForwardAuthFilter.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/ForwardAuthFilter.java new file mode 100644 index 000000000..f1ff79c95 --- /dev/null +++ b/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); + } +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/GlobalLogFilter.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/GlobalLogFilter.java new file mode 100644 index 000000000..033050afb --- /dev/null +++ b/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; + +/** + * 全局日志过滤器 + *

+ * 用于打印请求执行参数与响应时间等等 + * + * @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 parameterMap = UriComponentsBuilder.newInstance() + .query(request.getQueryString()) + .build() + .getQueryParams(); + if (MapUtil.isNotEmpty(parameterMap)) { + LinkedMultiValueMap 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 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); + } + } + } + +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/support/CachedBodyHttpServletRequest.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/support/CachedBodyHttpServletRequest.java new file mode 100644 index 000000000..47c5bec11 --- /dev/null +++ b/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); + } +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/support/MutableHttpServletRequest.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/filter/support/MutableHttpServletRequest.java new file mode 100644 index 000000000..41b34c320 --- /dev/null +++ b/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 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 getHeaders(String name) { + String value = customHeaders.get(name); + if (value != null) { + return Collections.enumeration(Collections.singletonList(value)); + } + return super.getHeaders(name); + } + + @Override + public Enumeration getHeaderNames() { + Set names = new LinkedHashSet<>(); + Enumeration headerNames = super.getHeaderNames(); + while (headerNames.hasMoreElements()) { + names.add(headerNames.nextElement()); + } + names.addAll(customHeaders.keySet()); + return new Vector<>(names).elements(); + } +} diff --git a/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/handler/GatewayExceptionHandler.java b/ruoyi-gateway-mvc/src/main/java/org/dromara/gateway/handler/GatewayExceptionHandler.java new file mode 100644 index 000000000..50b548c62 --- /dev/null +++ b/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 handleNotLogin(HttpServletRequest request, NotLoginException ex) { + log.warn("[网关认证失败]请求路径:{},异常信息:{}", request.getRequestURI(), ex.getMessage()); + return R.fail(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(Throwable.class) + public R 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); + } +} diff --git a/ruoyi-gateway-mvc/src/main/resources/application.yml b/ruoyi-gateway-mvc/src/main/resources/application.yml new file mode 100644 index 000000000..ef0e69ab0 --- /dev/null +++ b/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 diff --git a/ruoyi-gateway-mvc/src/main/resources/banner.txt b/ruoyi-gateway-mvc/src/main/resources/banner.txt new file mode 100644 index 000000000..ceced29f4 --- /dev/null +++ b/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 / | (_| || |_| | +|_| \__,_| \___/ \__, ||_| \__, | \__,_| \__| \___| \_/\_/ \__,_| \__, | + __/ | __/ | __/ | + |___/ |___/ |___/ \ No newline at end of file diff --git a/ruoyi-gateway-mvc/src/main/resources/logback-plus.xml b/ruoyi-gateway-mvc/src/main/resources/logback-plus.xml new file mode 100644 index 000000000..59cba6238 --- /dev/null +++ b/ruoyi-gateway-mvc/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 + + + + + + + + + + + + + + + + + diff --git a/script/config/nacos/ruoyi-gateway-mvc.yml b/script/config/nacos/ruoyi-gateway-mvc.yml new file mode 100644 index 000000000..3a9bc5f30 --- /dev/null +++ b/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