构建灵活可扩展的FastExcel导入字段校验系统
一、背景与需求
在企业级应用开发中,Excel文件导入是常见的功能需求。然而,用户上传的Excel数据往往存在各种问题,如格式错误、数据不规范、包含非法字符等。传统的手动校验方式效率低下且容易出错,因此我们需要一个统一、灵活、可扩展的Excel字段校验系统。
核心需求:
灵活配置:支持通过注解方式配置校验规则
多种校验类型:支持字符类型、长度、数字范围、正则表达式等多种校验
精确控制:能够精确控制哪些字符允许或禁止,特别是Emoji表情的处理
友好反馈:提供详细的错误信息,方便用户修改数据
易用性:简化使用方式,减少重复代码
可扩展性:支持自定义校验器和分组校验
行级数据校验:支持行级数据校验,可合并输出
二、系统设计
2.1 架构设计
整个校验系统采用注解驱动的方式,通过以下组件协同工作:
┌─────────────────────────────────────────────────────┐ │ Excel导入模块 │ ├─────────────────────────────────────────────────────┤ │ 监听器(Listener) → 校验工具类 → 结果收集 → 返回前端 │ └─────────────────────────────────────────────────────┘ │ │ │ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ ExcelValid │ │ 校验枚举 │ │ 错误结果实体 │ │ 注解 │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘
2.2 核心组件
校验注解(
ExcelValid):定义字段的校验规则字符类型枚举(
CharacterValidationType):定义支持的字符类型校验工具类(
ExcelValidationUtil):执行具体的校验逻辑结果实体:封装校验结果和错误信息
监听器(CustomExcelValidListener):集成到Excel解析流程中
三、实现细节
3.1 包引入
<dependency> <groupId>cn.idev.excel</groupId> <artifactId>fastexcel</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>com.vdurmont</groupId> <artifactId>emoji-java</artifactId> <version>5.1.1</version> </dependency>3.2 字符类型枚举
CharacterValidationType枚举定义了系统支持的所有字符类型,每种类型都包含正则表达式和错误信息:
package com.fantaibao.enums; import lombok.Getter; import java.math.BigDecimal; import java.util.regex.Pattern; /** * 字符类型枚举 */ @Getter public enum CharacterValidationType { /** * 中文字符(包括中文标点) */ CHINESE("^[\\u4e00-\\u9fa5\\u3000-\\u303F\\uFF00-\\uFFEF\\u201C-\\u201F\\u3001-\\u301F]*$", "只能包含中文"), /** * 中文字符和中文符号 */ CHINESE_WITH_SYMBOL("^[\\u4e00-\\u9fa5\\u3000-\\u303F\\uFF00-\\uFFEF]*$", "只能包含中文和中文符号"), /** * 英文字母(大小写) */ ENGLISH("^[A-Za-z]*$", "只能包含英文字母"), /** * 英文字母和英文符号 */ ENGLISH_WITH_SYMBOL("^[A-Za-z\\p{P}\\p{S}]*$", "只能包含英文字母和英文符号"), /** * 数字(0-9) */ DIGIT("^[0-9]*$", "只能包含数字"), /** * 正整数(不包括0) */ POSITIVE_INTEGER("^[1-9]\\d*$", "只能为正整数"), /** * 非负整数(包括0) */ NON_NEGATIVE_INTEGER("^\\d+$", "只能为非负整数"), /** * 负整数 */ NEGATIVE_INTEGER("^-[1-9]\\d*$", "只能为负整数"), /** * 整数(包括正负整数和0) */ INTEGER("^-?\\d+$", "只能为整数"), /** * 正小数(正浮点数) */ POSITIVE_DECIMAL("^[+]?([0-9]*\\.)?[0-9]+$", "只能为正小数"), /** * 负小数(负浮点数) */ NEGATIVE_DECIMAL("^-([0-9]*\\.)?[0-9]+$", "只能为负小数"), /** * 小数/浮点数(包括正负) */ DECIMAL("^-?([0-9]*\\.)?[0-9]+$", "只能为小数"), /** * 英文符号(常见标点符号) */ ENGLISH_SYMBOL("^[\\p{P}\\p{S}]*$", "只能包含英文符号"), /** * 中文符号 */ CHINESE_SYMBOL("^[\\u3000-\\u303F\\uFF00-\\uFFEF]*$", "只能包含中文符号"), /** * 特殊符号(自定义的特殊字符) */ SPECIAL_SYMBOL("^[`~!@#$%^&*()_\\-+=\\[\\]{}|;:'\",.<>/?]*$", "只能包含特殊符号"), /** * 字母和数字 */ ENGLISH_DIGIT("^[A-Za-z0-9]*$", "只能包含字母和数字"), /** * 中文字母数字 */ CHINESE_ENGLISH_DIGIT("^[\\u4e00-\\u9fa5A-Za-z0-9]*$", "只能包含中文、字母和数字"), /** * 字母、数字和英文符号 */ ENGLISH_DIGIT_SYMBOL("^[A-Za-z0-9\\p{P}\\p{S}]*$", "只能包含字母、数字和英文符号"), NULL(null, ""); /** * 正则表达式 */ private final String pattern; /** * 错误信息 */ private final String message; CharacterValidationType(String pattern, String message) { this.pattern = pattern; this.message = message; } /** * 校验字符串是否符合该字符类型 */ public boolean validate(String value) { if (value == null || value.isEmpty()) { // 空值不校验,如果需要校验空值,请配合@ExcelValid的required属性 return false; } return !Pattern.matches(pattern, value); } /** * 校验是否为数字(整数或小数) - 修正版 * 修正:原方法逻辑反了,应该返回true表示是数字 */ public static boolean isNumeric(String value) { if (value == null || value.isEmpty()) { return false; } try { new BigDecimal(value); return true; } catch (NumberFormatException e) { return false; } } /** * 校验小数位数 * @param value 要校验的值 * @param decimalPlaces 要求的小数位数 * @return 是否符合要求 */ public static boolean validateDecimalPlaces(String value, int decimalPlaces) { if (value == null || value.isEmpty() || !isNumeric(value)) { return true; } BigDecimal decimal = new BigDecimal(value); int scale = decimal.scale(); // 如果是整数,scale为0 return scale > decimalPlaces; } /** * 校验数字范围 * @param value 要校验的值 * @param min 最小值(可为null) * @param max 最大值(可为null) * @return 是否在范围内 */ public static boolean validateNumberRange(String value, BigDecimal min, BigDecimal max) { if (value == null || value.isEmpty() || !isNumeric(value)) { return true; } BigDecimal decimal = new BigDecimal(value); if (min != null && decimal.compareTo(min) < 0) { return true; } return max != null && decimal.compareTo(max) > 0; } }设计要点:
使用正则表达式定义字符类型范围
为
MIXED类型特殊处理,只排除Emoji而不限制其他字符提供静态方法支持数字范围、小数位数等高级校验
3.3 校验注解
ExcelValid注解是系统的核心配置接口,支持丰富的校验选项:
package com.fantaibao.annotation; import java.lang.annotation.*; /** * Excel字段校验注解 * @author 石头 */ @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ExcelValid { /** * 是否跳过校验 * 默认false,表示进行校验;true表示跳过所有校验 */ boolean skip() default false; /** * 是否必填 */ boolean required() default false; /** * 必填时的错误提示信息 */ String requiredMessage() default "不能为空"; /** * 最小长度 */ int minLength() default 0; /** * 最大长度 */ int maxLength() default Integer.MAX_VALUE; /** * 长度校验失败时的错误信息 */ String lengthMessage() default "长度不符合要求"; /** * 自定义正则表达式(优先级高于allowedTypes) */ String regex() default ""; /** * 自定义正则表达式校验失败时的错误信息 */ String regexMessage() default "格式不正确"; /** * 是否为数字(整数或小数) */ boolean numeric() default false; /** * 数字校验失败时的错误信息 */ String numericMessage() default "必须为数字"; /** * 是否为整数 */ boolean integer() default false; /** * 整数校验失败时的错误信息 */ String integerMessage() default "必须为整数"; /** * 是否为正整数(大于0) */ boolean positiveInteger() default false; /** * 正整数校验失败时的错误信息 */ String positiveIntegerMessage() default "必须为正整数"; /** * 是否为非负整数(大于等于0) */ boolean nonNegativeInteger() default false; /** * 非负整数校验失败时的错误信息 */ String nonNegativeIntegerMessage() default "必须为非负整数"; /** * 是否为负整数 */ boolean negativeInteger() default false; /** * 负整数校验失败时的错误信息 */ String negativeIntegerMessage() default "必须为负整数"; /** * 是否为小数 */ boolean decimal() default false; /** * 小数位数限制(仅当decimal=true时有效,-1表示不限制) */ int decimalPlaces() default -1; /** * 小数位数校验失败时的错误信息 */ String decimalMessage() default "小数位数不符合要求"; /** * 最小值(对于数字) */ String minValue() default ""; /** * 最大值(对于数字) */ String maxValue() default ""; /** * 数值范围校验失败时的错误信息 */ String rangeMessage() default "数值不在允许范围内"; /** * 是否包含Emoji */ boolean allowEmoji() default false; /** * Emoji校验失败时的错误信息 */ String emojiMessage() default "不能包含Emoji表情"; }设计要点:
默认值设计合理,简化使用
支持多种校验类型,可组合使用
提供详细的错误信息配置
3.4 校验工具类
ExcelValidationUtil是系统的核心执行引擎,负责解析注解并执行校验:
package com.fantaibao.utils.excelUtil; import cn.hutool.core.collection.CollUtil; import cn.idev.excel.annotation.ExcelProperty; import com.fantaibao.annotation.ExcelValid; import com.fantaibao.enums.CharacterValidationType; import com.fantaibao.vo.ExcelValidationBaseResultVo; import com.fantaibao.vo.ExcelValidationErrorVo; import com.vdurmont.emoji.EmojiParser; import lombok.extern.slf4j.Slf4j; import org.springframework.util.StringUtils; import java.lang.reflect.Field; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.regex.Pattern; /** * Excel校验工具类 - 返回校验结果,不抛出异常 */ @Slf4j public class ExcelValidationUtil { /** * 校验单个对象的字段 * @param obj 要校验的对象 * @param rowIndex 行索引(0-based) * @return 错误列表,如果校验通过返回空列表 */ public static List<ExcelValidationErrorVo> validate(Object obj, int rowIndex) { List<ExcelValidationErrorVo> errors = new ArrayList<>(); if (Objects.isNull(obj)) { return errors; } Class<?> clazz = obj.getClass(); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { ExcelValid excelValid = field.getAnnotation(ExcelValid.class); if (excelValid == null) { continue; } // 如果设置了skip=true,则跳过该字段的所有校验 if (excelValid.skip()) { continue; } field.setAccessible(true); try { String value = (String) field.get(obj); String fieldName = getFieldDisplayName(field); // 执行校验 List<ExcelValidationErrorVo> fieldErrors = doValidate(value, excelValid, fieldName, rowIndex); if (!fieldErrors.isEmpty()) { errors.addAll(fieldErrors); } } catch (IllegalAccessException e) { log.error("反射获取字段值失败", e); errors.add(ExcelValidationErrorVo.builder() // Excel行号从1开始 .rowNumber(rowIndex + 1) .fieldName(field.getName()) .errorMessage("字段访问失败") .skipRow(false) .build()); } catch (ClassCastException e) { // 如果不是String类型,跳过校验(或根据需要处理) log.debug("字段{}不是String类型,跳过校验", field.getName()); } } return errors; } /** * 获取字段的显示名称(优先使用ExcelProperty注解的值) */ private static String getFieldDisplayName(Field field) { ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); if (excelProperty != null && excelProperty.value().length > 0) { return excelProperty.value()[0]; } return field.getName(); } /** * 执行具体的校验逻辑 */ private static List<ExcelValidationErrorVo> doValidate(String value, ExcelValid excelValid, String fieldName, int rowIndex) { List<ExcelValidationErrorVo> errors = new ArrayList<>(); // 1. 必填校验 if (excelValid.required() && !StringUtils.hasText(value)) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(excelValid.requiredMessage()) // 如果必填字段为空,跳过该行 .skipRow(excelValid.required()) .build()); // 如果必填为空,直接返回 return errors; } // 如果没有值,且不是必填,直接返回(不进行后续校验) if (!StringUtils.hasText(value)) { return errors; } // 2. 长度校验 int length = value.length(); if (length < excelValid.minLength() || length > excelValid.maxLength()) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(String.format("%s,要求长度%d-%d,实际长度%d", excelValid.lengthMessage(), excelValid.minLength(), excelValid.maxLength(), length)) .skipRow(false) .build()); } // 3. 自定义正则校验(优先级最高) if (StringUtils.hasText(excelValid.regex())) { if (!Pattern.matches(excelValid.regex(), value)) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName).originalValue(value) .errorMessage(excelValid.regexMessage()) .skipRow(false) .build()); // 正则校验失败,直接返回 return errors; } } // 4. 数字类型校验 if (excelValid.numeric() && CharacterValidationType.isNumeric(value)) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(excelValid.numericMessage()) .skipRow(false) .build()); } // 5. 整数类型校验 if (excelValid.integer() && CharacterValidationType.INTEGER.validate(value)) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(excelValid.integerMessage()) .skipRow(false) .build()); } // 6. 正整数校验(新增) if (excelValid.positiveInteger() && CharacterValidationType.POSITIVE_INTEGER.validate(value)) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(excelValid.positiveIntegerMessage()) .skipRow(false) .build()); } // 7. 非负整数校验(新增) if (excelValid.nonNegativeInteger() && CharacterValidationType.NON_NEGATIVE_INTEGER.validate(value)) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(excelValid.nonNegativeIntegerMessage()) .skipRow(false) .build()); } // 8. 负整数校验(新增) if (excelValid.negativeInteger() && CharacterValidationType.NEGATIVE_INTEGER.validate(value)) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(excelValid.negativeIntegerMessage()) .skipRow(false) .build()); } // 9. 小数类型校验 if (excelValid.decimal() && CharacterValidationType.DECIMAL.validate(value)) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(excelValid.decimalMessage()) .skipRow(false) .build()); } else if (excelValid.decimal() && excelValid.decimalPlaces() >= 0) { if (CharacterValidationType.validateDecimalPlaces(value, excelValid.decimalPlaces())) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(String.format("%s,最多允许%d位小数", excelValid.decimalMessage(), excelValid.decimalPlaces())) .skipRow(false) .build()); } } // 10. 数值范围校验 if (StringUtils.hasText(excelValid.minValue()) || StringUtils.hasText(excelValid.maxValue())) { BigDecimal min = null; BigDecimal max = null; try { if (StringUtils.hasText(excelValid.minValue())) { min = new BigDecimal(excelValid.minValue()); } if (StringUtils.hasText(excelValid.maxValue())) { max = new BigDecimal(excelValid.maxValue()); } if (CharacterValidationType.validateNumberRange(value, min, max)) { String rangeDesc = ""; if (min != null && max != null) { rangeDesc = String.format("应在%s-%s之间", min, max); } else if (min != null) { rangeDesc = String.format("应大于等于%s", min); } else if (max != null) { rangeDesc = String.format("应小于等于%s", max); } errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(String.format("%s,%s", excelValid.rangeMessage(), rangeDesc)) .skipRow(false) .build()); } } catch (NumberFormatException e) { log.warn("minValue或maxValue格式不正确", e); } } // 11. Emoji校验 if (excelValid.allowEmoji() && isIncludedEmoji(value)) { errors.add(ExcelValidationErrorVo.builder() .rowNumber(rowIndex + 1) .fieldName(fieldName) .originalValue(value) .errorMessage(excelValid.emojiMessage()) .skipRow(false) .build()); } return errors; } /** * 判断字符串是否包含Emoji字符 * * @param input 待验证的字符串 * @return 如果字符串包含Emoji字符,则返回 true;否则返回 false */ public static boolean isIncludedEmoji(String input) { if (input == null || input.isEmpty()) { return false; } // 提取所有Emoji List<String> emojis = EmojiParser.extractEmojis(input); return CollUtil.isNotEmpty(emojis); } /** * 批量校验 * @param dataList 数据列表 * @param <T> 数据类型 * @return 所有校验错误的列表 */ public static <T> List<ExcelValidationErrorVo> validateBatch(List<T> dataList) { List<ExcelValidationErrorVo> allErrors = new ArrayList<>(); if (dataList == null || dataList.isEmpty()) { return allErrors; } for (int i = 0; i < dataList.size(); i++) { List<ExcelValidationErrorVo> errors = validate(dataList.get(i), i); if (!errors.isEmpty()) { allErrors.addAll(errors); } } return allErrors; } /** * 批量校验并分类数据 * @param dataList 原始数据列表 * @return 包含成功数据和错误信息的复合结果 */ public static <T> ExcelValidationBaseResultVo<T> validateAndClassify(List<T> dataList) { ExcelValidationBaseResultVo<T> result = new ExcelValidationBaseResultVo<>(); result.setTotalCount(dataList.size()); for (int i = 0; i < dataList.size(); i++) { T data = dataList.get(i); List<ExcelValidationErrorVo> errors = validate(data, i); if (errors.isEmpty()) { result.getSuccessData().add(data); result.setSuccessCount(result.getSuccessCount() + 1); } else { // 判断是否需要跳过该行(比如必填字段为空) boolean skipRow = errors.stream().anyMatch(ExcelValidationErrorVo::getSkipRow); if (!skipRow) { // 如果不跳过,可能只记录第一个错误 result.getExcelErrorList().add(errors.get(0)); } else { // 如果跳过,添加错误信息 result.getExcelErrorList().addAll(errors); } } } // 检查是否全部成功 result.setAllSuccess(result.getExcelErrorList().isEmpty()); return result; } }算法亮点:
校验顺序优化:按照优先级执行校验,失败后及时返回
Emoji精确检测:使用精确的码点检测,避免将常见符号误判为Emoji
错误收集:支持一行多错,但默认只返回第一个错误
3.5 结果实体
系统提供两种结果实体,支持灵活的返回方式:
package com.fantaibao.vo; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ExcelValidationBaseResultVo<T> { /** * 成功导入条数 */ private Integer successCount; /** * 成功数据列表 */ private List<T> successData; /** * 异常导入数据集合 */ private List<ExcelValidationErrorVo> excelErrorList; /** * 是否全部成功 */ private boolean allSuccess; /** * 总行数 */ private Integer totalCount; }package com.fantaibao.vo; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @AllArgsConstructor @NoArgsConstructor public class ExcelValidationErrorVo { /** * 行号(Excel行号,从1开始) */ private Integer rowNumber; /** * 字段名称 */ private String fieldName; /** * 原始值 */ private String originalValue; /** * 错误信息 */ private String errorMessage; /** * 是否跳过该行(当一行有多个错误时,可能只需要一个错误信息) */ @Builder.Default private Boolean skipRow = false; }3.6 Excel监听器
CustomExcelValidListener将校验系统集成到Excel解析流程中:
package com.fantaibao.listener; import cn.hutool.core.collection.CollUtil; import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.context.AnalysisContext; import cn.idev.excel.metadata.data.ReadCellData; import cn.idev.excel.read.listener.ReadListener; import cn.idev.excel.util.StringUtils; import com.fantaibao.utils.excelUtil.ExcelValidationUtil; import com.fantaibao.vo.ExcelValidationBaseResultVo; import com.fantaibao.vo.ExcelValidationErrorVo; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; @Slf4j @Getter @RequiredArgsConstructor public class CustomExcelValidListener<T> implements ReadListener<T> { /** * 存储所有解析的数据(包括成功和失败的) */ private List<T> allDataList; /** * 存储成功的数据 */ private List<T> successDataList; /** * 存储校验错误 */ private List<ExcelValidationErrorVo> errorList; /** * 最终返回结果 */ private ExcelValidationBaseResultVo<T> resultVo; /** * 表头字段列表 */ private List<String> fields; /** * 是否启用字段校验(默认启用) */ private boolean enableValidation = true; /** * 是否立即校验(在invoke方法中校验) */ private boolean validateImmediately = true; /** * 是否严格模式(表头校验失败则终止) */ private boolean strictMode = true; /** * 默认构造方法 */ public CustomExcelValidListener(Class<T> clazz) { this(clazz, true, true, true); } /** * 构造方法 */ public CustomExcelValidListener(Class<T> clazz, boolean enableValidation, boolean validateImmediately, boolean strictMode) { this.allDataList = new ArrayList<>(); this.successDataList = new ArrayList<>(); this.errorList = new ArrayList<>(); this.resultVo = new ExcelValidationBaseResultVo<>(); this.fields = getFields(clazz); this.enableValidation = enableValidation; this.validateImmediately = validateImmediately; this.strictMode = strictMode; } /** * 根据class通过反射获取字段上@ExcelProperty注解的值 */ private List<String> getFields(Class<T> clazz) { List<String> fields = CollUtil.newArrayList(); Field[] declaredFields = clazz.getDeclaredFields(); for (Field field : declaredFields) { ExcelProperty annotation = field.getAnnotation(ExcelProperty.class); if (annotation != null) { String[] value = annotation.value(); fields.addAll(Arrays.asList(value)); } } return fields; } @Override public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) { // 在这里检查表头是否正确 List<String> headerErrors = new ArrayList<>(); for (Map.Entry<Integer, ReadCellData<?>> entry : headMap.entrySet()) { String headerValue = entry.getValue().getStringValue(); if (!isValidHeader(headerValue)) { String error = String.format("表头'%s'不匹配", headerValue); headerErrors.add(error); } } // 检查是否所有必要的表头都被匹配 if (!fields.isEmpty()) { String error = String.format("缺少必要的表头: %s", String.join(", ", fields)); headerErrors.add(error); } // 处理表头错误 if (!headerErrors.isEmpty()) { String errorMessage = String.join("; ", headerErrors); if (strictMode) { throw new RuntimeException("模板错误: " + errorMessage); } else { // 非严格模式下,记录错误但不中断 log.warn("表头校验失败: {}", errorMessage); ExcelValidationErrorVo errorVo = ExcelValidationErrorVo.builder() // 表头错误行号为0 .rowNumber(0) .fieldName("表头") .errorMessage("模板错误: " + errorMessage) .build(); errorList.add(errorVo); } } } @Override public void invoke(T data, AnalysisContext context) { int rowIndex = context.readRowHolder().getRowIndex(); allDataList.add(data); if (enableValidation && validateImmediately) { // 立即校验当前行 List<ExcelValidationErrorVo> errors = ExcelValidationUtil.validate(data, rowIndex); if (errors.isEmpty()) { successDataList.add(data); } else { // 判断是否需要跳过该行(比如必填字段为空) boolean skipRow = errors.stream().anyMatch(ExcelValidationErrorVo::getSkipRow); if (!skipRow) { // 如果不跳过,可能只记录第一个错误 errorList.add(errors.get(0)); } else { // 如果跳过,添加所有错误信息 errorList.addAll(errors); } } } else { // 不进行立即校验,先全部加入成功列表(后续统一校验) successDataList.add(data); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 如果未启用立即校验,则在所有解析完成后批量校验 if (enableValidation && !validateImmediately) { // 清空之前的成功列表,重新校验 successDataList.clear(); errorList.clear(); for (int i = 0; i < allDataList.size(); i++) { T data = allDataList.get(i); List<ExcelValidationErrorVo> errors = ExcelValidationUtil.validate(data, i); if (errors.isEmpty()) { successDataList.add(data); } else { // 判断是否需要跳过该行 boolean skipRow = errors.stream().anyMatch(ExcelValidationErrorVo::getSkipRow); if (!skipRow) { // 如果不跳过,只记录第一个错误 errorList.add(errors.get(0)); } else { // 如果跳过,添加所有错误信息 errorList.addAll(errors); } } } } // 构建返回结果 resultVo.setSuccessData(new ArrayList<>(successDataList)); resultVo.setSuccessCount(successDataList.size()); resultVo.setExcelErrorList(new ArrayList<>(errorList)); resultVo.setTotalCount(allDataList.size()); resultVo.setAllSuccess(errorList.isEmpty()); log.info("Excel解析完成,sheet名: {},总行数: {},成功: {},失败: {}", context.readSheetHolder().getSheetName(), resultVo.getTotalCount(), resultVo.getSuccessCount(), errorList.size()); } /** * 获取最终结果 */ public ExcelValidationBaseResultVo<T> getResult() { return resultVo; } private boolean isValidHeader(String header) { if (StringUtils.isBlank(header)) { return true; } // 移除空白字符后比较 String trimmedHeader = header.trim(); for (String field : fields) { if (field.equals(trimmedHeader)) { fields.remove(field); return true; } } return false; } }四、使用示例
4.1 实体类配置
package com.fantaibao.module.vo.appDish; import cn.idev.excel.annotation.ExcelIgnoreUnannotated; import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.write.style.ColumnWidth; import com.fantaibao.annotation.ExcelValid; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @ColumnWidth(25) @AllArgsConstructor @NoArgsConstructor @ExcelIgnoreUnannotated public class DishAppRankingListVo1 { /** * 菜品名称 */ @ColumnWidth(30) @ExcelProperty("菜品名称") // 不需要校验 @ExcelValid(skip = true) private String menuName; /** * 理想销量排名 */ @ColumnWidth(20) @ExcelProperty("理想销量排名") @ExcelValid( minValue = "1", maxValue = "9999", positiveInteger = true ) private String salesRanking; /** * 理想实收排名 */ @ColumnWidth(20) @ExcelProperty("理想实收排名") @ExcelValid( minValue = "1", maxValue = "9999", positiveInteger = true ) private String turnoverRanking; /** * 理想毛利额排名 */ @ColumnWidth(15) @ExcelProperty("理想毛利额排名") @ExcelValid( minValue = "1", maxValue = "9999", positiveInteger = true ) private String profitRanking; }4.2 业务层使用
@Override public ExcelValidationBaseResultVo<DishAppRankingListVo1> importExcelRankingTest(String fileUrl) { RLock lock = redissonClient.getLock(String.format(DATA_ANALYSIS_STANDARD_RANKING_LOCK_KEY, UserProvider.getUser().getTenantId())); Assert.isFalse(lock.isLocked(), "当前租户标准菜品排名配置正在变更中,请稍后再试"); try { if (!lock.tryLock(60, -1, TimeUnit.SECONDS)) { throw new RuntimeException("无法获取分布式锁,请稍后再试"); } try { // 创建监听器 CustomExcelValidListener<DishAppRankingListVo1> listener = new CustomExcelValidListener<>(DishAppRankingListVo1.class, true, true, true); // 读取Excel文件并填充菜品列表 EasyExcel.read(new URL(fileUrl).openStream(), DishAppRankingListVo1.class, listener) .sheet(1) .doRead(); // 获取结果 ExcelValidationBaseResultVo<DishAppRankingListVo1> result = listener.getResult(); // 如果存在成功数据,可以进一步处理(如保存到数据库) if (result.getSuccessCount() > 0) { } // 记录错误信息 if (!result.isAllSuccess()) { logImportErrors(result); } return result; } catch (IOException e) { log.error("读取文件失败", e); return ExcelValidationBaseResultVo.<DishAppRankingListVo1>builder() .successCount(0) .allSuccess(false) .build(); } catch (Exception e) { // 处理表头错误等严重异常 log.error("Excel导入失败", e); return ExcelValidationBaseResultVo.<DishAppRankingListVo1>builder() .successCount(0) .allSuccess(false) .build(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }五、关键技术点
5.1 校验优先级设计
系统按照以下优先级执行校验,确保校验效率和准确性:
必填校验:失败则立即返回,不进行后续校验
长度校验:基础校验,失败不立即返回
自定义正则:优先级最高,失败则立即返回
数字类型校验:按类型分别校验
范围校验:依赖于数字类型校验
特殊字符校验:检查特殊符号和Emoji
字符类型校验:最后执行的综合校验
5.2 性能优化
正则表达式预编译:避免重复编译正则表达式
校验短路:失败后及时返回,减少不必要的校验
批量处理:支持批量校验,减少反射开销
对象复用:复用校验对象,避免重复创建