java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot Vue批量导入Excel

SpringBoot+Vue实现Excel批量导入全链路的完整指南

作者:菩提风

本文主要为大家详细介绍了基于SpringBoot与Ant Design Vue的Excel批量导入全链路完整实现流程,文中的示例代码讲解详细,希望对大家有所帮助

简介:面向使用 SpringBoot 与 AntDesignVue 开发后台管理系统的开发者,这份 PDF 文档聚焦 Excel 导入功能的完整实现思路,适合需要快速落地文件上传与数据导入场景的前后端人员查阅。资源包仅含 1 个 PDF 文件,大小约 198KB,篇幅精简,便于随项目资料一并保存。内容围绕 Ant Design Vue 的 upload 组件展开,涉及 accept 文件类型限制、customRequest 自定义上传、change 状态监听、disabled 禁用控制等关键点,并结合 SpringBoot 控制层 MultipartFile 接收、服务层解析导入,说明前后端参数对应关系。文档还呈现上传中、完成、失败状态下的图标切换与按钮禁用处理,以及防止重复点击、提升大数据量导入体验的思路,可作为日常开发中的实现参考与排错对照。已有 2440 人学习下载。

1. 后台管理系统里最不起眼、却最容易翻车的那个按钮:Excel 导入

运营甩过来一张三千行的学生名单,要求页面上点一下就全部进库。SpringBoot 加 Ant Design Vue 的技术栈下,这事看着只改两个文件,真动手才发现牵出四条线:前端要接管 a-upload 的默认上传行为,后端要从 MultipartFile 里把流读成工作簿,单元格类型得一列一列地对,事务边界还得自己划。这篇笔记拆的就是一条完整的 Excel 导入链路——前端用 customRequest 覆盖默认 XHR、自己拼 FormData 走封装好的 axios,后端用 POI 的 XSSFWorkbook 逐行解析、MyBatis-Plus 批量落库、异常时手动回滚。适合正在写后台管理系统导入导出的初中级开发,也适合手上有一坨祖传导入代码想推倒重写的熟手。Ant Design Vue 的 upload 组件只是层壳,真正的功夫在后端那几十行解析代码里。

2. 前端拆解:a-upload 的 customRequest 怎么把文件接管到自己手里

2.1 组件属性逐个拆:accept、customRequest、change、disabled

模板部分不长,但每个属性都有它的脾气,写错一个就是「点了没反应」或者「后台收不到文件」。

<!-- 导入按钮:挂在 a-upload 内部,点击触发文件选择框 -->
<a-upload
  accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
  :customRequest="customRequest"
  @change="customChange"
  :disabled="uploadDisabled">
  <a-button type="primary" :icon="uploadIcon" :disabled="uploadDisabled">导入</a-button>
</a-upload>

accept 里那两个 MIME 类型分别对应 .xlsx 和 .xls ,前者是 Office Open XML 格式,后者是老的二进制格式。这个属性只影响文件选择框的默认过滤器,用户在系统弹窗里切到「所有文件」照样能选一张图片进来,所以它只能算第一道软门槛,真正的类型判断要放在 beforeUpload 或后端。 customRequest 是这套方案的核心,一旦传了这个属性,组件内部那套 XHR 上传就不再走,控制权全部交给你,好处是能复用项目里已经封装的请求方法、能带统一 token、能走统一错误提示,坏处是文件上传的进度、成功、失败这些状态回调都得你自己触发。 @change 在文件上传中、完成、失败三个阶段都会触发,是拿来切 UI 状态的。 disabled 绑在组件和按钮两处,是为了保证按钮变灰的时候连点击事件都进不来。

data 里只需要两个状态位:

export default {
  data() {
    return {
      // 导入 excel 时的 icon 图标,初始为向上箭头
      uploadIcon: 'to-top',
      // 导入 excel 时是否禁用上传按钮,防止重复提交
      uploadDisabled: false
    }
  }
}

to-top 和 loading 都是 Ant Design Vue 图标库里的名字,直接当字符串传给 :icon 就能渲染。用状态位驱动图标而不是写死,是因为导入过程中需要一个「正在忙」的视觉反馈,这个反馈和禁用状态是同一件事的两个面。

2.2 customRequest 里为什么必须自己拼 FormData

拿到的 data 对象里装着本次上传的全部上下文, data.file 就是用户选中的那个 File 实例。文件不能直接丢给 axios,必须包一层 FormData。

/**
 * 自定义导入文件方法
 * @param data 上传的 excel 文件上下文,含 file、onProgress、onSuccess 等
 */
customRequest(data) {
  const file = data.file;              // 需要上传的 excel 文件
  const formData = new FormData();     // 文件必须走 FormData 才能被后端按 multipart 解析
  formData.append('file', file);       // key 必须和后端 @RequestParam("file") 对齐
  data.onProgress();                   // 手动通知组件进入上传中状态
  batchImportGetRecord(formData).then(res => {
    if (res) {
      this.$message.success('导入成功');
    } else {
      this.$message.error('导入失败');
    }
  }).finally(() => {
    // 无论成功失败,都要把按钮状态复位,否则按钮永久卡死
    this.switchIconAndStatus(false);
  });
}

FormData.append('file', file) 里那个 'file' 字符串是整个链路最容易被忽略的契约,后端 @RequestParam("file") 括号里写什么,这里就得是什么,两边不一致的表现是后台直接抛 Required request part 'file' is not present 。 batchImportGetRecord 是项目里已经封装好的 axios 方法,它内部处理了 baseURL、token、统一错误码弹窗这些东西,换成 ajax 或 fetch 也行,关键是别再自己设 Content-Type 。整个方法没有设置请求头,这是对的——浏览器需要在 multipart/form-data 后面自动补一个 boundary 分隔符,手动写死这个头反而会把 boundary 弄丢,后端解析直接失败。

data.onProgress() 这一行很多人会漏。customRequest 接管之后,组件自己不知道上传到哪一步了,得靠你主动调一下,它才会把内部状态置为 uploading ,进而触发 @change 。 finally 里的复位同样不能省, then 和 catch 只覆盖成功和异常两种情况, finally 保证第三种情况(组件卸载、请求被拦截器吞掉)也能把按钮解开。

2.3 用 uploadIcon 与 uploadDisabled 挡住第二次点击

处理好一次上传只是及格,处理好「用户在上传过程中疯狂点」才是生产环境的要求。

/**
 * 导入功能的 change 事件
 * @param data 上传文件过程中的文件状态信息
 */
customChange(data) {
  if (data.file.status === 'uploading') {
    // 上传中:图标转为 loading,按钮禁用
    this.switchIconAndStatus(true);
  } else if (data.file.status === 'done') {
    this.switchIconAndStatus(false);
  } else if (data.file.status === 'error') {
    this.switchIconAndStatus(false);
  }
},
/**
 * 导入按钮的图标和状态切换
 * @param flag true 表示正在导入,false 表示空闲
 */
switchIconAndStatus(flag) {
  if (flag) {
    this.uploadIcon = 'loading';
    this.uploadDisabled = true;
  } else {
    this.uploadIcon = 'to-top';
    this.uploadDisabled = false;
  }
}

file.status 的取值有四个: uploading 上传中、 done 上传成功、 error 上传失败、 removed 取消上传。这里只处理了前三个, removed 通常发生在用户点了文件列表里的删除按钮,本项目隐藏了文件列表所以碰不到。 switchIconAndStatus 抽出来是为了消重, customChange 和 customRequest 的 finally 都要调它,两处写两遍迟早改漏一处。

这套「图标 + 禁用」组合的真实价值在处理大文件时才体现出来。三千行数据后台可能跑十几秒,期间用户看不到任何反馈,本能反应就是再点两下,结果同一批数据被 插了三遍,这就是导入功能最经典的血泪经验。把 uploadDisabled 绑到组件和按钮上,等于在视觉和交互两个层面同时上锁。

2.4 补一层 beforeUpload:accept 不是保险

上面那套代码里有个隐含假设:用户选的一定是 Excel。实际项目里我一般还会加一个 beforeUpload 做兜底,它返回 false 时组件会直接放弃上传。

beforeUpload(file) {
  // 后缀白名单,accept 挡不住"所有文件"模式
  const isExcel = /\.(xlsx|xls)$/i.test(file.name);
  if (!isExcel) {
    this.$message.error('只支持 .xlsx 或 .xls 文件');
    return false;
  }
  // 单文件限制 10MB,和后端 multipart 配置保持一致
  if (file.size / 1024 / 1024 > 10) {
    this.$message.error('文件不能超过 10MB');
    return false;
  }
  return true;
}

用正则判后缀比判 MIME 更稳,因为有些系统导出的 .xls 会被识别成 application/octet-stream 。大小限制的数值要和后端 spring.servlet.multipart.max-file-size 对齐,两边不一致的结果是前端放行了、后端在 Tomcat 层就把它拒了,报错信息还很难看。这只是前端的第一道过滤,最终的类型校验依然要落在后端——Excel 文件本质是个 zip,改个后缀就能骗过所有前端检查。

3. 后端拆解:一个 MultipartFile 从进 Controller 到落库的完整链路

3.1 Controller 层:@RequestParam("file") 这根字符串是前后端契约

后端入口短得不像话,但两行代码里藏了三个约束。

/**
 * 导入 excel 数据
 * @param file 前端上传的 excel 文件,key 必须为 file
 * @return 导入成功返回 true,失败返回 false
 */
@PostMapping(value = "import")
public Boolean importExcelData(@RequestParam("file") MultipartFile file) {
    return studentService.importExcelData(file);
}

@PostMapping 是因为文件上传的体积决定了它不能走 GET,参数塞不进 URL。 MultipartFile 是 Spring 对 multipart 请求体里单个文件部分的封装,它对外暴露 getInputStream() 、 getOriginalFilename() 、 getSize() 、 getContentType() 几个方法,够用了。关键是 @RequestParam("file") 里这个 "file" ,它对应的是前端 FormData 里那个 key,写成 @RequestParam("excel") 而前端还传 file ,接口直接 400,报错信息是「所需的请求部件 'excel' 不存在」,第一次遇到容易怀疑人生,以为前端根本没发出请求。

return studentService.importExcelData(file) 直接把布尔值透出去,没包统一响应体。真实项目里我一般会包一层 Result<ImportResult> ,把成功条数、失败条数、失败行号一起带回去,这个改造放在第 6 章讲。

3.2 Service 层:从 Workbook 往下剥到 Cell 的四层下标

POI 的解析模型是个四层结构:Workbook 工作簿 → Sheet 工作表 → Row 行 → Cell 单元格,每层都用从 0 开始的下标访问。

@Override
public boolean importExcelData(MultipartFile file) {
    List<Student> list = new ArrayList<>();
    Student student;
    try {
        // 用文件流构建工作簿,XSSF 对应 .xlsx,.xls 需要 HSSFWorkbook
        XSSFWorkbook workbook = new XSSFWorkbook(file.getInputStream());
        // 只解析第一张 sheet,索引从 0 开始
        XSSFSheet sheet = workbook.getSheetAt(0);
        // 遍历工作表中的所有物理行
        for (int i = 0; i < sheet.getPhysicalNumberOfRows(); i++) {
            if (i == 0) continue;              // 第 0 行是表头,跳过
            XSSFRow row = sheet.getRow(i);     // 按行下标取行
            if (row == null) continue;         // 空行直接跳过
            student = new Student();
            // 遍历这一行的所有物理单元格
            for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
                XSSFCell cell = row.getCell(j);
                if (cell == null) continue;
                // ... 取值与赋值,见下一节
            }
            list.add(student);
        }
        // 批量插入,比循环单条 insert 少 n 次数据库往返
        studentService.saveBatch(list);
        return true;
    } catch (Exception e) {
        e.printStackTrace();
        // 捕获后必须手动标记回滚,否则事务不会感知到失败
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        return false;
    }
}

file.getInputStream() 拿到的流不要自己关,交给 POI 的 workbook.close() 处理,或者在 Spring 的 @Transactional 方法里交给容器,写 try-with-resources 是最稳的。 getSheetAt(0) 只读第一张表,这个设计对「一个文件一张表」的场景够用;如果业务上允许多 sheet,就得在外层再套一层 workbook.getNumberOfSheets() 的循环。

getPhysicalNumberOfRows() 和 getLastRowNum() 是两个容易混的方法,前者返回实际有数据的行数,后者返回最后一行的下标(可能包含空行),用前者遍历不会踩到 null 行。 row.getPhysicalNumberOfCells() 同理,它按实际存在的单元格计数,对于那些在 Excel 里被删过列的文件,用 row.getLastCellNum() 遍历更保险—— getPhysicalNumberOfCells 遇到中间有空洞的单元格会数少,导致后面的列取不到。这是我在一个脏数据文件上翻过的车。

3.3 单元格取值:字符串、数字、日期三条分岔路

真正的脏活在这个 switch 里。

String cellValue = cell.getStringCellValue();      // 取字符串类型内容
double cellValue2 = cell.getNumericCellValue();    // 取数字类型内容
switch (j) {
    case 0: // 第一列:姓名
        student.setName(cellValue);
        break;
    case 1: // 第二列:年龄
        student.setAge(cellValue2);
        break;
    case 2: // 第三列:爱好
        student.setHobby(cellValue);
        break;
    case 3: // 第四列:家庭地址
        student.setAddress(cellValue);
        break;
    case 4: // 第五列:出生日期
        // Excel 里日期若没设格式,取出来就是字符串,需要手动转换
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy年M月d日HH:mm:ss");
        Date date = sdf.parse(cellValue);
        student.setBirthday(date);
        break;
}

switch (j) 里的 j 是列下标,和 Excel 里的列顺序严格对应,前端模板改了列顺序,这里的 case 就得跟着改,属于典型的强耦合——想解耦就得把表头读出来做映射,后面再讲。

getStringCellValue() 有个硬约束:单元格必须是字符串类型,如果 Excel 里那一格存的是数字,这行会抛 IllegalStateException: Cannot get a STRING value from a NUMERIC cell 。 getNumericCellValue() 反过来也有同样的限制。这两个方法在真实文件上大概率会翻车,因为用户在 Excel 里输年龄时,有的是文本格式,有的是数字格式,同一个文件里混着来。

稳妥的做法是先看类型再取值:

// 按单元格实际类型取值,避免 IllegalStateException
String value;
switch (cell.getCellType()) {
    case STRING:
        value = cell.getStringCellValue();
        break;
    case NUMERIC:
        // 日期在 Excel 底层是数值,需要先判断格式
        if (DateUtil.isCellDateFormatted(cell)) {
            value = new SimpleDateFormat("yyyy-MM-dd")
                    .format(cell.getDateCellValue());
        } else {
            // 用 DataFormatter 避免 18 变成 18.0 这种丑输出
            value = new DataFormatter().formatCellValue(cell);
        }
        break;
    case BOOLEAN:
        value = String.valueOf(cell.getBooleanCellValue());
        break;
    case FORMULA:
        value = cell.getCellFormula();
        break;
    default:
        value = "";
}

DateUtil.isCellDateFormatted(cell) 是 POI 提供的判断,它看的是单元格的样式而非值本身,所以一个被设成日期格式的单元格才能被正确识别。 DataFormatter().formatCellValue(cell) 会按 Excel 里显示的样式输出字符串,比 getNumericCellValue() 拼出来的 18.0 干净得多,代价是性能稍差,几千行的量级完全可以接受。

日期那一段还有个大坑: SimpleDateFormat 的 pattern 必须和 Excel 里实际的文本格式严格一致, yyyy年M月d日HH:mm:ss 里 M 和 d 用单字母是为了容忍「2024年3月5日」和「2024年03月05日」两种写法,如果写成 MM dd ,遇到单位数月份就会抛 ParseException 。 SimpleDateFormat 本身也不是线程安全的,写在方法内部才安全,千万别提到成员变量上去。

3.4 批量插入与手动回滚:saveBatch 配 setRollbackOnly

解析完的实体全在 list 里,一次性 交给 studentService.saveBatch(list) 。MyBatis-Plus 的 saveBatch 默认按 1000 条一批切分,拼成 insert into ... values (...),(...),(...) 的多值语句,比循环调 save() 少了几千次网络往返。三千行的导入,前者一两秒,后者能拖到十几秒,这个差距在用户眼里就是「卡死」和「秒回」的区别。

事务这块有个细节值得单独说。方法上要有 @Transactional(rollbackFor = Exception.class) ,但光有注解不够—— catch 块把异常吞掉了,Spring 的事务切面感知不到异常,回滚就不会发生。所以 catch 里那句 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 是必须的,它把当前事务标记为「只能回滚」,方法正常返回后切面会执行 rollback。

注意 rollbackFor = Exception.class 这个参数,Spring 默认只对 RuntimeException 和 Error 回滚,而 XSSFWorkbook 构造函数和 SimpleDateFormat.parse 抛的都是受检异常,不显式声明的话,解析失败时前面已经插入的数据会留在库里,变成半截子脏数据。这个点我在线上吃过一次亏,查了半天才发现是回滚没生效。

还有一点: saveBatch 内部自己也会开事务,如果外层已经有事务,它会加入进来;如果没有,它会自己提交。所以外层这个 @Transactional 是让整批插入变成一个原子操作的前提。

4. 工程结构与跑通步骤:这套东西在我本机是怎么拼起来的

4.1 后端目录与依赖:POI、MyBatis-Plus、multipart 配置

目录按常规三层切,导入相关的代码集中放在学生模块下,方便后续照着抄一份给别的业务。

src/main/java/com/example/demo
├── controller
│   └── StudentController.java        # 导入接口入口
├── service
│   ├── StudentService.java
│   └── impl
│       └── StudentServiceImpl.java   # POI 解析与批量插入
├── entity
│   └── Student.java                  # 姓名、年龄、爱好、地址、生日
├── mapper
│   └── StudentMapper.java
└── DemoApplication.java
src/main/resources
├── application.yml
└── mapper/StudentMapper.xml

Maven 依赖里除了 spring-boot-starter-web ,还需要三样: mybatis-plus-boot-starter 提供 saveBatch , poi-ooxml 提供 XSSFWorkbook , mysql-connector-java 连库。 poi-ooxml 会顺带把 poi 核心包拉下来,不用单独再声明一次。

<!-- Excel 解析,XSSFWorkbook 在这个包里 -->
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.2.5</version>
</dependency>
<!-- 提供 saveBatch 批量插入 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.5</version>
</dependency>

版本号按项目里已有的 BOM 对齐,我这里写的只是能跑通的组合,不必照抄。 application.yml 里必须放开 multipart 的大小限制,默认单文件 1MB,超过就抛 MaxUploadSizeExceededException :

spring:
  servlet:
    multipart:
      max-file-size: 10MB      # 单文件上限,和前端 beforeUpload 保持一致
      max-request-size: 20MB   # 整个请求上限,多文件时才是瓶颈
  datasource:
    url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8
    username: root
    password: 123456

max-file-size 和 max-request-size 的区别在于后者统计的是整个请求体(含其他表单字段),单文件场景前者先触发。两个值都写,别只写一个。

4.2 前端目录与请求封装:api 层收口

前端把导入组件单独放一个文件,请求方法收在 api 目录里,组件不直接碰 axios。

src
├── api
│   └── student.js          # batchImportGetRecord 等接口方法
├── views
│   └── StudentImport.vue   # a-upload 导入组件
├── utils
│   └── request.js          # axios 实例、拦截器、token 注入
└── main.js

api/student.js 里那个方法长这样:

import request from '@/utils/request'
/**
 * 批量导入学生
 * @param {FormData} formData 含 file 字段的表单数据
 */
export function batchImportGetRecord(formData) {
  // 不设置 Content-Type,交给浏览器补 boundary
  return request({
    url: '/student/import',
    method: 'post',
    data: formData,
    timeout: 60000          // 大文件解析慢,超时要单独放宽
  })
}

timeout 这一项单独放宽到 60 秒是有必要的,axios 默认超时往往只有几秒,三千行的文件后台解析加批量插入很容易超时,表现是前端报错但后台其实已经插成功了,用户重试一次就是重复数据。这个参数和后端的事务要一起看。

4.3 从零跑通的六步操作

把工程拉下来之后,按这个顺序走,基本不会卡在环境上。

# 1. 建库建表,student 表至少包含 name、age、hobby、address、birthday 五列
mysql -uroot -p -e "create database demo default charset utf8mb4;"
# 2. 启动后端,确认 8080 端口起来了
cd backend && mvn spring-boot:run
# 3. 装前端依赖,注意 ant-design-vue 的版本要和 Vue 2/3 匹配
cd frontend && npm install
# 4. 起前端开发服务,默认 8081
npm run serve
# 5. 用 Postman 单独验证一次后端接口,排除前端干扰
curl -X POST http://localhost:8080/student/import -F "file=@students.xlsx"
# 6. 页面点导入,观察 Network 面板里请求体是否为 multipart/form-data

第 5 步单独用 curl 验证很关键, -F "file=@students.xlsx" 会自动构造 multipart 请求体,如果这一步能通、页面点不通,问题一定在前端;反之问题在后端解析逻辑。这个二分法能省掉大量来回猜的时间。第 6 步看 Network 里的 Content-Type ,只要带了 boundary=----WebKitFormBoundaryXXXX 就是对的,如果只有 multipart/form-data 没有 boundary,回去检查是不是手动设了请求头。

Excel 测试文件建议自己造一个,第一行表头,第二行开始塞三条数据,其中一条故意把年龄写成文本格式、日期写成 2024-03-05 ,用来验证类型兼容性。拿真实业务文件测反而不好排查,因为你不确定哪一格是脏的。

5. 避坑与排查:五条踩过的导入翻车记录

5.1 上传参数对不上的两类报错

现象一 :接口返回 400,日志里是 Required request part 'file' is not present ,前端 Network 看着请求已经发出去了,body 里也确实有文件。

原因基本只有一个:FormData 里的 key 和后端 @RequestParam 的 value 不一致。前端写 formData.append('excel', file) ,后端写 @RequestParam("file") ,两边对不上,Spring 找不到叫 file 的部件就报这个。

解决方式是把两边字符串统一,我会在 api 层的注释里显式写一句「key 必须与后端 @RequestParam 一致」,或者干脆抽个常量。

现象二 :同样是 400,但报错是 Current request is not a multipart request 。

原因是手动设置了 Content-Type: multipart/form-data 。这个头必须由浏览器自动生成,因为要附带 boundary 分隔符,手写的话 boundary 丢失,Spring 的 multipart 解析器直接判定这不是一个 multipart 请求。

解决方式是把请求拦截器里统一加 Content-Type 的那段逻辑跳过文件上传接口,或者判断 data instanceof FormData 时删掉这个头。

5.2 单元格类型引发的解析异常

现象三 :导入直接失败,日志里一行 IllegalStateException: Cannot get a STRING value from a NUMERIC cell 。

原因是代码里对所有列都无脑调了 cell.getStringCellValue() ,而 Excel 里年龄、编号这类列天然是数字类型。用户在 Excel 里输入 18 ,POI 读出来就是 NUMERIC,跟代码期望的 STRING 对不上。

解决方式是先 cell.getCellType() 判断,再分派到对应的 getter,或者统一用 DataFormatter().formatCellValue(cell) 转字符串,牺牲一点性能换稳定。我现在的习惯是:除了明确要算数的字段,其余全走 DataFormatter。

现象四 :日期字段报 ParseException: Unparseable date: "45000" 。

原因是 Excel 里日期的真实存储是一个序列号(1900 年起的第几天), getStringCellValue() 在某些格式下拿到的就是这个数字,而不是你眼睛看到的「2024年3月5日」。

解决方式是用 DateUtil.isCellDateFormatted(cell) 先判断,命中就 cell.getDateCellValue() 直接拿 Date,不要自己 parse。反过来,如果模板要求用户手填文本日期,就得在模板里把那一列设成文本格式,并在导入时给出明确的格式提示。

5.3 事务边界与按钮状态的两笔账

现象五 :导入五千行,报错提示「导入失败」,但库里多了两千条数据,用户重试一次数据翻倍。

原因有两层。一是方法上没加 @Transactional , saveBatch 自己按批提交,前几批已经落库了。二是加了注解但 catch 块把异常吞了,事务切面没感知到异常,照样提交。

解决方式是方法上 @Transactional(rollbackFor = Exception.class) ,catch 里补一句 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 。更彻底的做法是「先全量解析校验、再统一落库」,把解析阶段的异常挡在写库之前,这样连回滚都省了。

现象六 :按钮转圈之后一直不恢复,只能刷新页面。

原因通常是 customRequest 里只写了 then 没写 finally ,或者中间抛了未捕获的异常,复位逻辑根本没走到。另一种情况是自定义上传通道下组件的 done 状态不会被自动触发(因为没调 data.onSuccess() ),如果你指望 @change 里的 done 分支去复位,就会永远等不到。

解决方式是在 customRequest 的入口先 switchIconAndStatus(true) ,出口用 finally 兜底复位,不要依赖 @change 的状态回调。两条复位路径留一条就够,混用反而容易出现「刚置灰又被解开」的时序问题。

6. 二次开发:把这套一次性导入改造成能回传失败行的导入任务

原版的返回值是 Boolean ,用户只知道成功或失败,不知道哪一行错了。第一次遇到运营拿着文件来问「到底哪条没进去」的时候,我就决定把它改掉。

第一步是把返回值换成结果对象,用两个计数加一个失败明细撑起来。在 Service 里把 catch 拆细,逐行 try-catch,失败行记下行号和原因,成功的照常收集:

// 逐行解析,单行失败不中断整批
for (int i = 1; i < sheet.getPhysicalNumberOfRows(); i++) {
    try {
        list.add(parseRow(sheet.getRow(i)));
    } catch (Exception e) {
        // 行号从 1 开始对用户更友好,和 Excel 左侧行标一致
        failRows.add("第 " + (i + 1) + " 行:" + e.getMessage());
    }
}
studentService.saveBatch(list);
return new ImportResult(list.size(), failRows.size(), failRows);

第二步是加表头校验。列顺序一变, switch (j) 的 case 全部错位,数据会静默写错字段,这种错误比抛异常更可怕。做法是读第 0 行,把表头拼成字符串和模板比对,不一致直接拒绝,不给用户「导进去一半」的机会。

改造点改之前改之后
返回值Boolean成功数 + 失败数 + 失败行明细
异常处理整批回滚逐行隔离,成功行照常入库
列映射硬编码下标表头校验 + 下标映射
用户反馈一句 toast弹窗列出失败行号与原因

第三步才是性能。三千行以内当前方案够用,上万行就得考虑异步:接口只负责把文件存到临时目录并返回一个任务 ID,后台线程池慢慢解析,前端轮询进度。这一步改造量不小,我的建议是先量一下真实数据量,别为了不存在的性能问题提前上异步,那套东西的复杂度会让排查难度翻倍。

验证方法很朴素:造一个包含空行、文本型数字、错误日期格式、重复姓名的脏文件,跑一遍看返回的失败明细是否精确到行。我一般还会故意把表头第二列和第三列对调,确认表头校验能拦住——这个场景在真实使用里出现频率比想象中高,用户下载模板后自己加了一列备注,后面的列全移位了。

从那以后我每次做导入功能,都强制先把「表头校验」和「失败行回传」这两件事写进第一版,而不是等出问题再补。运营要的从来不是「导入成功」四个字,而是「哪几条没进去」。

以上就是SpringBoot+Vue实现Excel批量导入全链路的完整指南的详细内容,更多关于SpringBoot Vue批量导入Excel的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文