Merge remote-tracking branch 'refs/remotes/origin/master' into feature/payment-apply-bfi-sync
# Conflicts: # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.common.excel;
|
||||
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* 单元格读取失败异常 —— 用于「异常不是 fastexcel 抛出、但同样发生在读取阶段」的场景。
|
||||
* <p>
|
||||
* fastexcel 自身解析失败时抛 {@code ExcelDataConvertException},位置信息齐全,
|
||||
* {@link ImportFailureExcelUtil#exportReadFailure} 可以直接定位到行与列。但有些模块
|
||||
* 在读取阶段有自己的解析逻辑(例如运输对账的动态费用列:模板列不固定,由
|
||||
* {@code VehicleReconciliationFeeReader} 自行按表头解析),这些代码如果只是
|
||||
* {@code catch (NumberFormatException)} 后返回 {@code BigDecimal.ZERO},
|
||||
* 脏数据就会静默入库(费用 0.00 还会参与结算金额与自动匹配)。
|
||||
* <p>
|
||||
* 正确做法是改抛本异常,把位置信息补齐,让导出工具能把原因挂到正确的行、标红正确的列。
|
||||
* <p>
|
||||
* 本类位于 blade-common,供 blade-system 与 blade-transport 等模块共用。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public class ImportCellReadException extends ServiceException {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 出错的单元格所在的 Excel 行号,<b>绝对 0 基</b>(表头占 0,第一个数据行为 1)。
|
||||
* 与 {@code ExcelDataConvertException.getRowIndex()} 保持同一口径,
|
||||
* 便于导出工具统一换算。
|
||||
*/
|
||||
private final transient Integer rowIndex;
|
||||
|
||||
/**
|
||||
* 出错的列名(导入模板表头原文),会写进原因文案里,导出工具靠它标红对应列。
|
||||
*/
|
||||
private final transient String columnName;
|
||||
|
||||
public ImportCellReadException(Integer rowIndex, String columnName, String cellValue) {
|
||||
super(buildMessage(rowIndex, columnName, cellValue));
|
||||
this.rowIndex = rowIndex;
|
||||
this.columnName = columnName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文案格式与 {@link ImportFailureExcelUtil#describeReadFailure} 保持一致:
|
||||
* 「第N行「列名」格式不正确:<原值>」。列名必须包在「」里,导出工具才能标红该列。
|
||||
*/
|
||||
private static String buildMessage(Integer rowIndex, String columnName, String cellValue) {
|
||||
String rowText = rowIndex == null ? "第?行" : "第" + (rowIndex + 1) + "行";
|
||||
String columnText = columnName == null || columnName.isBlank() ? "该列" : "「" + columnName + "」";
|
||||
StringBuilder message = new StringBuilder(rowText).append(columnText).append("格式不正确");
|
||||
if (cellValue != null && !cellValue.isBlank()) {
|
||||
message.append(":").append(cellValue);
|
||||
}
|
||||
return message.toString();
|
||||
}
|
||||
|
||||
public Integer getRowIndex() {
|
||||
return rowIndex;
|
||||
}
|
||||
|
||||
public String getColumnName() {
|
||||
return columnName;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -45,7 +45,9 @@ import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -80,6 +82,443 @@ 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) {
|
||||
// 不是 fastexcel 抛的转换异常:模块自带的读取期解析逻辑(如运输对账的动态费用列)
|
||||
// 用 ImportCellReadException 补齐了位置信息,直接透传它已经拼好的原因文案。
|
||||
ImportCellReadException cellReadException = findCause(exception, ImportCellReadException.class);
|
||||
return cellReadException == null ? null : cellReadException.getMessage();
|
||||
}
|
||||
Integer rowIndex = convertException.getRowIndex();
|
||||
Integer columnIndex = convertException.getColumnIndex();
|
||||
String columnName = columnName(excelClass, columnIndex);
|
||||
// Excel 行号:getRowIndex() 是绝对 0 基行号(表头占 0),故 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>
|
||||
* 优先使用 Excel 文件真实表头(含动态费用列等模型类未声明的列),保证失败明细
|
||||
* 与原导入模板列一致,用户修正后可直接用该文件重新导入。
|
||||
* <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<>();
|
||||
Map<Integer, String> headerByIndex = new LinkedHashMap<>();
|
||||
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 invokeHeadMap(Map<Integer, String> headMap, cn.idev.excel.context.AnalysisContext context) {
|
||||
if (headMap == null) {
|
||||
return;
|
||||
}
|
||||
headMap.forEach((index, header) -> {
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
String name = header == null ? "" : header.trim();
|
||||
// 历史失败明细再导入时忽略旧的失败原因列,导出时重新追加
|
||||
if (!name.isEmpty() && !FAILURE_REASON.equals(name)) {
|
||||
headerByIndex.put(index, name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invoke(Map<Integer, String> data, cn.idev.excel.context.AnalysisContext context) {
|
||||
rawRows.add(data == null ? new LinkedHashMap<>() : new LinkedHashMap<>(data));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doAfterAllAnalysed(cn.idev.excel.context.AnalysisContext context) {
|
||||
// 无需处理
|
||||
}
|
||||
});
|
||||
builder.headRowNumber(1).sheet().doRead();
|
||||
} catch (Exception ignored) {
|
||||
// 重读本身失败时退回空明细,由调用方按「无法读取」提示
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<Map<String, String>> rows = new ArrayList<>();
|
||||
for (Map<Integer, String> rawRow : rawRows) {
|
||||
Map<String, String> row = new LinkedHashMap<>();
|
||||
Set<Integer> columnIndexes = new LinkedHashSet<>(headerByIndex.keySet());
|
||||
if (rawRow != null) {
|
||||
columnIndexes.addAll(rawRow.keySet());
|
||||
}
|
||||
List<Integer> sortedIndexes = new ArrayList<>(columnIndexes);
|
||||
Collections.sort(sortedIndexes);
|
||||
for (Integer columnIndex : sortedIndexes) {
|
||||
if (columnIndex == null) {
|
||||
continue;
|
||||
}
|
||||
String columnName = headerByIndex.get(columnIndex);
|
||||
if (columnName == null || columnName.isBlank()) {
|
||||
columnName = columnName(excelClass, columnIndex);
|
||||
}
|
||||
if (columnName == null || columnName.isBlank()) {
|
||||
columnName = "第" + (columnIndex + 1) + "列";
|
||||
}
|
||||
if (FAILURE_REASON.equals(columnName)) {
|
||||
continue;
|
||||
}
|
||||
row.put(columnName, rawRow == null ? null : rawRow.get(columnIndex));
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按原上传文件真实表头导出失败明细(含动态列),并追加「导入失败原因」。
|
||||
* <p>
|
||||
* 适用于运输对账等「模板列 = 固定列 + 动态费用列」场景:按实体字段反射导出
|
||||
* 会丢掉动态列,用户无法用失败文件修正后重新导入。本方法重读原文件全量列,
|
||||
* 再按 failureList 行序挂上失败原因。
|
||||
*
|
||||
* @param response 响应
|
||||
* @param fileName 文件名
|
||||
* @param sheetName 工作表名
|
||||
* @param file 原导入文件
|
||||
* @param failureList 校验失败列表(与原表行序一致,成功行 errorMessage 可为空)
|
||||
* @param excelClass 导入模型类(原文件重读失败时回退到按实体导出)
|
||||
*/
|
||||
public static void exportOriginalWithFailures(HttpServletResponse response, String fileName, String sheetName,
|
||||
org.springframework.web.multipart.MultipartFile file,
|
||||
List<?> failureList, Class<?> excelClass) {
|
||||
List<Map<String, String>> rawRows = new ArrayList<>();
|
||||
if (file != null && !file.isEmpty()) {
|
||||
try (java.io.InputStream inputStream = file.getInputStream()) {
|
||||
rawRows = readRawRows(inputStream, excelClass);
|
||||
} catch (IOException ignored) {
|
||||
rawRows = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
if (rawRows.isEmpty()) {
|
||||
// 原文件读不出时回退实体导出,至少保证失败原因可下载
|
||||
if (failureList != null && !failureList.isEmpty()) {
|
||||
export(response, fileName, sheetName, failureList, excelClass);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Map<Integer, String> failureMessageByRow = new HashMap<>();
|
||||
if (failureList != null) {
|
||||
for (int index = 0; index < failureList.size(); index++) {
|
||||
Object message = errorMessage(failureList.get(index));
|
||||
if (message != null && !String.valueOf(message).isBlank()) {
|
||||
failureMessageByRow.put(index, String.valueOf(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
exportRawFailure(response, fileName, sheetName, rawRows, failureMessageByRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出「读取阶段失败」明细:原表全量行 + 末尾「导入失败原因」列。
|
||||
* <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<>();
|
||||
List<List<String>> headerKeywords = new ArrayList<>();
|
||||
for (int columnIndex = 0; columnIndex < headers.size() - 1; columnIndex++) {
|
||||
headerKeywords.add(headerKeywords(headers.get(columnIndex)));
|
||||
}
|
||||
for (int rowIndex = 0; rowIndex < rawRows.size(); rowIndex++) {
|
||||
Map<String, String> rawRow = rawRows.get(rowIndex);
|
||||
List<Object> row = new ArrayList<>();
|
||||
String failureMessage = failureMessageByRow == null ? null : failureMessageByRow.get(rowIndex);
|
||||
for (int columnIndex = 0; columnIndex < headers.size() - 1; columnIndex++) {
|
||||
row.add(rawRow.get(headers.get(columnIndex)));
|
||||
}
|
||||
row.add(failureMessage == null ? "" : failureMessage);
|
||||
rows.add(row);
|
||||
redColumnsByRow.put(rowIndex, resolveRawRedColumns(failureMessage, headerKeywords, headers.size() - 1));
|
||||
}
|
||||
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 基,只对数据行计数),供挂失败原因使用。
|
||||
* <p>
|
||||
* 无论哪种异常,源信息都是<b>绝对 0 基行号</b>(表头占 0,第一个数据行为 1),
|
||||
* 而这里要的下标是相对数据行的,故统一减 1。
|
||||
* 不减的话原因会整体下移一行:错误恰好落在最后一行时会因调用处的 clamping
|
||||
* 侥幸正确,落在首行/中间行就会标到不该标的行上,误导用户去改正确的那一行。
|
||||
*/
|
||||
private static Integer readFailureRowIndex(Throwable exception) {
|
||||
Integer excelRowIndex = readFailureExcelRowIndex(exception);
|
||||
return excelRowIndex == null ? null : Math.max(0, excelRowIndex - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取读取阶段异常的绝对 0 基行号(表头 = 0)。
|
||||
* <p>
|
||||
* 两条来源统一到这里:fastexcel 自带的 {@code ExcelDataConvertException},
|
||||
* 以及模块自行解析时抛出的 {@link ImportCellReadException}。
|
||||
*/
|
||||
private static Integer readFailureExcelRowIndex(Throwable exception) {
|
||||
cn.idev.excel.exception.ExcelDataConvertException convertException = findCause(exception,
|
||||
cn.idev.excel.exception.ExcelDataConvertException.class);
|
||||
if (convertException != null) {
|
||||
return convertException.getRowIndex();
|
||||
}
|
||||
ImportCellReadException cellReadException = findCause(exception, ImportCellReadException.class);
|
||||
return cellReadException == null ? null : cellReadException.getRowIndex();
|
||||
}
|
||||
|
||||
private static final String FAILURE_REASON = "导入失败原因";
|
||||
private static final String ERROR_MESSAGE_FIELD = "errorMessage";
|
||||
private static final String FAILURE_REASON_FIELD = "failureReason";
|
||||
@@ -247,6 +686,69 @@ public class ImportFailureExcelUtil {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按真实表头生成定位关键词(用于原表失败明细标红)
|
||||
*/
|
||||
private static List<String> headerKeywords(String header) {
|
||||
if (header == null || header.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> keywords = new ArrayList<>();
|
||||
keywords.add(header);
|
||||
keywords.addAll(Arrays.asList(header.replace("*", "").split("[//、()()\\s]+")));
|
||||
return keywords.stream()
|
||||
.map(ImportFailureExcelUtil::normalize)
|
||||
.filter(keyword -> keyword.length() >= 2)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按失败原因命中表头关键词,计算需要标红的列(含失败原因列)
|
||||
*/
|
||||
private static Set<Integer> resolveRawRedColumns(String failureMessage, List<List<String>> headerKeywords,
|
||||
int failureReasonColumnIndex) {
|
||||
Set<Integer> redColumns = new LinkedHashSet<>();
|
||||
redColumns.add(failureReasonColumnIndex);
|
||||
String normalizedReason = normalize(failureMessage);
|
||||
if (normalizedReason.isEmpty()) {
|
||||
return redColumns;
|
||||
}
|
||||
List<int[]> matches = new ArrayList<>();
|
||||
for (int column = 0; column < headerKeywords.size(); column++) {
|
||||
for (String keyword : headerKeywords.get(column)) {
|
||||
int fromIndex = 0;
|
||||
while (true) {
|
||||
int index = normalizedReason.indexOf(keyword, fromIndex);
|
||||
if (index < 0) {
|
||||
break;
|
||||
}
|
||||
matches.add(new int[]{index, index + keyword.length(), column});
|
||||
fromIndex = index + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int[] match : matches) {
|
||||
if (!isRawMatchSubsumed(match, matches)) {
|
||||
redColumns.add(match[2]);
|
||||
}
|
||||
}
|
||||
return redColumns;
|
||||
}
|
||||
|
||||
private static boolean isRawMatchSubsumed(int[] match, List<int[]> matches) {
|
||||
int matchLength = match[1] - match[0];
|
||||
for (int[] other : matches) {
|
||||
if (other[2] == match[2]) {
|
||||
continue;
|
||||
}
|
||||
if ((other[1] - other[0]) > matchLength && other[0] <= match[0] && other[1] >= match[1]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static class ImportFailureCellStyleHandler implements CellWriteHandler {
|
||||
|
||||
private final int failureReasonColumnIndex;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.common.excel;
|
||||
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 导入失败异常,携带失败明细用于导出原表并标注错误。
|
||||
* <p>
|
||||
* 批量导入采用「任一行失败即整批回滚」语义:循环内先收集完全部错误明细,
|
||||
* 再抛出本异常触发事务回滚。明细在抛异常前已收集完毕,因此回滚不影响明细内容。
|
||||
* <p>
|
||||
* 注意:仅标注 {@code @Transactional} 并不足够。若循环内把异常吞掉(catch 后
|
||||
* 只往 errorList 塞数据并正常 return),方法会正常返回,Spring 会照常提交事务。
|
||||
* 必须抛出异常才能让事务真正回滚。
|
||||
* <p>
|
||||
* 本类位于 blade-common,供 blade-system 与 blade-transport 等模块共用。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public class ImportFailureException extends ServiceException {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 失败明细,包含原表全部数据,错误行已标注错误原因。
|
||||
*/
|
||||
private final transient List<?> failureList;
|
||||
|
||||
public ImportFailureException(List<?> failureList) {
|
||||
super("导入失败,已回滚全部数据");
|
||||
this.failureList = failureList;
|
||||
}
|
||||
|
||||
public List<?> getFailureList() {
|
||||
return failureList;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user