Spring Boot + FastExcel:打造完美的导入校验功能

构建灵活可扩展的FastExcel导入字段校验系统

一、背景与需求

在企业级应用开发中,Excel文件导入是常见的功能需求。然而,用户上传的Excel数据往往存在各种问题,如格式错误、数据不规范、包含非法字符等。传统的手动校验方式效率低下且容易出错,因此我们需要一个统一、灵活、可扩展的Excel字段校验系统。

核心需求:

  1. 灵活配置:支持通过注解方式配置校验规则

  2. 多种校验类型:支持字符类型、长度、数字范围、正则表达式等多种校验

  3. 精确控制:能够精确控制哪些字符允许或禁止,特别是Emoji表情的处理

  4. 友好反馈:提供详细的错误信息,方便用户修改数据

  5. 易用性:简化使用方式,减少重复代码

  6. 可扩展性:支持自定义校验器和分组校验

  7. **行级数据校验:**支持行级数据校验,可合并输出

二、系统设计

2.1 架构设计

整个校验系统采用注解驱动的方式,通过以下组件协同工作:

复制代码
┌─────────────────────────────────────────────────────┐
│                    Excel导入模块                    │
├─────────────────────────────────────────────────────┤
│ 监听器(Listener) → 校验工具类 → 结果收集 → 返回前端 │
└─────────────────────────────────────────────────────┘
           │              │              │
           ▼              ▼              ▼
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  ExcelValid  │  │  校验枚举    │  │ 错误结果实体 │
│   注解       │  │              │  │              │
└──────────────┘  └──────────────┘  └──────────────┘

2.2 核心组件

  1. 校验注解 (ExcelValid):定义字段的校验规则

  2. 字符类型枚举 (CharacterValidationType):定义支持的字符类型

  3. 校验工具类 (ExcelValidationUtil):执行具体的校验逻辑

  4. 结果实体:封装校验结果和错误信息

  5. 监听器 (CustomExcelValidListener):集成到Excel解析流程中

三、实现细节

3.1 包引入

XML 复制代码
<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 枚举定义了系统支持的所有字符类型,每种类型都包含正则表达式和错误信息:

java 复制代码
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 注解是系统的核心配置接口,支持丰富的校验选项:

java 复制代码
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 是系统的核心执行引擎,负责解析注解并执行校验:

java 复制代码
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;
    }
}

算法亮点

  1. 校验顺序优化:按照优先级执行校验,失败后及时返回

  2. Emoji精确检测:使用精确的码点检测,避免将常见符号误判为Emoji

  3. 错误收集:支持一行多错,但默认只返回第一个错误

3.5 结果实体

系统提供两种结果实体,支持灵活的返回方式:

java 复制代码
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;
}
java 复制代码
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解析流程中:
java 复制代码
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 实体类配置

java 复制代码
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 业务层使用

java 复制代码
 @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 校验优先级设计

系统按照以下优先级执行校验,确保校验效率和准确性:

  1. 必填校验:失败则立即返回,不进行后续校验

  2. 长度校验:基础校验,失败不立即返回

  3. 自定义正则:优先级最高,失败则立即返回

  4. 数字类型校验:按类型分别校验

  5. 范围校验:依赖于数字类型校验

  6. 特殊字符校验:检查特殊符号和Emoji

  7. 字符类型校验:最后执行的综合校验

5.2 性能优化

  1. 正则表达式预编译:避免重复编译正则表达式

  2. 校验短路:失败后及时返回,减少不必要的校验

  3. 批量处理:支持批量校验,减少反射开销

  4. 对象复用:复用校验对象,避免重复创建

5.3 结果演示

相关推荐
毕设源码-邱学长2 小时前
【开题答辩全过程】以 基于Springboot个人健康运动系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
Rainly20002 小时前
java原生实现企业级spring batch数据迁移
java·spring·batch
云霄IT2 小时前
go语言post请求遭遇403反爬解决tls/ja3指纹或Cloudflare防护
开发语言·后端·golang
Dragon Wu2 小时前
OpenAPI 3.0(Swagger3/Knife4j)完整简洁注解清单
spring boot·后端·springboot
綦枫Maple2 小时前
IDEA选择“在当前窗口打开”还是“新窗口打开”的提示不见了,如何恢复?
java·ide·intellij-idea
缺一句感谢和缺一句道歉2 小时前
Module was compiled with an incompatible version of Kotlin.
java·kotlin
码云数智-大飞2 小时前
优雅解决 IntelliJ IDEA “命令行过长”问题:使用 JAR 清单(Manifest)方式
java·intellij-idea·jar
毕设源码-赖学姐2 小时前
【开题答辩全过程】以 基于Spring Boot的驾校信息管理系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端