diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MultipartFileCheckUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MultipartFileCheckUtils.java new file mode 100644 index 000000000..68fe6ed14 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MultipartFileCheckUtils.java @@ -0,0 +1,229 @@ +package org.dromara.common.core.utils; + +import org.springframework.web.multipart.MultipartFile; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 文件上传属性检查工具类 + *

+ * 功能: + * 1. 判断类及其父类中是否包含 MultipartFile 类型的属性。 + * 2. 支持 MultipartFile 数组 (MultipartFile[]) 判断。 + * 3. 支持集合泛型判断 (如 List, Set)。 + * 4. 使用 ConcurrentHashMap 缓存反射结果,提升高频调用时的性能。 + * 5. 兼容 JDK 21 特性(如 Record,Record 的组件会被视为字段处理)。 + * + * @author Your Name + * @since JDK 21 + */ +public class MultipartFileCheckUtils { + + /** + * 缓存:Key 为类对象,Value 为是否包含 MultipartFile + * 使用 ConcurrentHashMap 保证线程安全 + */ + private static final Map, Boolean> CACHE = new ConcurrentHashMap<>(); + + /** + * 私有构造,防止实例化 + */ + private MultipartFileCheckUtils() { + } + + /** + * 检查指定类是否包含 MultipartFile 属性(包含父类递归检查) + *

+ * 此方法会自动使用缓存,重复调用同一 Class 时性能极高。 + * + * @param clazz 目标类对象 + * @return true 如果包含 MultipartFile(直接、数组或集合泛型),否则 false + */ + public static boolean containsMultipartFile(Class clazz) { + if (clazz == null) { + return false; + } + // computeIfAbsent: 如果缓存中有则直接返回,没有则执行 checkClassHierarchy 并存入缓存 + return CACHE.computeIfAbsent(clazz, MultipartFileCheckUtils::checkClassHierarchy); + } + + /** + * 检查指定类是否包含 MultipartFile 属性(包含父类递归检查) + *

+ * 可指定是否跳过缓存(通常用于开发调试或热更新场景) + * + * @param clazz 目标类对象 + * @param useCache 是否使用缓存 + * @return true 如果包含,否则 false + */ + public static boolean containsMultipartFile(Class clazz, boolean useCache) { + if (clazz == null) { + return false; + } + if (useCache) { + return CACHE.computeIfAbsent(clazz, MultipartFileCheckUtils::checkClassHierarchy); + } else { + // 不使用缓存,直接执行检查 + return checkClassHierarchy(clazz); + } + } + + /** + * 核心逻辑:递归检查类继承体系 + * + * @param clazz 目标类 + * @return 检查结果 + */ + private static boolean checkClassHierarchy(Class clazz) { + Class currentClass = clazz; + // 循环遍历当前类及其所有父类,直到 Object 类 + while (currentClass != null && currentClass != Object.class) { + // 检查当前类的所有声明的字段 + if (hasMultipartFileField(currentClass)) { + return true; + } + // 移动到父类 + currentClass = currentClass.getSuperclass(); + } + return false; + } + + /** + * 检查单个类的所有字段 + * + * @param clazz 目标类 + * @return true 如果该类中存在符合条件的字段 + */ + private static boolean hasMultipartFileField(Class clazz) { + // 获取该类声明的所有字段(public, private, protected 等) + // 注意:getDeclaredFields 不会包含父类字段,父类由外部循环处理 + Field[] fields = clazz.getDeclaredFields(); + + // JDK 21+ 推荐使用 Stream 流式处理,代码更简洁 + return Arrays.stream(fields) + .anyMatch(MultipartFileCheckUtils::isMultipartFileField); + } + + /** + * 判断单个字段是否为 MultipartFile 类型(包括数组、集合等复杂情况) + * + * @param field 待检查的字段 + * @return true 如果是文件上传字段 + */ + private static boolean isMultipartFileField(Field field) { + Class fieldType = field.getType(); + + // 1. 基础检查:直接是 MultipartFile 类型 + if (MultipartFile.class.isAssignableFrom(fieldType)) { + return true; + } + + // 2. 数组检查:是否是 MultipartFile[] + if (fieldType.isArray()) { + Class componentType = fieldType.getComponentType(); + if (MultipartFile.class.isAssignableFrom(componentType)) { + return true; + } + } + + // 3. 集合/接口检查:如 List, Set, Collection + // 注意:不直接检查 Map,因为 Spring MVC 通常用 List/Array 接收多文件 + if (Iterable.class.isAssignableFrom(fieldType) || Collection.class.isAssignableFrom(fieldType)) { + return isGenericParameterMultipartFile(field); + } + + return false; + } + + /** + * 检查集合字段的泛型参数是否为 MultipartFile + *

+ * 例如:private List files; + * 我们需要获取 List 尖括号里的类型,判断它是不是 MultipartFile + * + * @param field 字段 + * @return true 如果泛型类型是 MultipartFile + */ + private static boolean isGenericParameterMultipartFile(Field field) { + Type genericType = field.getGenericType(); + + // 必须是参数化类型(即带泛型的类型,如 List) + if (genericType instanceof ParameterizedType parameterizedType) { + // 获取实际的泛型参数数组,例如 List -> [String.class] + Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + + if (actualTypeArguments.length > 0) { + // 取第一个泛型参数(通常集合只有一个参数) + Type firstArg = actualTypeArguments[0]; + + // 处理通配符情况(虽然少见,但在反射中可能出现) + // 这里简化处理,只判断 Class 对象 + if (firstArg instanceof Class rawType) { + return MultipartFile.class.isAssignableFrom(rawType); + } + } + } + + return false; + } + + // ======================== 测试用例 ======================== + + public static void main(String[] args) { + System.out.println("=== 测试开始 ==="); + + // 1. 测试普通对象 + System.out.println("SimpleDTO: " + containsMultipartFile(SimpleDTO.class)); // true + + // 2. 测试数组对象 + System.out.println("ArrayDTO: " + containsMultipartFile(ArrayDTO.class)); // true + + // 3. 测试集合泛型对象 + System.out.println("ListDTO: " + containsMultipartFile(ListDTO.class)); // true + + // 4. 测试不包含文件的对象 + System.out.println("NoneFileDTO: " + containsMultipartFile(NoneFileDTO.class)); // false + + // 5. 测试继承关系 + System.out.println("ChildDTO (继承自 SimpleDTO): " + containsMultipartFile(ChildDTO.class)); // true + + // 6. 性能测试(验证缓存) + long start = System.nanoTime(); + for (int i = 0; i < 10000; i++) { + containsMultipartFile(SimpleDTO.class); + } + long end = System.nanoTime(); + System.out.println("10000次调用耗时 (毫秒): " + (end - start) / 1_000_000); + } + + // ======================== 模拟测试类 ======================== + + static class SimpleDTO { + private String name; + private MultipartFile file; // 包含 + } + + static class ArrayDTO { + private MultipartFile[] files; // 包含数组 + } + + static class ListDTO { + // 包含集合泛型 + private List fileList; + private Set fileSet; + } + + static class NoneFileDTO { + private String name; + private Integer age; + } + + static class ChildDTO extends SimpleDTO { + private String childName; + // 继承了父类的 file 字段,应该返回 true + } +} diff --git a/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/aspectj/RepeatSubmitAspect.java b/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/aspectj/RepeatSubmitAspect.java index 5a27e9189..3883e1b97 100644 --- a/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/aspectj/RepeatSubmitAspect.java +++ b/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/aspectj/RepeatSubmitAspect.java @@ -15,6 +15,7 @@ import org.dromara.common.core.constant.GlobalConstants; import org.dromara.common.core.domain.R; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.MultipartFileCheckUtils; import org.dromara.common.core.utils.ServletUtils; import org.dromara.common.core.utils.StringUtils; import org.dromara.common.idempotent.annotation.RepeatSubmit; @@ -111,7 +112,10 @@ public class RepeatSubmitAspect { } for (Object o : paramsArray) { if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) { - params.add(JsonUtils.toJsonString(o)); + // 校验对象属性为文件的情况 前端FormData 包含一般属性及文件属性 无法序列化 + if(!MultipartFileCheckUtils.containsMultipartFile(o.getClass()) ){ + params.add(JsonUtils.toJsonString(o)); + } } } return params.toString(); diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java index b08825731..7388ef28a 100644 --- a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java @@ -1,10 +1,12 @@ package org.dromara.common.mybatis.config; import cn.hutool.core.net.NetUtil; +import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import com.baomidou.mybatisplus.core.handlers.PostInitTableInfoHandler; import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; +import com.baomidou.mybatisplus.extension.MybatisMapWrapperFactory; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; @@ -13,6 +15,7 @@ import org.dromara.common.core.factory.YmlPropertySourceFactory; import org.dromara.common.core.utils.SpringUtils; import org.dromara.common.mybatis.aspect.DataPermissionPointcutAdvisor; import org.dromara.common.mybatis.handler.InjectionMetaObjectHandler; +import org.dromara.common.mybatis.handler.JsonbTypeHandler; import org.dromara.common.mybatis.handler.MybatisExceptionHandler; import org.dromara.common.mybatis.handler.PlusPostInitTableInfoHandler; import org.dromara.common.mybatis.interceptor.PlusDataPermissionInterceptor; @@ -24,6 +27,9 @@ import org.springframework.context.annotation.PropertySource; import org.springframework.context.annotation.Role; import org.springframework.transaction.annotation.EnableTransactionManagement; +import java.util.List; +import java.util.Map; + /** * mybatis-plus配置类(下方注释有插件介绍) * @@ -119,6 +125,26 @@ public class MybatisPlusConfig { return new PlusPostInitTableInfoHandler(); } + @Bean + public ConfigurationCustomizer configurationCustomizer() { + return configuration -> { + // 当使用 resultType="java.util.Map" 时,您可以通过以下步骤在 Spring Boot 中实现下划线自动转换为驼峰 + configuration.setObjectWrapperFactory(new MybatisMapWrapperFactory()); + + // 1. 注册 Map 类型的处理器 + // 实例化时传入 Map.class,这样反序列化时会自动识别为 Map + configuration.getTypeHandlerRegistry().register(Map.class, JsonbTypeHandler.class); + + // 2. 注册 List 类型的处理器 + // 实例化时传入 List.class,支持数组/列表结构的 JSON + configuration.getTypeHandlerRegistry().register(List.class, JsonbTypeHandler.class); + + // 3. 注册 Object 类型的处理器 + // 作为兜底,处理未明确指定 Map 或 List 的 Object 字段 + configuration.getTypeHandlerRegistry().register(Object.class, JsonbTypeHandler.class); + }; + } + /** * PaginationInnerInterceptor 分页插件,自动识别数据库类型 * https://baomidou.com/pages/97710a/ diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/JsonbTypeHandler.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/JsonbTypeHandler.java new file mode 100644 index 000000000..fb520eef1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/JsonbTypeHandler.java @@ -0,0 +1,93 @@ +package org.dromara.common.mybatis.handler; + +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedTypes; +import org.dromara.common.mybatis.helper.DataBaseHelper; + +import java.lang.reflect.Method; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +/** + * 通用 JSON/JSONB 字段处理器 + */ +@Slf4j +@MappedTypes({Object.class}) +public class JsonbTypeHandler extends JacksonTypeHandler { + // PGobject 的类全名 + private static final String PG_OBJECT_CLASS_NAME = "org.postgresql.util.PGobject"; + + public JsonbTypeHandler() { + super(Object.class); + } + + public JsonbTypeHandler(Class type) { + super(type); + } + + /** + * 写库:Java 对象 → JSON 字符串 → 数据库(PostgreSQL 使用 PGobject) + */ + @Override + public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType) throws SQLException { + // 1. 利用父类转 JSON 字符串 + String json = super.toJson(parameter); + + // 2. 检查是否为 PostgreSQL + if (DataBaseHelper.getDataBaseType().isPostgreSql()) { + // 3. 尝试使用反射处理 PGobject + boolean success = setPgObjectByReflection(ps, i, json); + // 4. 如果反射失败(比如驱动版本不对),降级为 setString + if (!success) { + ps.setString(i, json); + } + } else { + // MySQL 或其他数据库 + ps.setString(i, json); + } + } + + /** + * 反射设置 PGobject + *

+ * 使用 catch (Exception) 兜底,确保: + * 1. 缺少 PostgreSQL 驱动时 -> 降级 + * 2. setObject 抛 SQLException 时 -> 降级 + * 3. 反射调用失败时 -> 降级 + */ + private boolean setPgObjectByReflection(PreparedStatement ps, int i, String json) { + try { + // 1. 加载类 + Class pgObjectClass = Class.forName(PG_OBJECT_CLASS_NAME); + + // 2. 实例化 + Object pgObject = pgObjectClass.getDeclaredConstructor().newInstance(); + + // 3. setType + Method setTypeMethod = pgObjectClass.getMethod("setType", String.class); + setTypeMethod.invoke(pgObject, "jsonb"); + + // 4. setValue + Method setValueMethod = pgObjectClass.getMethod("setValue", String.class); + setValueMethod.invoke(pgObject, json); + + // 5. setObject (这里会抛 SQLException) + ps.setObject(i, pgObject); + + return true; // 成功 + + } catch (ClassNotFoundException e) { + // 没有驱动,是正常现象(如 MySQL 环境),吞掉异常 + log.warn("PostgreSQL PGobject reflection failed, because PostgreSQL driver is missing. Error: {}", e.getMessage(), e); + return false; + } catch (Exception e) { + // 重点:捕获所有异常,包括 SQLException, InvocationTargetException 等 + // 如果 PG 处理失败,我们允许降级为 String 处理,保证业务不中断 + // 生产环境建议加一行日志,方便排查为什么 PG 处理失败了 + log.warn("PostgreSQL PGobject reflection failed, fallback to String. Error: {}", e.getMessage(), e); + return false; + } + } +}