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;
+ }
+ }
+}