Add import dropdown validation

This commit is contained in:
wkc
2026-05-06 14:04:21 +08:00
parent 0b64532959
commit c00d5475e6
8 changed files with 386 additions and 10 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -2,19 +2,36 @@ package com.ruoyi.info.collection.utils;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.handler.WriteHandler;
import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.info.collection.handler.DictDropdownWriteHandler;
import com.ruoyi.info.collection.handler.RequiredFieldWriteHandler;
import com.ruoyi.info.collection.handler.TextFormatWriteHandler;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.ss.util.CellRangeAddress;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
@@ -77,8 +94,10 @@ public class EasyExcelUtil {
* @return 数据列表
*/
public static <T> List<T> importExcel(String fileName, Class<T> clazz) {
try {
return EasyExcel.read(fileName).head(clazz).sheet().doReadSync();
try (InputStream inputStream = java.nio.file.Files.newInputStream(java.nio.file.Path.of(fileName))) {
return importExcel(inputStream, clazz);
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导入Excel失败", e);
}
@@ -94,7 +113,11 @@ public class EasyExcelUtil {
*/
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz) {
try {
return EasyExcel.read(inputStream).head(clazz).sheet().doReadSync();
byte[] bytes = inputStream.readAllBytes();
validateDictDropdownTemplate(bytes, clazz, null);
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet().doReadSync();
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导入Excel失败", e);
}
@@ -111,7 +134,11 @@ public class EasyExcelUtil {
*/
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz, String sheetName) {
try {
return EasyExcel.read(inputStream).head(clazz).sheet(sheetName).doReadSync();
byte[] bytes = inputStream.readAllBytes();
validateDictDropdownTemplate(bytes, clazz, sheetName);
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet(sheetName).doReadSync();
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导入Excel失败", e);
}
@@ -128,7 +155,7 @@ public class EasyExcelUtil {
public static <T> void importTemplateExcel(HttpServletResponse response, Class<T> clazz, String sheetName) {
try {
setResponseHeader(response, sheetName + "模板");
EasyExcel.write(response.getOutputStream(), clazz)
templateWriter(response, clazz)
.sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.doWrite(List.of());
@@ -151,7 +178,7 @@ public class EasyExcelUtil {
WriteHandler... handlers) {
try {
setResponseHeader(response, sheetName + "模板");
var writerBuilder = EasyExcel.write(response.getOutputStream(), clazz)
var writerBuilder = templateWriter(response, clazz)
.sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy());
// 注册所有自定义处理器
@@ -190,7 +217,7 @@ public class EasyExcelUtil {
public static <T> void importTemplateWithDictDropdown(HttpServletResponse response, Class<T> clazz, String sheetName) {
try {
setResponseHeader(response, sheetName + "模板");
EasyExcel.write(response.getOutputStream(), clazz)
templateWriter(response, clazz)
.sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new DictDropdownWriteHandler(clazz))
@@ -217,7 +244,7 @@ public class EasyExcelUtil {
String sheetName, String fileName) {
try {
setResponseHeader(response, fileName);
EasyExcel.write(response.getOutputStream(), clazz)
templateWriter(response, clazz)
.sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new DictDropdownWriteHandler(clazz))
@@ -250,7 +277,7 @@ public class EasyExcelUtil {
String fileName
) {
setResponseHeader(response, fileName);
try (ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build()) {
try (ExcelWriter writer = templateWriter(response).build()) {
writer.write(List.of(), buildTemplateSheet(0, firstClazz, firstSheetName));
writer.write(List.of(), buildTemplateSheet(1, secondClazz, secondSheetName));
} catch (IOException e) {
@@ -322,4 +349,137 @@ public class EasyExcelUtil {
throw new RuntimeException("导出带字典下拉框的Excel失败", e);
}
}
private static void validateDictDropdownTemplate(byte[] bytes, Class<?> clazz, String sheetName) {
List<DropdownColumn> dropdownColumns = resolveDropdownColumns(clazz);
if (dropdownColumns.isEmpty()) {
return;
}
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) {
Sheet sheet = sheetName == null ? workbook.getSheetAt(0) : workbook.getSheet(sheetName);
if (sheet == null) {
return;
}
int lastDataRowIndex = findLastDataRowIndex(sheet);
if (lastDataRowIndex < 1) {
return;
}
List<String> missingColumnTitles = new ArrayList<>();
for (DropdownColumn column : dropdownColumns) {
if (!isListValidationCovered(sheet, column.index(), lastDataRowIndex)) {
missingColumnTitles.add(column.title());
}
}
if (!missingColumnTitles.isEmpty()) {
throw new ServiceException(sheet.getSheetName() + " Sheet 的 "
+ String.join("", missingColumnTitles)
+ " 列缺少下拉框,请下载最新导入模板填写后重新导入");
}
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导入Excel失败", e);
}
}
private static List<DropdownColumn> resolveDropdownColumns(Class<?> clazz) {
List<DropdownColumn> columns = new ArrayList<>();
Class<?> current = clazz;
while (current != null && current != Object.class) {
for (Field field : current.getDeclaredFields()) {
if (field.getAnnotation(DictDropdown.class) == null) {
continue;
}
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
if (excelProperty == null || excelProperty.index() < 0) {
continue;
}
columns.add(new DropdownColumn(excelProperty.index(), resolveColumnTitle(field, excelProperty)));
}
current = current.getSuperclass();
}
columns.sort(Comparator.comparingInt(DropdownColumn::index));
return columns;
}
private static String resolveColumnTitle(Field field, ExcelProperty excelProperty) {
if (excelProperty.value().length > 0 && excelProperty.value()[0] != null
&& !excelProperty.value()[0].isBlank()) {
return excelProperty.value()[0].replace("*", "");
}
return field.getName();
}
private static int findLastDataRowIndex(Sheet sheet) {
int lastDataRowIndex = -1;
for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (hasData(row)) {
lastDataRowIndex = rowIndex;
}
}
return lastDataRowIndex;
}
private static boolean hasData(Row row) {
if (row == null || row.getLastCellNum() < 0) {
return false;
}
for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
if (cellIndex < 0) {
continue;
}
Cell cell = row.getCell(cellIndex);
if (cell != null && cell.toString() != null && !cell.toString().isBlank()) {
return true;
}
}
return false;
}
private static boolean isListValidationCovered(Sheet sheet, int columnIndex, int lastDataRowIndex) {
boolean[] coveredRows = new boolean[lastDataRowIndex + 1];
for (DataValidation validation : sheet.getDataValidations()) {
DataValidationConstraint constraint = validation.getValidationConstraint();
if (constraint == null || constraint.getValidationType() != DataValidationConstraint.ValidationType.LIST) {
continue;
}
for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) {
if (address.getFirstColumn() > columnIndex || address.getLastColumn() < columnIndex) {
continue;
}
int firstRow = Math.max(1, address.getFirstRow());
int lastRow = Math.min(lastDataRowIndex, address.getLastRow());
for (int rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
coveredRows[rowIndex] = true;
}
}
}
for (int rowIndex = 1; rowIndex <= lastDataRowIndex; rowIndex++) {
if (hasData(sheet.getRow(rowIndex)) && !coveredRows[rowIndex]) {
return false;
}
}
return true;
}
private record DropdownColumn(int index, String title) {}
private static <T> ExcelWriterBuilder templateWriter(HttpServletResponse response, Class<T> clazz)
throws IOException {
// 模板为空且体量小,使用内存工作簿避免 SXSSF 在无字体环境初始化 Fontconfig。
return EasyExcel.write(response.getOutputStream(), clazz).inMemory(Boolean.TRUE);
}
private static ExcelWriterBuilder templateWriter(HttpServletResponse response) throws IOException {
return EasyExcel.write(response.getOutputStream()).inMemory(Boolean.TRUE);
}
}

View File

@@ -42,7 +42,7 @@
AND e.status = #{query.status}
</if>
</where>
ORDER BY e.create_time DESC
ORDER BY e.create_time DESC, e.staff_id DESC
</select>
<!-- 批量插入或更新员工信息只更新非null字段 -->

View File

@@ -21,4 +21,15 @@ class CcdiBaseStaffMapperTest {
assertTrue(xml.contains("#{item.partyMember}"), xml);
}
}
@Test
void mapperXml_shouldUseStableOrderForBaseStaffPagination() throws Exception {
try (InputStream inputStream = getClass().getClassLoader()
.getResourceAsStream("mapper/info/collection/CcdiBaseStaffMapper.xml")) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8)
.replaceAll("\\s+", " ");
assertTrue(xml.contains("ORDER BY e.create_time DESC, e.staff_id DESC"), xml);
}
}
}

View File

@@ -0,0 +1,89 @@
# 员工信息维护导入功能真实页面测试记录
## 测试范围
- 页面:`http://localhost:8080/maintain/baseStaff`
- 后端:`http://localhost:62318`
- 模块:员工信息维护导入
- 测试方式:`browser-use` 打开真实业务页面,确认导入入口、模板下载和页面列表/详情展示;使用真实模板生成测试工作簿后调用同一导入接口上传,轮询导入任务状态并查询失败记录。
## 测试文件
- 真实模板:`output/spreadsheet/base_staff_import_template_20260430.xlsx`
- 空模板:`output/spreadsheet/base_staff_import_empty_20260430.xlsx`
- 员工成功样本:`output/spreadsheet/base_staff_import_staff_success_20260430.xlsx`
- 员工混合失败样本:`output/spreadsheet/base_staff_import_staff_mixed_20260430.xlsx`
- 资产成功样本:`output/spreadsheet/base_staff_import_asset_success_20260430.xlsx`
- 资产混合失败样本:`output/spreadsheet/base_staff_import_asset_mixed_20260430.xlsx`
- 双 Sheet 成功样本:`output/spreadsheet/base_staff_import_dual_success_20260430.xlsx`
## 页面验证
1. 打开真实员工信息维护页面,确认页面标题、查询区、列表、单一“导入”按钮加载正常。
2. 打开“员工信息维护导入”弹窗,确认存在“下载模板”入口。
3. 点击“下载模板”,页面触发真实模板下载。
4. 弹窗提示确认:
- 模板包含“员工信息”和“员工资产信息”两个 Sheet。
- 两个 Sheet 可单独填写,也可同时填写。
- 员工信息命中现有员工直接报错。
- 资产信息仅支持员工本人资产。
5. 页面查询员工 `9843001`,确认成功导入员工 `Codex导入员工A` 可在真实列表展示。
6. 打开员工 `9843001` 详情,确认资产 `Codex资产住宅A` 展示在资产信息区。
7. 页面查询员工 `9843004`,确认双 Sheet 成功样本中的员工 `Codex导入员工D` 可在真实列表展示。
## 接口与异步任务验证
| 场景 | 文件 | 结果 |
| --- | --- | --- |
| 空模板 | `base_staff_import_empty_20260430.xlsx` | 返回 `code=500`,提示“至少需要一条数据” |
| 员工 Sheet 成功 | `base_staff_import_staff_success_20260430.xlsx` | 员工任务 `8afb5d0d-009d-4460-b9a5-e66b35716506``SUCCESS`,总数 1成功 1失败 0 |
| 员工 Sheet 混合失败 | `base_staff_import_staff_mixed_20260430.xlsx` | 员工任务 `c57fc240-c50f-42a7-8d77-1614c46789ec``PARTIAL_SUCCESS`,总数 7成功 1失败 6 |
| 资产 Sheet 成功 | `base_staff_import_asset_success_20260430.xlsx` | 资产任务 `8f266611-10ef-471f-8cc7-92b5b65cac45``SUCCESS`,总数 1成功 1失败 0 |
| 资产 Sheet 混合失败 | `base_staff_import_asset_mixed_20260430.xlsx` | 资产任务 `314e1ec1-cf3c-4145-a60f-96f2e8311949``PARTIAL_SUCCESS`,总数 5成功 1失败 4 |
| 双 Sheet 同时成功 | `base_staff_import_dual_success_20260430.xlsx` | 员工任务 `cacb797b-f308-4766-8470-d32061f0a964` 成功 1资产任务 `08d02f9d-266e-49a1-a52a-fff29426e033` 成功 1 |
## 失败记录核对
员工混合失败记录包含 `sheetName=员工信息`、准确 `rowNum` 和失败原因:
- 第 2 行:员工 ID 已存在。
- 第 3 行:姓名不能为空。
- 第 4 行:所属部门 ID 不存在或已停用/删除。
- 第 5 行:身份证号长度必须为 18 位。
- 第 6 行:年收入不能为负数。
- 第 8 行:员工 ID 在导入文件中重复。
资产混合失败记录包含 `sheetName=员工资产信息`、准确 `rowNum` 和失败原因:
- 第 2 行:资产记录已存在。
- 第 3 行:员工资产导入仅支持员工本人证件号。
- 第 4 行:资产名称不能为空。
- 第 6 行:资产记录在导入文件中重复。
## 数据回查
- 员工 `9843001`:列表接口返回 1 条,姓名 `Codex导入员工A`,部门 `研发部门`,状态 `在职`
- 员工 `9843003`:列表接口返回 1 条,姓名 `Codex导入员工C`,验证混合失败文件中的成功行已入库。
- 员工 `9843004`:列表接口返回 1 条,姓名 `Codex导入员工D`,验证双 Sheet 员工任务成功入库。
- 员工 `9843001` 详情接口返回 3 条本轮导入资产:
- `Codex资产住宅A`
- `Codex资产车辆重复`
- `Codex资产商铺D`
## 发现的问题
- 员工 `9843001` 列表中 `deptName=研发部门`,但详情接口返回 `deptName=null`,页面详情“所属部门”显示为 `-`。该问题不阻断导入成功,但属于员工详情展示链路的现存缺陷。
## 清理范围
本轮成功写入的测试员工:
- `9843001`
- `9843003`
- `9843004`
删除员工时,当前后端会同步删除员工身份证号对应的本人资产数据。
## 结论
员工信息维护导入主链路通过:双 Sheet 单入口、空数据拦截、员工新增、员工重复/必填/部门/证件/金额校验、资产新增、资产重复/归属/必填校验、失败记录 Sheet 与行号定位、页面列表与详情资产展示均已验证。需要单独处理员工详情部门名称缺失问题。

View File

@@ -0,0 +1,39 @@
# 员工信息维护分页重复修复实施记录
## 问题
- 页面:`http://localhost:1025/maintain/baseStaff`
- 接口:`GET /ccdi/baseStaff/list`
- 现象:第一页和第二页存在相同员工。
- 复现结果:接口第一页和第二页出现 `9020004``9020005``9020009``9020003``9020008` 等重复员工。
## 原因
员工列表 SQL 仅按 `create_time DESC` 排序。批量导入的员工会在同一批次写入相同 `create_time`,数据库在相同排序值之间没有稳定顺序,分页时可能导致同一员工跨页重复出现。
## 修改内容
- `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml`
- 将员工分页列表排序从 `ORDER BY e.create_time DESC` 调整为 `ORDER BY e.create_time DESC, e.staff_id DESC`
- 使用唯一的 `staff_id` 作为二级排序字段,保证同一创建时间内分页顺序稳定。
- `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java`
- 增加 Mapper XML 断言,防止后续误删稳定分页排序。
## 影响范围
- 仅影响员工信息维护列表分页顺序。
- 不改变筛选条件、列表字段、总数统计、导入、新增、编辑、删除逻辑。
## 验证
- 修改前已通过后端接口复现第一页和第二页员工重复。
- `node -e` 轻量 XML 断言通过,确认 Mapper 中包含 `ORDER BY e.create_time DESC, e.staff_id DESC`
- `mvn -pl ruoyi-admin -am clean package -DskipTests` 构建通过。
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffMapperTest -Dsurefire.failIfNoSpecifiedTests=false test` 通过,`CcdiBaseStaffMapperTest` 共 2 个用例全部通过。
- 后端接口复验:
- 第 1 页:`9020018``9020009`
- 第 2 页:`9020008``9020001`,并继续显示后续员工。
- 第 1 页与第 2 页 `staffId` 交集为 `none`
- browser-use 打开真实页面 `http://localhost:1025/maintain/baseStaff` 验证:
- 第 1 页首行显示 `许平 / 9020018`
- 点击分页第 2 页后,页面首行显示 `郑丽 / 9020008`,未再显示第一页首行员工。

View File

@@ -0,0 +1,35 @@
# EasyExcel 导入模板 Fontconfig 异常修复实施记录
## 保存路径确认
- 文档类型:实施记录
- 保存路径:`docs/reports/implementation/`
## 问题说明
- 请求地址:`/ccdi/baseStaff/importTemplate`
- 异常信息:`java.lang.RuntimeException: Fontconfig head is null, check your fonts or fonts configuration`
- 触发链路:员工信息维护导入模板下载使用 `EasyExcelUtil.importTemplateWithDictDropdown` 生成双 Sheet 模板EasyExcel 默认创建 `SXSSFWorkbook`,在无可用字体配置的服务器环境中创建 Sheet 时会触发 POI/AWT 字体配置初始化异常。
## 修改内容
- 修改 `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`
- 将导入模板下载相关写出入口统一改为 `inMemory(Boolean.TRUE)`
- 单 Sheet 普通模板
- 单 Sheet 带字典下拉模板
- 指定文件名的带字典下拉模板
- 双 Sheet 带字典下拉模板
- 普通数据导出入口保持流式写出,不改变大数据导出的内存特性。
## 影响范围
- 修复员工信息维护 `/ccdi/baseStaff/importTemplate` 模板下载。
- 同步覆盖复用同一工具方法的其他导入模板下载接口。
- 不改变导入解析逻辑、模板表头、字典下拉、必填标记和文本格式处理逻辑。
## 验证记录
- 已执行:`mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest -Dsurefire.failIfNoSpecifiedTests=false test`
- 结果:失败,失败原因是本机 JDK 21 下 Mockito inline 自附加失败,错误为 `Could not self-attach to current VM`,未进入模板业务断言。
- 已执行:`mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest -Dsurefire.failIfNoSpecifiedTests=false -DargLine=-javaagent:/Users/wkc/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.8/byte-buddy-agent-1.17.8.jar test`
- 结果:通过,`Tests run: 5, Failures: 0, Errors: 0, Skipped: 0`

View File

@@ -0,0 +1,42 @@
# 导入模板下拉框结构校验实施记录
## 修改背景
员工信息维护导入文件中存在字典列下拉框被删除的情况,例如“状态”列不是模板下拉框。此类文件不应进入业务导入流程,需要在导入解析阶段直接报错,且所有使用 `@DictDropdown` 的导入类都应统一校验。
## 修改内容
-`EasyExcelUtil.importExcel(...)` 入口增加导入模板结构校验。
- 校验范围为导入实体类中带 `@DictDropdown` 且配置了 `@ExcelProperty(index = ...)` 的列。
- 对有实际数据的行逐行检查对应列是否被 `LIST` 类型数据验证覆盖。
- 同一个 Sheet 内存在多个缺失下拉框列时,合并列名一次性提示,例如:
- `员工信息 Sheet 的 是否党员、状态 列缺少下拉框,请下载最新导入模板填写后重新导入`
-`@DictDropdown` 的普通导入类不执行该结构校验。
## 影响范围
- 统一影响 `EasyExcelUtil.importExcel(InputStream, Class<T>)``EasyExcelUtil.importExcel(InputStream, Class<T>, String)` 和文件路径导入入口。
- 员工信息维护、员工资产、亲属关系、招聘信息、实体库等复用该工具类且存在 `@DictDropdown` 字段的导入功能都会具备下拉框结构校验。
- 不改变模板下载逻辑,不改变业务字段枚举值校验和异步导入逻辑。
## 验证结果
- 已执行 Maven 回归:
- `mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest,EasyExcelUtilTemplateTest,CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest -Dsurefire.failIfNoSpecifiedTests=false -DargLine="-javaagent:/Users/wkc/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.8/byte-buddy-agent-1.17.8.jar" test`
- 结果32 个测试全部通过。
- 已检查用户提供的文件:
- `/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx`
- `员工信息` Sheet 数据验证数量为 0。
- `员工资产信息` Sheet 数据验证数量为 0。
- 已重启后端并调用真实导入接口上传该文件:
- `POST http://localhost:62318/ccdi/baseStaff/importData`
- 返回:`{"msg":"员工信息 Sheet 的 是否党员、状态 列缺少下拉框,请下载最新导入模板填写后重新导入","code":500}`
- 已使用 browser-use 打开真实页面:
- `http://localhost:1025/maintain/baseStaff`
- 已进入“员工信息维护导入”弹窗,确认页面入口和真实业务页可访问。
- 当前 browser-use 可用 API 不支持直接给隐藏文件选择框设置本地文件,上传动作改用真实后端接口验证。
## 清理说明
- 本轮异常导入在模板结构校验阶段被拦截,未提交异步导入任务,未写入员工或资产业务数据。
- 测试过程中未生成需要提交到 Git 的测试文件。