add MultipartFileCheckUtils 防止重复提交RepeatSubmitAspect校验对象属性为文件的情况 前端FormData 包含一般属性及文件属性 无法序列化;

add JsonbTypeHandler pg数据库jsonb字段无法写入问题
This commit is contained in:
zhangweiwei 2026-01-09 18:33:37 +08:00
parent 948eba6566
commit fdc4a19428
4 changed files with 353 additions and 1 deletions

View File

@ -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;
/**
* 文件上传属性检查工具类
* <p>
* 功能
* 1. 判断类及其父类中是否包含 MultipartFile 类型的属性
* 2. 支持 MultipartFile 数组 (MultipartFile[]) 判断
* 3. 支持集合泛型判断 ( List<MultipartFile>, Set<MultipartFile>)
* 4. 使用 ConcurrentHashMap 缓存反射结果提升高频调用时的性能
* 5. 兼容 JDK 21 特性 RecordRecord 的组件会被视为字段处理
*
* @author Your Name
* @since JDK 21
*/
public class MultipartFileCheckUtils {
/**
* 缓存Key 为类对象Value 为是否包含 MultipartFile
* 使用 ConcurrentHashMap 保证线程安全
*/
private static final Map<Class<?>, Boolean> CACHE = new ConcurrentHashMap<>();
/**
* 私有构造防止实例化
*/
private MultipartFileCheckUtils() {
}
/**
* 检查指定类是否包含 MultipartFile 属性包含父类递归检查
* <p>
* 此方法会自动使用缓存重复调用同一 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 属性包含父类递归检查
* <p>
* 可指定是否跳过缓存通常用于开发调试或热更新场景
*
* @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<MultipartFile>, Set<MultipartFile>, Collection<MultipartFile>
// 注意不直接检查 Map因为 Spring MVC 通常用 List/Array 接收多文件
if (Iterable.class.isAssignableFrom(fieldType) || Collection.class.isAssignableFrom(fieldType)) {
return isGenericParameterMultipartFile(field);
}
return false;
}
/**
* 检查集合字段的泛型参数是否为 MultipartFile
* <p>
* 例如private List<MultipartFile> files;
* 我们需要获取 List 尖括号里的类型判断它是不是 MultipartFile
*
* @param field 字段
* @return true 如果泛型类型是 MultipartFile
*/
private static boolean isGenericParameterMultipartFile(Field field) {
Type genericType = field.getGenericType();
// 必须是参数化类型即带泛型的类型 List<String>
if (genericType instanceof ParameterizedType parameterizedType) {
// 获取实际的泛型参数数组例如 List<String> -> [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<MultipartFile> fileList;
private Set<MultipartFile> fileSet;
}
static class NoneFileDTO {
private String name;
private Integer age;
}
static class ChildDTO extends SimpleDTO {
private String childName;
// 继承了父类的 file 字段应该返回 true
}
}

View File

@ -15,6 +15,7 @@ import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.MessageUtils; 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.ServletUtils;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.idempotent.annotation.RepeatSubmit; import org.dromara.common.idempotent.annotation.RepeatSubmit;
@ -111,7 +112,10 @@ public class RepeatSubmitAspect {
} }
for (Object o : paramsArray) { for (Object o : paramsArray) {
if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) { 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(); return params.toString();

View File

@ -1,10 +1,12 @@
package org.dromara.common.mybatis.config; package org.dromara.common.mybatis.config;
import cn.hutool.core.net.NetUtil; 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.MetaObjectHandler;
import com.baomidou.mybatisplus.core.handlers.PostInitTableInfoHandler; import com.baomidou.mybatisplus.core.handlers.PostInitTableInfoHandler;
import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; 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.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; 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.core.utils.SpringUtils;
import org.dromara.common.mybatis.aspect.DataPermissionPointcutAdvisor; import org.dromara.common.mybatis.aspect.DataPermissionPointcutAdvisor;
import org.dromara.common.mybatis.handler.InjectionMetaObjectHandler; 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.MybatisExceptionHandler;
import org.dromara.common.mybatis.handler.PlusPostInitTableInfoHandler; import org.dromara.common.mybatis.handler.PlusPostInitTableInfoHandler;
import org.dromara.common.mybatis.interceptor.PlusDataPermissionInterceptor; 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.context.annotation.Role;
import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.EnableTransactionManagement;
import java.util.List;
import java.util.Map;
/** /**
* mybatis-plus配置类(下方注释有插件介绍) * mybatis-plus配置类(下方注释有插件介绍)
* *
@ -119,6 +125,26 @@ public class MybatisPlusConfig {
return new PlusPostInitTableInfoHandler(); 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 分页插件自动识别数据库类型 * PaginationInnerInterceptor 分页插件自动识别数据库类型
* https://baomidou.com/pages/97710a/ * https://baomidou.com/pages/97710a/

View File

@ -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
* <p>
* 使用 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;
}
}
}