init: 导入RuoYi‑Vue‑Plus 6.X完整代码

This commit is contained in:
admin
2026-08-09 17:51:11 +08:00
commit 51cac459b3
914 changed files with 103833 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-common-web</artifactId>
<description>
ruoyi-common-web web服务
</description>
<dependencies>
<!-- 序列化模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-json</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 容器使用 jetty -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
<!-- 监控端点 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 图形验证码 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-captcha</artifactId>
</dependency>
<!-- Hutool 加密工具 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-crypto</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,42 @@
package org.dromara.common.web.advice;
import lombok.RequiredArgsConstructor;
import org.dromara.common.json.enhance.JsonValueEnhancer;
import org.jspecify.annotations.NonNull;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
/**
* 响应体统一增强拦截器。
*/
@RestControllerAdvice
@RequiredArgsConstructor
public class ResponseEnhancementAdvice implements ResponseBodyAdvice<Object> {
private final JsonValueEnhancer jsonValueEnhancer;
@Override
public boolean supports(@NonNull MethodParameter returnType,
@NonNull Class<? extends HttpMessageConverter<?>> converterType) {
return jsonValueEnhancer.supports(converterType);
}
@Override
public Object beforeBodyWrite(Object body,
@NonNull MethodParameter returnType,
@NonNull MediaType selectedContentType,
@NonNull Class<? extends HttpMessageConverter<?>> selectedConverterType,
@NonNull ServerHttpRequest request,
@NonNull ServerHttpResponse response) {
if (!selectedContentType.isCompatibleWith(MediaType.APPLICATION_JSON)) {
return body;
}
return jsonValueEnhancer.enhance(body);
}
}
@@ -0,0 +1,52 @@
package org.dromara.common.web.config;
import jakarta.servlet.DispatcherType;
import org.dromara.common.web.config.properties.XssProperties;
import org.dromara.common.web.filter.RepeatableFilter;
import org.dromara.common.web.filter.XssFilter;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistration;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
/**
* Filter配置
*
* @author Lion Li
*/
@AutoConfiguration
@EnableConfigurationProperties(XssProperties.class)
public class FilterConfig {
/**
* 注册 XSS 过滤器。
*
* @param xssProperties XSS 配置
* @return XSS 请求过滤器实例
*/
@Bean
@ConditionalOnProperty(value = "xss.enabled", havingValue = "true")
@FilterRegistration(
name = "xssFilter",
urlPatterns = "/*",
order = FilterRegistrationBean.HIGHEST_PRECEDENCE + 1,
dispatcherTypes = DispatcherType.REQUEST
)
public XssFilter xssFilter(XssProperties xssProperties) {
return new XssFilter(xssProperties);
}
/**
* 注册可重复读取请求体过滤器。
*
* @return 请求包装过滤器实例
*/
@Bean
@FilterRegistration(name = "repeatableFilter", urlPatterns = "/*")
public RepeatableFilter repeatableFilter() {
return new RepeatableFilter();
}
}
@@ -0,0 +1,27 @@
package org.dromara.common.web.config;
import org.dromara.common.web.core.I18nLocaleResolver;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.LocaleResolver;
/**
* 国际化配置
*
* @author Lion Li
*/
@AutoConfiguration(before = WebMvcAutoConfiguration.class)
public class I18nConfig {
/**
* 注册自定义国际化区域解析器。
*
* @return Locale 解析器实例
*/
@Bean
public LocaleResolver localeResolver() {
return new I18nLocaleResolver();
}
}
@@ -0,0 +1,109 @@
package org.dromara.common.web.config;
import cn.hutool.core.date.DateTime;
import org.dromara.common.core.utils.DateUtils;
import org.dromara.common.core.utils.ObjectUtils;
import org.dromara.common.json.enhance.JsonValueEnhancer;
import org.dromara.common.web.advice.ResponseEnhancementAdvice;
import org.dromara.common.web.config.properties.CorsProperties;
import org.dromara.common.web.handler.GlobalExceptionHandler;
import org.dromara.common.web.interceptor.PlusWebInvokeTimeInterceptor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.time.LocalDateTime;
import java.util.Date;
/**
* 通用配置
*
* @author Lion Li
*/
@AutoConfiguration
@EnableConfigurationProperties(CorsProperties.class)
public class ResourcesConfig implements WebMvcConfigurer {
/**
* 注册全局拦截器。
*
* @param registry 拦截器注册表
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 全局访问性能拦截
registry.addInterceptor(new PlusWebInvokeTimeInterceptor());
}
/**
* 注册全局格式转换器。
*
* @param registry 格式化器注册表
*/
@Override
public void addFormatters(FormatterRegistry registry) {
// 全局日期格式转换配置
registry.addConverter(String.class, Date.class, source -> {
DateTime parse = DateUtils.parse(source);
if (ObjectUtils.isNull(parse)) {
return null;
}
return parse.toJdkDate();
});
registry.addConverter(String.class, LocalDateTime.class, source -> {
DateTime parse = DateUtils.parse(source);
if (ObjectUtils.isNull(parse)) {
return null;
}
return parse.toLocalDateTime();
});
}
/**
* 跨域配置
*
* @return 全局 Cors 过滤器
*/
@Bean
public CorsFilter corsFilter(CorsProperties corsProperties) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(corsProperties.getAllowCredentials());
config.setAllowedOriginPatterns(corsProperties.getAllowedOriginPatterns());
config.setAllowedHeaders(corsProperties.getAllowedHeaders());
config.setAllowedMethods(corsProperties.getAllowedMethods());
config.setMaxAge(corsProperties.getMaxAge());
// 添加映射路径,拦截一切请求
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
// 返回新的CorsFilter
return new CorsFilter(source);
}
/**
* 全局异常处理器
*
* @return 全局异常处理器实例
*/
@Bean
public GlobalExceptionHandler globalExceptionHandler() {
return new GlobalExceptionHandler();
}
/**
* 注册响应增强处理器。
*
* @param jsonValueEnhancer JSON 字段增强器
* @return 响应增强处理器
*/
@Bean
public ResponseEnhancementAdvice responseEnhancementAdvice(JsonValueEnhancer jsonValueEnhancer) {
return new ResponseEnhancementAdvice(jsonValueEnhancer);
}
}
@@ -0,0 +1,41 @@
package org.dromara.common.web.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/**
* 跨域配置属性。
*/
@Data
@ConfigurationProperties(prefix = "web.cors")
public class CorsProperties {
/**
* 是否允许携带凭证。
*/
private Boolean allowCredentials = true;
/**
* 允许的来源匹配规则。
*/
private List<String> allowedOriginPatterns = new ArrayList<>(List.of("*"));
/**
* 允许的请求头。
*/
private List<String> allowedHeaders = new ArrayList<>(List.of("*"));
/**
* 允许的请求方法。
*/
private List<String> allowedMethods = new ArrayList<>(List.of("*"));
/**
* 预检请求缓存时间,单位秒。
*/
private Long maxAge = 1800L;
}
@@ -0,0 +1,28 @@
package org.dromara.common.web.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/**
* XSS 过滤配置属性,用于控制过滤器开关及排除路径。
*
* @author Lion Li
*/
@Data
@ConfigurationProperties(prefix = "xss")
public class XssProperties {
/**
* XSS 过滤总开关。
*/
private Boolean enabled;
/**
* 跳过 XSS 过滤的请求路径集合。
*/
private List<String> excludeUrls = new ArrayList<>();
}
@@ -0,0 +1,43 @@
package org.dromara.common.web.core;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.StringUtils;
/**
* web层通用数据处理
*
* @author Lion Li
*/
public class BaseController {
/**
* 响应返回结果
*
* @param rows 影响行数
* @return 操作结果
*/
protected R<Void> toAjax(int rows) {
return rows > 0 ? R.ok() : R.fail();
}
/**
* 响应返回结果
*
* @param result 结果
* @return 操作结果
*/
protected R<Void> toAjax(boolean result) {
return result ? R.ok() : R.fail();
}
/**
* 页面跳转
*
* @param url 目标跳转地址
* @return Spring MVC 重定向路径表达式
*/
public String redirect(String url) {
return StringUtils.format("redirect:{}", url);
}
}
@@ -0,0 +1,43 @@
package org.dromara.common.web.core;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.LocaleResolver;
import java.util.Locale;
/**
* 基于请求头解析国际化区域信息的语言解析器。
*
* @author Lion Li
*/
public class I18nLocaleResolver implements LocaleResolver {
/**
* 从请求头 {@code content-language} 中解析本次请求的区域信息,缺省时回退到系统默认区域。
*
* @param httpServletRequest 当前请求
* @return 当前请求对应的区域设置
*/
@Override
public Locale resolveLocale(HttpServletRequest httpServletRequest) {
String language = httpServletRequest.getHeader("content-language");
Locale locale = Locale.getDefault();
if (language != null && !language.isEmpty()) {
locale = Locale.forLanguageTag(language.replace('_', '-'));
}
return locale;
}
/**
* 当前项目不在服务端主动切换区域信息,因此保留空实现。
*
* @param httpServletRequest 当前请求
* @param httpServletResponse 当前响应
* @param locale 目标区域
*/
@Override
public void setLocale(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Locale locale) {
}
}
@@ -0,0 +1,265 @@
package org.dromara.common.web.core;
import cn.hutool.captcha.AbstractCaptcha;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.captcha.generator.RandomGenerator;
import cn.hutool.core.img.GraphicsUtil;
import cn.hutool.core.img.ImgUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.RandomUtil;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.Serial;
import java.util.concurrent.ThreadLocalRandom;
/**
* 带干扰线、波浪和圆形干扰元素的验证码实现,用于增强验证码识别难度。
*
* @author Lion Li
*/
public class WaveAndCircleCaptcha extends AbstractCaptcha {
@Serial
private static final long serialVersionUID = 1L;
/**
* 构造默认长度为 4 的验证码。
*
* @param width 图片宽度
* @param height 图片高度
*/
public WaveAndCircleCaptcha(int width, int height) {
this(width, height, 4);
}
/**
* 构造指定验证码长度的验证码对象。
*
* @param width 图片宽度
* @param height 图片高度
* @param codeCount 验证码字符数
*/
public WaveAndCircleCaptcha(int width, int height, int codeCount) {
this(width, height, codeCount, 6);
}
/**
* 构造指定字符数与干扰数的验证码对象。
*
* @param width 图片宽度
* @param height 图片高度
* @param codeCount 验证码字符数
* @param interfereCount 干扰元素数量
*/
public WaveAndCircleCaptcha(int width, int height, int codeCount, int interfereCount) {
this(width, height, new RandomGenerator(codeCount), interfereCount);
}
/**
* 使用指定验证码生成器构造验证码对象。
*
* @param width 图片宽度
* @param height 图片高度
* @param generator 验证码生成器
* @param interfereCount 干扰元素数量
*/
public WaveAndCircleCaptcha(int width, int height, CodeGenerator generator, int interfereCount) {
super(width, height, generator, interfereCount);
}
/**
* 构造带字体缩放比例的验证码对象。
*
* @param width 图片宽度
* @param height 图片高度
* @param codeCount 验证码字符数
* @param interfereCount 干扰元素数量
* @param size 字体相对尺寸
*/
public WaveAndCircleCaptcha(int width, int height, int codeCount, int interfereCount, float size) {
super(width, height, new RandomGenerator(codeCount), interfereCount, size);
}
/**
* 生成验证码图片并绘制文字、扭曲效果及干扰图形。
*
* @param code 验证码文本
* @return 生成后的验证码图片
*/
@Override
public Image createImage(String code) {
final BufferedImage image = new BufferedImage(
width,
height,
(null == this.background) ? BufferedImage.TYPE_4BYTE_ABGR : BufferedImage.TYPE_INT_RGB
);
final Graphics2D g = ImgUtil.createGraphics(image, this.background);
try {
drawString(g, code);
// 扭曲
shear(g, this.width, this.height, ObjectUtil.defaultIfNull(this.background, Color.WHITE));
drawInterfere(g);
} finally {
g.dispose();
}
return image;
}
/**
* 绘制验证码文本并开启文字抗锯齿。
*
* @param g 图形上下文
* @param code 验证码文本
*/
private void drawString(Graphics2D g, String code) {
// 设置抗锯齿(让字体渲染更清晰)
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
if (this.textAlpha != null) {
g.setComposite(this.textAlpha);
}
GraphicsUtil.drawStringColourful(g, code, this.font, this.width, this.height);
}
/**
* 绘制圆形与波浪线干扰元素。
*
* @param g 图形上下文
*/
protected void drawInterfere(Graphics2D g) {
ThreadLocalRandom random = RandomUtil.getRandom();
int circleCount = Math.max(0, this.interfereCount - 1);
// 圈圈
for (int i = 0; i < circleCount; i++) {
g.setColor(ImgUtil.randomColor(random));
int x = random.nextInt(width);
int y = random.nextInt(height);
int w = random.nextInt(height >> 1);
int h = random.nextInt(height >> 1);
g.drawOval(x, y, w, h);
}
// 仅 1 条平滑波浪线
if (this.interfereCount >= 1) {
g.setColor(getRandomColor(120, 230, random));
drawSmoothWave(g, random);
}
}
/**
* 绘制平滑波浪线干扰轨迹。
*
* @param g 图形上下文
* @param random 随机数生成器
*/
private void drawSmoothWave(Graphics2D g, ThreadLocalRandom random) {
int amplitude = random.nextInt(8) + 5; // 波动幅度
int wavelength = random.nextInt(40) + 30; // 波长
double phase = random.nextDouble() * Math.PI * 2;
// ✅ 关键:限制 baseY 在中间区域
int centerY = height / 2;
int verticalJitter = Math.max(5, height / 6); // 至少偏移5像素
int baseY = centerY - verticalJitter + random.nextInt(verticalJitter * 2);
g.setStroke(new BasicStroke(2.5f)); // 线宽
int[] xPoints = new int[width];
int[] yPoints = new int[width];
for (int x = 0; x < width; x++) {
int y = baseY + (int) (amplitude * Math.sin((double) x / wavelength * 2 * Math.PI + phase));
// 限制 y 不要超出图像边界(可选)
y = Math.max(amplitude, Math.min(y, height - amplitude));
xPoints[x] = x;
yPoints[x] = y;
}
g.drawPolyline(xPoints, yPoints, width);
}
/**
* 生成指定 RGB 范围内的随机颜色。
*
* @param min 最小颜色值
* @param max 最大颜色值
* @param random 随机数生成器
* @return 随机颜色
*/
private Color getRandomColor(int min, int max, ThreadLocalRandom random) {
int range = max - min;
return new Color(
min + random.nextInt(range),
min + random.nextInt(range),
min + random.nextInt(range)
);
}
/**
* 扭曲
*
* @param g {@link Graphics}
* @param w1 w1
* @param h1 h1
* @param color 颜色
*/
private void shear(Graphics g, int w1, int h1, Color color) {
shearX(g, w1, h1, color);
shearY(g, w1, h1, color);
}
/**
* X坐标扭曲
*
* @param g {@link Graphics}
* @param w1 宽
* @param h1 高
* @param color 颜色
*/
private void shearX(Graphics g, int w1, int h1, Color color) {
int period = RandomUtil.randomInt(this.width);
int frames = 1;
int phase = RandomUtil.randomInt(2);
for (int i = 0; i < h1; i++) {
double d = (double) (period >> 1) * Math.sin((double) i / (double) period + (6.2831853071795862D * (double) phase) / (double) frames);
g.copyArea(0, i, w1, 1, (int) d, 0);
g.setColor(color);
g.drawLine((int) d, i, 0, i);
g.drawLine((int) d + w1, i, w1, i);
}
}
/**
* Y坐标扭曲
*
* @param g {@link Graphics}
* @param w1 宽
* @param h1 高
* @param color 颜色
*/
private void shearY(Graphics g, int w1, int h1, Color color) {
int period = RandomUtil.randomInt(this.height >> 1);
int frames = 20;
int phase = 7;
for (int i = 0; i < w1; i++) {
double d = (double) (period >> 1) * Math.sin((double) i / (double) period + (6.2831853071795862D * (double) phase) / (double) frames);
g.copyArea(i, 0, 1, h1, 0, (int) d);
g.setColor(color);
// 擦除原位置的痕迹
g.drawLine(i, (int) d, i, 0);
g.drawLine(i, (int) d + h1, i, h1);
}
}
}
@@ -0,0 +1,59 @@
package org.dromara.common.web.filter;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.http.MediaType;
import java.io.IOException;
/**
* 可重复读取请求体的过滤器,仅对 JSON 请求包装可重复消费的请求对象。
*
* @author ruoyi
*/
public class RepeatableFilter implements Filter {
/**
* 过滤器初始化入口,当前无额外初始化逻辑。
*
* @param filterConfig 过滤器配置
* @throws ServletException 过滤器初始化异常
*/
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
/**
* 为 JSON 请求创建可重复读取的包装器,便于日志、验签等场景多次读取请求体。
*
* @param request 原始请求
* @param response 当前响应
* @param chain 过滤器链
* @throws IOException IO 异常
* @throws ServletException Servlet 异常
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
ServletRequest requestWrapper = null;
if (request instanceof HttpServletRequest
&& StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) {
requestWrapper = new RepeatedlyRequestWrapper((HttpServletRequest) request, response);
}
if (null == requestWrapper) {
chain.doFilter(request, response);
} else {
chain.doFilter(requestWrapper, response);
}
}
/**
* 过滤器销毁入口,当前无额外资源需要释放。
*/
@Override
public void destroy() {
}
}
@@ -0,0 +1,117 @@
package org.dromara.common.web.filter;
import cn.hutool.core.io.IoUtil;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.dromara.common.core.constant.Constants;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* 构建可重复读取输入流的请求包装器,缓存请求体以支持多次读取。
*
* @author ruoyi
*/
public class RepeatedlyRequestWrapper extends HttpServletRequestWrapper {
/**
* 请求体字节数据。
*/
private final byte[] body;
/**
* 读取原始请求体并缓存到内存,统一设置请求与响应编码。
*
* @param request 原始请求
* @param response 当前响应
* @throws IOException 读取请求体异常
*/
public RepeatedlyRequestWrapper(HttpServletRequest request, ServletResponse response) throws IOException {
super(request);
request.setCharacterEncoding(Constants.UTF8);
response.setCharacterEncoding(Constants.UTF8);
body = IoUtil.readBytes(request.getInputStream(), false);
}
/**
* 基于缓存的请求体构造字符读取器。
*
* @return 可重复读取的字符流
* @throws IOException IO 异常
*/
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
}
/**
* 返回基于缓存请求体重新生成的输入流。
*
* @return 可重复读取的输入流
* @throws IOException IO 异常
*/
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream bais = new ByteArrayInputStream(body);
return new ServletInputStream() {
/**
* 读取缓存请求体的下一个字节。
*
* @return 下一个字节
* @throws IOException IO 异常
*/
@Override
public int read() throws IOException {
return bais.read();
}
/**
* 返回缓存请求体剩余可读字节数。
*
* @return 剩余字节数
* @throws IOException IO 异常
*/
@Override
public int available() throws IOException {
return bais.available();
}
/**
* 判断缓存请求体是否已读取完毕。
*
* @return 是否读取完毕
*/
@Override
public boolean isFinished() {
return bais.available() == 0;
}
/**
* 判断输入流是否可读。
*
* @return 固定为 true
*/
@Override
public boolean isReady() {
return true;
}
/**
* 设置异步读取监听器。
*
* @param readListener 读取监听器
*/
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
}
@@ -0,0 +1,88 @@
package org.dromara.common.web.filter;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.web.config.properties.XssProperties;
import org.springframework.http.HttpMethod;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 防止 XSS 攻击的过滤器,对非排除请求执行参数与请求体清洗。
*
* @author ruoyi
*/
@RequiredArgsConstructor
public class XssFilter implements Filter {
/**
* 跳过 XSS 过滤的请求路径集合。
*/
private final List<String> excludes = new ArrayList<>();
private final XssProperties properties;
/**
* 初始化过滤器并加载配置中的排除路径。
*
* @param filterConfig 过滤器配置
* @throws ServletException 过滤器初始化异常
*/
@Override
public void init(FilterConfig filterConfig) throws ServletException {
if (properties.getExcludeUrls() != null) {
excludes.addAll(properties.getExcludeUrls());
}
}
/**
* 对请求执行 XSS 包装处理,命中排除规则时直接放行。
*
* @param request 原始请求
* @param response 当前响应
* @param chain 过滤器链
* @throws IOException IO 异常
* @throws ServletException Servlet 异常
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
if (handleExcludeURL(req, resp)) {
chain.doFilter(request, response);
return;
}
XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper((HttpServletRequest) request);
chain.doFilter(xssRequest, response);
}
/**
* 判断当前请求是否需要跳过 XSS 过滤。
*
* @param request 当前请求
* @param response 当前响应
* @return true 表示跳过过滤
*/
private boolean handleExcludeURL(HttpServletRequest request, HttpServletResponse response) {
String url = request.getServletPath();
String method = request.getMethod();
// GET DELETE 不过滤
if (method == null || HttpMethod.GET.matches(method) || HttpMethod.DELETE.matches(method)) {
return true;
}
return StringUtils.matches(url, excludes);
}
/**
* 过滤器销毁入口,当前无额外资源需要释放。
*/
@Override
public void destroy() {
}
}
@@ -0,0 +1,189 @@
package org.dromara.common.web.filter;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HtmlUtil;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* XSS 请求包装器,统一清洗参数与 JSON 请求体中的 HTML 标签内容。
*
* @author ruoyi
*/
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
/**
* 使用原始请求构造 XSS 包装器。
*
* @param request 原始请求
*/
public XssHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}
/**
* 获取并清洗单个请求参数。
*
* @param name 参数名
* @return 清洗后的参数值
*/
@Override
public String getParameter(String name) {
String value = super.getParameter(name);
if (value == null) {
return null;
}
return HtmlUtil.cleanHtmlTag(value).trim();
}
/**
* 获取并清洗整组请求参数。
*
* @return 清洗后的参数映射
*/
@Override
public Map<String, String[]> getParameterMap() {
Map<String, String[]> valueMap = super.getParameterMap();
if (MapUtil.isEmpty(valueMap)) {
return valueMap;
}
// 避免某些容器不允许改参数的情况 copy一份重新改
Map<String, String[]> map = new HashMap<>(valueMap.size());
map.putAll(valueMap);
for (Map.Entry<String, String[]> entry : map.entrySet()) {
String[] values = entry.getValue();
if (values != null) {
int length = values.length;
String[] escapseValues = new String[length];
for (int i = 0; i < length; i++) {
// 防xss攻击和过滤前后空格
escapseValues[i] = HtmlUtil.cleanHtmlTag(values[i]).trim();
}
map.put(entry.getKey(), escapseValues);
}
}
return map;
}
/**
* 获取并清洗指定参数的多值数组。
*
* @param name 参数名
* @return 清洗后的参数值数组
*/
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (ArrayUtil.isEmpty(values)) {
return values;
}
int length = values.length;
String[] escapseValues = new String[length];
for (int i = 0; i < length; i++) {
// 防xss攻击和过滤前后空格
escapseValues[i] = HtmlUtil.cleanHtmlTag(values[i]).trim();
}
return escapseValues;
}
/**
* 获取输入流并在 JSON 场景下对请求体执行清洗。
*
* @return 清洗后的输入流
* @throws IOException 读取请求体异常
*/
@Override
public ServletInputStream getInputStream() throws IOException {
// 非json类型,直接返回
if (!isJsonRequest()) {
return super.getInputStream();
}
// 为空,直接返回
String json = StrUtil.str(IoUtil.readBytes(super.getInputStream(), false), StandardCharsets.UTF_8);
if (StringUtils.isEmpty(json)) {
return super.getInputStream();
}
// xss过滤
json = HtmlUtil.cleanHtmlTag(json).trim();
byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
final ByteArrayInputStream bis = IoUtil.toStream(jsonBytes);
return new ServletInputStream() {
/**
* 判断清洗后的 JSON 流是否已读取完毕。
*
* @return 是否读取完毕
*/
@Override
public boolean isFinished() {
return true;
}
/**
* 判断清洗后的 JSON 流是否可读。
*
* @return 固定为 true
*/
@Override
public boolean isReady() {
return true;
}
/**
* 返回清洗后的 JSON 字节数。
*
* @return JSON 字节数
* @throws IOException IO 异常
*/
@Override
public int available() throws IOException {
return jsonBytes.length;
}
/**
* 设置异步读取监听器。
*
* @param readListener 读取监听器
*/
@Override
public void setReadListener(ReadListener readListener) {
}
/**
* 读取清洗后的 JSON 流下一个字节。
*
* @return 下一个字节
* @throws IOException IO 异常
*/
@Override
public int read() throws IOException {
return bis.read();
}
};
}
/**
* 判断当前请求是否为 JSON 请求。
*
* @return true 表示 JSON 请求
*/
public boolean isJsonRequest() {
String header = super.getHeader(HttpHeaders.CONTENT_TYPE);
return StringUtils.startsWithIgnoreCase(header, MediaType.APPLICATION_JSON_VALUE);
}
}
@@ -0,0 +1,240 @@
package org.dromara.common.web.handler;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.http.HttpStatus;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.SseException;
import org.dromara.common.core.exception.base.BaseException;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.boot.json.JsonParseException;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.expression.ExpressionException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingPathVariableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;
import java.io.IOException;
/**
* 全局异常处理器
*
* @author Lion Li
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 请求方式不支持
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public R<Void> handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException e,
HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求地址'{}',不支持'{}'请求", requestURI, e.getMethod());
return R.fail(HttpStatus.HTTP_BAD_METHOD, e.getMessage());
}
/**
* 业务异常
*/
@ExceptionHandler(ServiceException.class)
public R<Void> handleServiceException(ServiceException e, HttpServletRequest request) {
log.error(e.getMessage());
Integer code = e.getCode();
return ObjectUtil.isNotNull(code) ? R.fail(code, e.getMessage()) : R.fail(e.getMessage());
}
/**
* 认证失败
*/
@ResponseStatus(org.springframework.http.HttpStatus.UNAUTHORIZED)
@ExceptionHandler(SseException.class)
public String handleNotLoginException(SseException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.debug("请求地址'{}',认证失败'{}',无法访问系统资源", requestURI, e.getMessage());
return JsonUtils.toJsonString(R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源"));
}
/**
* servlet异常
*/
@ExceptionHandler(ServletException.class)
public R<Void> handleServletException(ServletException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求地址'{}',发生未知异常.", requestURI, e);
return R.fail("发生未知异常,请联系管理员");
}
/**
* 业务异常
*/
@ExceptionHandler(BaseException.class)
public R<Void> handleBaseException(BaseException e, HttpServletRequest request) {
log.error(e.getMessage());
return R.fail(e.getMessage());
}
/**
* 请求路径中缺少必需的路径变量
*/
@ExceptionHandler(MissingPathVariableException.class)
public R<Void> handleMissingPathVariableException(MissingPathVariableException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求路径中缺少必需的路径变量'{}',发生系统异常.", requestURI);
return R.fail(String.format("请求路径中缺少必需的路径变量[%s]", e.getVariableName()));
}
/**
* 请求参数类型不匹配
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public R<Void> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求参数类型不匹配'{}',发生系统异常.", requestURI);
return R.fail(String.format("请求参数类型不匹配,参数[%s]要求类型为:'%s',但输入值为:'%s'", e.getName(), e.getRequiredType().getName(), e.getValue()));
}
/**
* 找不到路由
*/
@ExceptionHandler(NoHandlerFoundException.class)
public R<Void> handleNoHandlerFoundException(NoHandlerFoundException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求地址'{}'不存在.", requestURI);
return R.fail(HttpStatus.HTTP_NOT_FOUND, "请求地址不存在");
}
/**
* 拦截未知的运行时异常
*/
@ResponseStatus(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(IOException.class)
public void handleIoException(IOException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
String path = SpringUtils.getProperty("message.path");
if (requestURI.contains(path)) {
// sse 经常性连接中断 例如关闭浏览器 直接屏蔽
return;
}
log.error("请求地址'{}',连接中断", requestURI, e);
}
/**
* sse 连接超时异常 不需要处理
*/
@ExceptionHandler(AsyncRequestTimeoutException.class)
public void handleRuntimeException(AsyncRequestTimeoutException e) {
}
/**
* 拦截未知的运行时异常
*/
@ExceptionHandler(RuntimeException.class)
public R<Void> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
String errorId = RandomUtil.randomNumbers(8);
log.error("请求地址'{}',发生未知异常, 错误编号: {}", requestURI, errorId, e);
return R.fail("发生未知异常,请联系管理员 [错误编号: " + errorId + "]");
}
/**
* 系统异常
*/
@ExceptionHandler(Exception.class)
public R<Void> handleException(Exception e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
String errorId = RandomUtil.randomNumbers(8);
log.error("请求地址'{}',发生系统异常, 错误编号: {}", requestURI, errorId, e);
return R.fail("发生系统异常,请联系管理员 [错误编号: " + errorId + "]");
}
/**
* 自定义验证异常
*/
@ExceptionHandler(BindException.class)
public R<Void> handleBindException(BindException e) {
log.error(e.getMessage());
String message = StreamUtils.join(e.getAllErrors(), DefaultMessageSourceResolvable::getDefaultMessage, ", ");
return R.fail(message);
}
/**
* 自定义验证异常
*/
@ExceptionHandler(ConstraintViolationException.class)
public R<Void> constraintViolationException(ConstraintViolationException e) {
log.error(e.getMessage());
String message = StreamUtils.join(e.getConstraintViolations(), ConstraintViolation::getMessage, ", ");
return R.fail(message);
}
/**
* 自定义验证异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public R<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.error(e.getMessage());
String message = StreamUtils.join(e.getBindingResult().getAllErrors(), DefaultMessageSourceResolvable::getDefaultMessage, ", ");
return R.fail(message);
}
/**
* 方法参数校验异常 用于处理 @Validated 注解
*/
@ExceptionHandler(HandlerMethodValidationException.class)
public R<Void> handlerMethodValidationException(HandlerMethodValidationException e) {
log.error(e.getMessage());
String message = StreamUtils.join(e.getAllErrors(), MessageSourceResolvable::getDefaultMessage, ", ");
return R.fail(message);
}
/**
* JSON 解析异常(Jackson 在处理 JSON 格式出错时抛出)
* 可能是请求体格式非法,也可能是服务端反序列化失败
*/
@ExceptionHandler(JsonParseException.class)
public R<Void> handleJsonParseException(JsonParseException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求地址'{}' 发生 JSON 解析异常: {}", requestURI, e.getMessage());
return R.fail(HttpStatus.HTTP_BAD_REQUEST, "请求数据格式错误");
}
/**
* 请求体读取异常(通常是请求参数格式非法、字段类型不匹配等)
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public R<Void> handleHttpMessageNotReadableException(HttpMessageNotReadableException e, HttpServletRequest request) {
log.error("请求地址'{}', 参数解析失败: {}", request.getRequestURI(), e.getMessage());
return R.fail(HttpStatus.HTTP_BAD_REQUEST, "请求参数格式错误");
}
/**
* SpEL 表达式相关异常
*/
@ExceptionHandler(ExpressionException.class)
public R<Void> handleSpelException(ExpressionException e, HttpServletRequest request) {
log.error("请求地址'{}'SpEL解析异常: {}", request.getRequestURI(), e.getMessage());
return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, "SpEL解析失败:" + e.getMessage());
}
}
@@ -0,0 +1,140 @@
package org.dromara.common.web.interceptor;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ObjectUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.time.StopWatch;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.web.filter.RepeatedlyRequestWrapper;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.HandlerInterceptor;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Web 调用时间统计拦截器,同时记录请求参数并对敏感字段做脱敏处理。
*
* @author Lion Li
* @since 3.3.0
*/
@Slf4j
public class PlusWebInvokeTimeInterceptor implements HandlerInterceptor {
private final static ThreadLocal<StopWatch> KEY_CACHE = new ThreadLocal<>();
/**
* 请求参数日志最大长度。
*/
private static final int MAX_PARAM_LOG_LENGTH = 4000;
/**
* 请求进入控制器前记录入参并启动耗时统计。
*
* @param request 当前请求
* @param response 当前响应
* @param handler 目标处理器
* @return 始终返回 true,继续后续处理流程
* @throws Exception 读取请求体或解析 JSON 失败时抛出
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String url = request.getMethod() + " " + request.getRequestURI();
// 打印请求参数
if (isJsonRequest(request)) {
String jsonParam = "";
if (request instanceof RepeatedlyRequestWrapper) {
jsonParam = IoUtil.read(request.getReader());
if (StringUtils.isNotBlank(jsonParam)) {
jsonParam = sanitizeJsonParam(jsonParam);
}
}
log.info("[PLUS]开始请求 => URL[{}],参数类型[json],参数:[{}]", url, limit(jsonParam));
} else {
Map<String, String[]> parameterMap = request.getParameterMap();
if (MapUtil.isNotEmpty(parameterMap)) {
Map<String, String[]> map = new LinkedHashMap<>(parameterMap);
MapUtil.removeAny(map, SystemConstants.EXCLUDE_PROPERTIES);
String parameters = JsonUtils.toJsonString(map);
log.info("[PLUS]开始请求 => URL[{}],参数类型[param],参数:[{}]", url, limit(parameters));
} else {
log.info("[PLUS]开始请求 => URL[{}],无参数", url);
}
}
StopWatch stopWatch = new StopWatch();
KEY_CACHE.set(stopWatch);
stopWatch.start();
return true;
}
/**
* 清洗 JSON 请求参数日志,解析失败时不影响主请求。
*
* @param jsonParam 原始 JSON 字符串
* @return 清洗后的参数日志
*/
private String sanitizeJsonParam(String jsonParam) {
try {
JsonMapper jsonMapper = JsonUtils.getJsonMapper();
JsonNode rootNode = jsonMapper.readTree(jsonParam);
JsonUtils.removeFields(rootNode, SystemConstants.EXCLUDE_PROPERTIES);
return rootNode.toString();
} catch (Exception e) {
log.debug("[PLUS]请求参数 JSON 解析失败,跳过结构化脱敏: {}", e.getMessage());
return jsonParam;
}
}
/**
* 限制日志字段长度。
*
* @param value 原始字符串
* @return 截断后的字符串
*/
private String limit(String value) {
return StringUtils.substring(value, 0, MAX_PARAM_LOG_LENGTH);
}
/**
* 请求完成后输出最终耗时,并清理线程内缓存的计时器。
*
* @param request 当前请求
* @param response 当前响应
* @param handler 目标处理器
* @param ex 请求处理过程中的异常
* @throws Exception 拦截器链路抛出的异常
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
StopWatch stopWatch = KEY_CACHE.get();
if (ObjectUtil.isNotNull(stopWatch)) {
stopWatch.stop();
log.info("[PLUS]结束请求 => URL[{}],耗时:[{}]毫秒", request.getMethod() + " " + request.getRequestURI(), stopWatch.getDuration().toMillis());
KEY_CACHE.remove();
}
}
/**
* 判断本次请求的数据类型是否为json
*
* @param request request
* @return boolean
*/
private boolean isJsonRequest(HttpServletRequest request) {
String contentType = request.getContentType();
if (contentType != null) {
return StringUtils.startsWithIgnoreCase(contentType, MediaType.APPLICATION_JSON_VALUE);
}
return false;
}
}
@@ -0,0 +1,3 @@
org.dromara.common.web.config.FilterConfig
org.dromara.common.web.config.I18nConfig
org.dromara.common.web.config.ResourcesConfig