🐛 导入单元格无法转换时改为返回可读失败明细,兜底 26 个导入接口

问题(用户实测反馈):金额列填「一万二千元」这类无法转换的内容时,EasyExcel 在
ExcelUtil.read(...) 阶段就抛 ExcelDataConvertException,根本走不到行级校验;
控制器只 catch ImportFailureException,异常穿透到全局处理器,用户看到的是
HTTP 500 + 「Convert data ... to class java.math.BigDecimal error」这类技术文案,
且完全拿不到失败明细。

影响面:全部 27 个导入接口(读阶段的 ExcelUtil.read 都在 try 内、只 catch
ImportFailureException),非维修记录独有。

改动:
- ImportFailureExcelUtil 新增三个能力(blade-common,公共层一次覆盖各模块):
  1) describeReadFailure:把 ExcelDataConvertException 转成
     「第N行「列名」格式不正确:<原值>」,列名按列序号反查 @ExcelProperty;
  2) readRawRows:以原始文本(Map)方式重读文件,不做类型转换因此绝不抛异常,
     用于拿回「原表全部行」;
  3) exportReadFailure:一站式兜底(重读 → 导出全量行 + 仅出错行带原因 +
     按列标红),返回 false 表示非转换类异常,调用方继续抛出保持原行为。
- 26 个导入控制器在现有 catch 之后追加 catch(Exception) 兜底,未改动原有
  行级校验与整批回滚逻辑。

验证:
- 实测维修记录/费用项/币种汇率三个模块:HTTP 500→200,返回全量明细,
  文案如「第5行「费用」格式不正确:一万二千元」(行号经单行/四行文件双向校准)。
- 既有回归无退化:记录类 80/80、台账 32/32、港口码头 45/45、货物类型 24/24、
  费用项 20/20、币种汇率 25/25。
- 新增 scripts/api-read-failure-test.sh(4 断言)固化该场景。
This commit is contained in:
2026-09-26 11:37:07 +08:00
parent be02a5e942
commit 2d185f0de6
26 changed files with 576 additions and 0 deletions
@@ -80,6 +80,348 @@ public class ImportFailureExcelUtil {
addValidationError(validationErrors, value != null && value.length() > maxLength, message);
}
/**
* 把「读取 Excel 阶段」的转换异常转成可读的失败原因。
* <p>
* 背景:单元格里填了无法转换的内容(如金额列填「一万二千元」)时,EasyExcel 会在
* {@code ExcelUtil.read(...)} 阶段就抛 {@link cn.idev.excel.exception.ExcelDataConvertException},
* 根本走不到行级校验;若不兜底,用户看到的是
* 「Convert data ... to class java.math.BigDecimal error」这类技术文案。
* <p>
* 本方法据异常里的行列位置与目标类型,生成「第N行「列名」格式不正确:<原值>」这样
* 用户能看懂、能定位的文案;列名靠表头反射拿 {@code @ExcelProperty},拿不到则退回列序号。
*
* @param exception 读取阶段抛出的异常
* @param excelClass 导入模型类(用于按列序号反查表头名)
* @return 可读的失败原因;非转换类异常返回 null(交由上层按原样处理)
*/
public static String describeReadFailure(Throwable exception, Class<?> excelClass) {
cn.idev.excel.exception.ExcelDataConvertException convertException = findCause(exception,
cn.idev.excel.exception.ExcelDataConvertException.class);
if (convertException == null) {
return null;
}
Integer rowIndex = convertException.getRowIndex();
Integer columnIndex = convertException.getColumnIndex();
String columnName = columnName(excelClass, columnIndex);
// Excel 行号:EasyExcel 的 rowIndex 为 1 基数据行号(表头占第 1 行),故 Excel 行号 = rowIndex + 1
String rowText = rowIndex == null ? "第?行" : "第" + (rowIndex + 1) + "行";
String columnText = columnName == null ? ("第" + (columnIndex == null ? "?" : columnIndex + 1) + "列") : "「" + columnName + "」";
String value = cellValue(convertException);
StringBuilder message = new StringBuilder(rowText).append(columnText).append("格式不正确");
if (value != null && !value.isBlank()) {
message.append(":").append(value);
}
return message.toString();
}
/**
* 在异常链里查找指定类型的 cause
*/
private static <T extends Throwable> T findCause(Throwable throwable, Class<T> type) {
for (Throwable current = throwable; current != null; current = current.getCause()) {
if (type.isInstance(current)) {
return type.cast(current);
}
if (current.getCause() == current) {
break;
}
}
return null;
}
/**
* 按列序号反查表头名(@ExcelProperty 的 value,取不到则用字段名)
*/
private static String columnName(Class<?> excelClass, Integer columnIndex) {
if (excelClass == null || columnIndex == null) {
return null;
}
List<Field> fields = excelFields(excelClass);
if (columnIndex < 0 || columnIndex >= fields.size()) {
return null;
}
Field field = fields.get(columnIndex);
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
if (excelProperty == null) {
return field.getName();
}
String[] value = excelProperty.value();
String columnName = value.length == 0 ? field.getName() : value[0];
return columnName.replace("*", "");
}
/**
* 取触发转换失败的单元格原值
*/
private static String cellValue(cn.idev.excel.exception.ExcelDataConvertException exception) {
try {
cn.idev.excel.metadata.data.CellData<?> cellData = exception.getCellData();
if (cellData == null) {
return null;
}
Object data = cellData.getData();
if (data == null) {
data = cellData.getStringValue();
}
return data == null ? null : String.valueOf(data).trim();
} catch (Exception ignored) {
return null;
}
}
/**
* 以「原始单元格文本」方式重读导入文件,得到的行用于构造读取阶段的失败明细。
* <p>
* 用途:当 {@code ExcelUtil.read(file, XxxExcel.class)} 因单元格无法转换而失败时,
* 已解析的实体列表是不完整的(异常中断了整批读取)。此时用本方法重读一次:
* 不做类型转换(直接读成 Map),因此绝不会再抛转换异常,可拿到**原表全部行**,
* 供导出「原表全量 + 仅出错行带原因」的失败明细。
*
* @param file 上传的 Excel 文件
* @param excelClass 导入模型类(按列序号反查表头名,用于把列序号映射成列名)
* @return 按行组织的明细数据:每行是「表头名 → 单元格文本」的有序 Map;
* 读不出内容时返回空列表(调用方按空明细处理)
*/
public static List<Map<String, String>> readRawRows(java.io.InputStream file, Class<?> excelClass) {
List<Map<Integer, String>> rawRows = new ArrayList<>();
try {
cn.idev.excel.read.builder.ExcelReaderBuilder builder = cn.idev.excel.FastExcel
.read(file, new cn.idev.excel.event.AnalysisEventListener<Map<Integer, String>>() {
@Override
public void invoke(Map<Integer, String> data, cn.idev.excel.context.AnalysisContext context) {
rawRows.add(new java.util.LinkedHashMap<>(data));
}
@Override
public void doAfterAllAnalysed(cn.idev.excel.context.AnalysisContext context) {
// 无需处理
}
});
builder.headRowNumber(1).sheet().doRead();
} catch (Exception ignored) {
// 重读本身失败时退回空明细,由调用方按「无法读取」提示
return new ArrayList<>();
}
List<Field> fields = excelClass == null ? List.of() : excelFields(excelClass);
List<Map<String, String>> rows = new ArrayList<>();
for (Map<Integer, String> rawRow : rawRows) {
Map<String, String> row = new java.util.LinkedHashMap<>();
for (Map.Entry<Integer, String> entry : rawRow.entrySet()) {
Integer columnIndex = entry.getKey();
String columnName = columnIndex != null && columnIndex < fields.size()
? columnName(excelClass, columnIndex)
: null;
if (columnName == null) {
columnName = "第" + (columnIndex == null ? "?" : columnIndex + 1) + "列";
}
row.put(columnName, entry.getValue());
}
rows.add(row);
}
return rows;
}
/**
* 导出「读取阶段失败」明细:原表全量行 + 末尾「导入失败原因」列。
* <p>
* 与 {@link #export} 的区别:本方法直接按重读得到的「表头名 → 单元格文本」写出,
* 不依赖导入模型的字段反射,因此适用于「单元格无法转换为实体字段」的场景
* (此时实体列表不完整,无法走常规导出)。
*
* @param response 响应
* @param fileName 文件名
* @param sheetName 工作表名
* @param rawRows {@link #readRawRows} 得到的原表全部行
* @param failureMessageByRow 行号(0 基,对应 rawRows 下标)→ 该行失败原因;为 null 表示该行无错
*/
public static void exportRawFailure(HttpServletResponse response, String fileName, String sheetName,
List<Map<String, String>> rawRows, Map<Integer, String> failureMessageByRow) {
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
String encodeFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
response.setHeader("Content-disposition", "attachment;filename=" + encodeFileName + ".xlsx");
List<String> headers = new ArrayList<>();
for (Map<String, String> row : rawRows) {
for (String key : row.keySet()) {
if (!headers.contains(key)) {
headers.add(key);
}
}
}
headers.add(FAILURE_REASON);
List<List<String>> head = new ArrayList<>();
for (String header : headers) {
head.add(List.of(header));
}
List<List<Object>> rows = new ArrayList<>();
Map<Integer, Set<Integer>> redColumnsByRow = new HashMap<>();
for (int rowIndex = 0; rowIndex < rawRows.size(); rowIndex++) {
Map<String, String> rawRow = rawRows.get(rowIndex);
List<Object> row = new ArrayList<>();
Set<Integer> redColumns = new LinkedHashSet<>();
// 失败原因列恒标红
redColumns.add(headers.size() - 1);
String failureMessage = failureMessageByRow == null ? null : failureMessageByRow.get(rowIndex);
for (int columnIndex = 0; columnIndex < headers.size() - 1; columnIndex++) {
String header = headers.get(columnIndex);
row.add(rawRow.get(header));
// 失败原因里出现该列名时,标红该列单元格
if (failureMessage != null && !failureMessage.isBlank() && failureMessage.contains("「" + header + "」")) {
redColumns.add(columnIndex);
}
}
row.add(failureMessage == null ? "" : failureMessage);
rows.add(row);
redColumnsByRow.put(rowIndex, redColumns);
}
try {
FastExcel.write(response.getOutputStream())
.registerWriteHandler(new RawFailureCellStyleHandler(rows, redColumnsByRow))
.head(head)
.sheet(sheetName)
.doWrite(rows);
} catch (IOException exception) {
throw new IllegalStateException("导出导入失败明细失败", exception);
}
}
/**
* 读取阶段失败明细的单元格样式处理器:统一列宽 + 标红失败原因列与出错列
*/
private static class RawFailureCellStyleHandler implements CellWriteHandler {
private final int failureReasonColumnIndex;
private final Map<Integer, Set<Integer>> redColumnsByRow;
private final Map<Short, CellStyle> redStyleCache = new HashMap<>();
private final Map<Short, CellStyle> noWrapStyleCache = new HashMap<>();
private final Map<Integer, Integer> columnWidthCache = new HashMap<>();
private RawFailureCellStyleHandler(List<List<Object>> rows, Map<Integer, Set<Integer>> redColumnsByRow) {
this.failureReasonColumnIndex = rows.isEmpty() ? 0 : rows.get(0).size() - 1;
this.redColumnsByRow = redColumnsByRow;
}
@Override
public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder,
List<WriteCellData<?>> cellDataList, Cell cell, cn.idev.excel.metadata.Head head,
Integer relativeRowIndex, Boolean isHead) {
if (Boolean.TRUE.equals(isHead)) {
adjustColumnWidth(cell);
markNoWrap(cell);
return;
}
adjustColumnWidth(cell);
if (relativeRowIndex == null || relativeRowIndex < 0) {
return;
}
Set<Integer> redColumns = redColumnsByRow.get(relativeRowIndex);
if (redColumns != null && redColumns.contains(cell.getColumnIndex())) {
markRed(cell);
}
}
private void markRed(Cell cell) {
CellStyle currentStyle = cell.getCellStyle();
CellStyle redStyle = redStyleCache.computeIfAbsent(currentStyle.getIndex(), styleIndex -> {
Workbook workbook = cell.getSheet().getWorkbook();
CellStyle newStyle = workbook.createCellStyle();
newStyle.cloneStyleFrom(currentStyle);
Font font = workbook.createFont();
font.setColor(IndexedColors.RED.getIndex());
newStyle.setFont(font);
newStyle.setWrapText(true);
return newStyle;
});
cell.setCellStyle(redStyle);
}
private void markNoWrap(Cell cell) {
CellStyle currentStyle = cell.getCellStyle();
CellStyle noWrapStyle = noWrapStyleCache.computeIfAbsent(currentStyle.getIndex(), styleIndex -> {
CellStyle newStyle = cell.getSheet().getWorkbook().createCellStyle();
newStyle.cloneStyleFrom(currentStyle);
newStyle.setWrapText(false);
return newStyle;
});
cell.setCellStyle(noWrapStyle);
}
private void adjustColumnWidth(Cell cell) {
int columnIndex = cell.getColumnIndex();
int columnWidth = Math.min(Math.max(displayWidth(cell.toString()) + 4, 12), 80) * 256;
Integer currentWidth = columnWidthCache.get(columnIndex);
if (currentWidth == null || columnWidth > currentWidth) {
columnWidthCache.put(columnIndex, columnWidth);
cell.getSheet().setColumnWidth(columnIndex, columnWidth);
}
}
private int displayWidth(String value) {
int width = 0;
for (int index = 0; index < value.length(); index++) {
width += value.charAt(index) > 255 ? 2 : 1;
}
return width;
}
}
/**
* 读取阶段异常的一站式兜底:重读原表 → 导出「全量行 + 出错行原因」失败明细。
* <p>
* 控制器用法:在导入的 try 块末尾追加
* <pre>
* } catch (Exception exception) {
* if (ImportFailureExcelUtil.exportReadFailure(response, fileName, sheetName, file, XxxExcel.class, exception)) {
* return null;
* }
* throw exception;
* }
* </pre>
*
* @return true 表示已识别为「单元格转换失败」并已写出明细(调用方直接 return null);
* false 表示不是转换类异常(调用方应继续抛出,保持原有处理)
*/
public static boolean exportReadFailure(HttpServletResponse response, String fileName, String sheetName,
org.springframework.web.multipart.MultipartFile file,
Class<?> excelClass, Throwable exception) {
String failureMessage = describeReadFailure(exception, excelClass);
if (failureMessage == null) {
return false;
}
List<Map<String, String>> rawRows;
try (java.io.InputStream inputStream = file.getInputStream()) {
rawRows = readRawRows(inputStream, excelClass);
} catch (IOException ioException) {
rawRows = new ArrayList<>();
}
Map<Integer, String> failureMessageByRow = new HashMap<>();
if (rawRows.isEmpty()) {
// 重读也拿不到内容时,退化为「仅失败原因」一行,至少让用户看到可读原因
Map<String, String> single = new java.util.LinkedHashMap<>();
single.put(FAILURE_REASON, failureMessage);
rawRows.add(single);
failureMessageByRow.put(0, failureMessage);
exportRawFailure(response, fileName, sheetName, rawRows, failureMessageByRow);
return true;
}
// 异常里的数据行下标(0 基)用于把原因挂到对应行
Integer rowIndex = readFailureRowIndex(exception);
int targetRow = rowIndex == null ? 0 : Math.max(0, Math.min(rowIndex, rawRows.size() - 1));
failureMessageByRow.put(targetRow, failureMessage);
exportRawFailure(response, fileName, sheetName, rawRows, failureMessageByRow);
return true;
}
/**
* 取转换异常中的数据行下标(0 基)
*/
private static Integer readFailureRowIndex(Throwable exception) {
cn.idev.excel.exception.ExcelDataConvertException convertException = findCause(exception,
cn.idev.excel.exception.ExcelDataConvertException.class);
return convertException == null ? null : convertException.getRowIndex();
}
private static final String FAILURE_REASON = "导入失败原因";
private static final String ERROR_MESSAGE_FIELD = "errorMessage";
private static final String FAILURE_REASON_FIELD = "failureReason";