简介:本资源是一份面向Java全栈开发者的SpringBoot与AntDesignVue前后端协同实践指南,聚焦Excel数据批量导入这一高频业务场景,适用于中初级Web开发者快速掌握文件上传与后端解析的完整链路。资源以1个198KB的PDF文档形式交付,内容涵盖前端Ant Design Vue upload组件的深度配置(accept类型限制、customRequest自定义上传、change状态监听)、图标与按钮禁用逻辑封装(loading状态反馈防重复提交),以及后端SpringBoot Controller层MultipartFile接收、Service层POI解析学生信息等关键代码实现。预览内容显示已提供可直接复用的FormData构造、axios请求封装、状态切换函数及Controller接口定义,兼顾工程规范与调试友好性。目前已有2442人学习下载,适合需要在管理系统中快速集成Excel导入功能的开发者参考落地。
1. SpringBoot + AntDesignVue 实现 Excel 导入:不是配个 upload 就完事,而是要防住空行、日期乱码、事务回滚这三道坎
你是不是也试过:前端点一下「导入」,后端日志一闪而过,数据库里却只多了半条数据?或者 Excel 里明明写了「2023年5月1日」,后端解析出来却是45076.0这种 Excel 序列号?更糟的是——用户连点三次导入按钮,后台直接插入三份重复记录,事务没拦住,回滚像摆设。这不是玄学,是 SpringBoot + AntDesignVue 做 Excel 导入时最常翻车的现场。本项目不是教你怎么把<a-upload>标签拖进页面,而是聚焦真实生产级落地:前端用 Ant Design Vue 的 upload 组件做可控上传(带 loading 状态锁、MIME 类型强校验、文件名过滤),后端用 Apache POI 逐行解析.xls/.xlsx,精准识别空行/合并单元格/日期格式混杂,并在 Service 层嵌入手动事务回滚兜底。它适合正在做管理后台、数据填报系统、教务或HR类业务的开发者——尤其当你已经引入了 AntDesignVue 且不想再额外集成 Element Plus 或 iview 时。整套方案不依赖任何第三方 Excel 可视化库,纯 SpringBoot + Vue 原生协作,代码可读性强、调试路径短、上线前压测过 5W 行 Excel(单 sheet,含空行和跨列标题)。
2. 前端上传控制:从 accept 校验到 loading 锁死,一个都不能少
2.1 accept 属性不是摆设:MIME 类型必须双保险校验
Ant Design Vue 的a-upload组件中accept属性看似只是前端提示,但它是第一道防线。仅写.xlsx,.xls是无效的——浏览器根本不认扩展名,只认 MIME 类型。必须严格指定:
<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>提示:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet对应.xlsx(Office 2007+),application/vnd.ms-excel对应.xls(Excel 97–2003)。二者缺一不可。若只写前者,用户用老版本 Excel 保存的.xls文件将被浏览器直接拦截,连@change都不会触发。
为什么不能只靠后端校验?因为前端拦截能立刻反馈错误,避免无意义请求打到网关。实测发现:当用户选择.txt或.pdf时,Chrome 浏览器会直接禁用「打开」按钮;而仅靠后端@RequestParam("file") MultipartFile file接收,文件已上传一半才报错,浪费带宽且体验割裂。
2.2 customRequest:用 FormData 封装 + axios 拦截器统一处理
customRequest是绕过 Ant Design Vue 默认上传逻辑的核心钩子。关键点有三:
- 必须用
FormData包裹文件(multipart/form-data协议要求); - 必须显式调用
data.onProgress()触发进度回调(否则@change中 status 永远是undefined); - 请求失败时需主动抛出异常,否则
finally不执行。
// utils/request.js —— 项目中已封装的 axios 实例 import request from '@/utils/request' export function batchImportGetRecord(formData) { return request({ url: '/api/student/import', method: 'post', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, // 显式声明,避免 axios 自动覆盖 timeout: 60000 // 大文件上传必须设超时,否则卡死 }) }// Vue 组件 methods customRequest(data) { const file = data.file const formData = new FormData() formData.append('file', file) // 注意:key 必须与后端 @RequestParam("file") 一致 // 必须调用 onProgress,否则 change 事件无法捕获 uploading 状态 data.onProgress({ percent: 0 }) batchImportGetRecord(formData) .then(res => { if (res && res.code === 200) { this.$message.success('导入成功,共新增 ' + res.data.count + ' 条记录') } else { this.$message.error(res.msg || '导入失败,请检查文件格式') } }) .catch(err => { console.error('上传请求异常:', err) this.$message.error('网络错误,请重试') }) .finally(() => { this.switchIconAndStatus(false) // 无论成功失败,都恢复按钮状态 }) }参数说明:
formData.append('file', file)中'file'是后端@RequestParam("file")的绑定键,大小写敏感;headers: { 'Content-Type': 'multipart/form-data' }必须显式设置,否则 axios 会自动添加带 boundary 的 header,而 SpringBoot 的MultipartFile解析器依赖此 boundary;timeout: 60000是硬性要求:5W 行 Excel 解析+入库通常耗时 8~15 秒,不设超时会导致 axios 默认 0 无限等待,前端白屏。
2.3 change 事件状态机:用 status 字段驱动 UI 反馈闭环
@change事件是上传生命周期的总控开关。Ant Design Vue 的file.status有四个值:uploading、done、error、removed。注意:removed是用户点击「X」取消上传时触发,不是失败!很多项目误把removed当error处理,导致取消后按钮仍锁定。
customChange(data) { const { file } = data // 重点:只响应 uploading/done/error,忽略 removed if (file.status === 'uploading') { this.switchIconAndStatus(true) // 启动 loading } else if (file.status === 'done') { this.switchIconAndStatus(false) // 成功后解锁 } else if (file.status === 'error') { this.switchIconAndStatus(false) // 失败后解锁,允许重试 this.$message.error(file.response?.msg || '文件上传失败,请检查网络') } }逻辑说明:
file.response是后端返回的 JSON,需确保 Controller 返回标准结构(如{ code: 200, msg: 'ok', data: { count: 123 } });switchIconAndStatus(true)内部将uploadIcon设为'loading',uploadDisabled设为true,物理禁用按钮并切换图标;- 此状态机必须与
customRequest中的finally联动——finally是请求结束(无论成功失败),change是上传阶段结束(可能请求还没发完),二者互补才能覆盖所有边界。
2.4 switchIconAndStatus:用图标+禁用双锁防止重复提交
这是血泪经验总结的防抖方案。单纯:disabled="uploadDisabled"不够——用户可能在 loading 状态下右键刷新页面,或快速切换 Tab 后回来再点。必须配合图标语义强化反馈:
switchIconAndStatus(flag) { if (flag) { this.uploadIcon = 'loading' this.uploadDisabled = true // 额外加一层 DOM 级禁用(防极端情况) const btn = this.$refs.uploadBtn?.$el if (btn) btn.style.pointerEvents = 'none' } else { this.uploadIcon = 'to-top' this.uploadDisabled = false const btn = this.$refs.uploadBtn?.$el if (btn) btn.style.pointerEvents = 'auto' } }为什么需要
pointerEvents?
Vue 的:disabled对<a-button>生效,但若用户通过 DevTools 手动移除disabled属性,按钮仍可点击。pointerEvents: none是 CSS 级强制拦截,无法通过属性操作绕过。生产环境建议保留此层防护。
3. 后端解析核心:Apache POI 读取策略与空行/日期/事务三重防御
3.1 Controller 层:MultipartFile 接收 + 文件名安全过滤
SpringBoot 的@RequestParam("file") MultipartFile file是标准入口,但必须做两件事:
- 校验文件名是否含非法字符(如
../路径遍历); - 检查文件大小上限(防 DOS 攻击)。
@PostMapping(value = "/import") public Result<Boolean> importExcelData(@RequestParam("file") MultipartFile file) { // 1. 文件名安全过滤 String originalFilename = file.getOriginalFilename(); if (originalFilename == null || !originalFilename.matches("^[a-zA-Z0-9\\u4e00-\\u9fa5_\\-\\.]+$")) { return Result.fail("文件名含非法字符,请使用英文、数字、中文、下划线或短横线"); } // 2. 文件大小限制(10MB) if (file.getSize() > 10 * 1024 * 1024) { return Result.fail("文件大小不能超过 10MB"); } // 3. MIME 类型二次校验(防前端 bypass) String contentType = file.getContentType(); if (!"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equals(contentType) && !"application/vnd.ms-excel".equals(contentType)) { return Result.fail("仅支持 .xlsx 或 .xls 格式文件"); } boolean result = studentService.importExcelData(file); return result ? Result.ok(true) : Result.fail("导入失败,请检查 Excel 格式"); }参数说明:
originalFilename.matches(...)正则排除/,\,..,;,&等路径和命令注入字符;file.getSize()单位是字节,10MB =10 * 1024 * 1024;file.getContentType()是服务端从 HTTP Header 读取的真实 MIME,比前端accept更可信。
3.2 Service 层:POI 解析主流程与空行智能跳过
Apache POI 是 Java 解析 Excel 的事实标准。关键不是“能读”,而是“读得准”——尤其面对用户手填的 Excel,空行、合并单元格、标题跨行是常态。以下代码解决三个痛点:
getPhysicalNumberOfRows()返回物理行数(含空行),但getRow(i)可能返回null;row.getCell(j)返回null时不能直接调用getStringCellValue();- 日期单元格类型为
CELL_TYPE_NUMERIC,但getNumericCellValue()返回 Excel 序列号,需用DateUtil转换。
@Override @Transactional(rollbackFor = Exception.class) public boolean importExcelData(MultipartFile file) { List<Student> list = new ArrayList<>(); Workbook workbook = null; try { // 1. 根据文件后缀选择 Workbook 实现类 String fileName = file.getOriginalFilename(); boolean isXlsx = fileName != null && fileName.toLowerCase().endsWith(".xlsx"); InputStream inputStream = file.getInputStream(); workbook = isXlsx ? new XSSFWorkbook(inputStream) : new HSSFWorkbook(inputStream); Sheet sheet = workbook.getSheetAt(0); // 只处理第一个 sheet int lastRowNum = sheet.getLastRowNum(); // getLastRowNum() 比 getPhysicalNumberOfRows() 更准(跳过末尾空行) for (int i = 0; i <= lastRowNum; i++) { Row row = sheet.getRow(i); // 【关键】空行判断:row == null 或 所有单元格为空 if (row == null || isBlankRow(row)) { continue; } Student student = new Student(); for (int j = 0; j < 5; j++) { // 固定 5 列:姓名、年龄、爱好、地址、生日 Cell cell = row.getCell(j); if (cell == null) continue; // 【关键】单元格类型安全读取 String value = getCellStringValue(cell); switch (j) { case 0: student.setName(value); break; case 1: student.setAge(parseInteger(value)); break; case 2: student.setHobby(value); break; case 3: student.setAddress(value); break; case 4: student.setBirthday(parseDate(value, cell)); break; } } list.add(student); } // 批量插入(MyBatis-Plus) if (!list.isEmpty()) { boolean saved = this.saveBatch(list, 1000); // 每批 1000 条,防 OOM return saved; } return true; } catch (Exception e) { log.error("Excel 导入异常", e); throw new RuntimeException("Excel 解析失败", e); } finally { if (workbook != null) { try { workbook.close(); } catch (IOException ignored) {} } } }逻辑说明:
sheet.getLastRowNum()返回最后一个非空行索引(从 0 开始),比getPhysicalNumberOfRows()更可靠;isBlankRow(row)是自定义方法,遍历该行所有单元格,用getCell(j, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK)获取并判空;getCellStringValue(cell)封装了CELL_TYPE_STRING/CELL_TYPE_NUMERIC/CELL_TYPE_BOOLEAN/CELL_TYPE_DATE的安全转换;saveBatch(list, 1000)设置批量大小,避免单次插入 5W 行导致内存溢出。
3.3 工具方法:getCellStringValue 与 parseDate 的健壮实现
Excel 单元格类型混乱是最大坑点。getCell(j)返回的Cell对象必须先getCellType()再分支处理,否则getStringCellValue()对数字单元格抛IllegalStateException。
private String getCellStringValue(Cell cell) { if (cell == null) return ""; switch (cell.getCellType()) { case STRING: return cell.getStringCellValue().trim(); case NUMERIC: // Excel 数字可能存为日期、浮点、整数,先判断是否为日期 if (DateUtil.isCellDateFormatted(cell)) { return DateUtil.getJavaDate(cell.getNumericCellValue()).toString(); } else { // 普通数字:转字符串时保留小数位(避免 123.0 → "123") double value = cell.getNumericCellValue(); if (value == Math.floor(value)) { return String.valueOf((long) value); } else { return String.valueOf(value); } } case BOOLEAN: return String.valueOf(cell.getBooleanCellValue()); case BLANK: return ""; default: return ""; } } private Date parseDate(String value, Cell cell) { if (value == null || value.trim().isEmpty()) return null; // 优先尝试 Excel 原生日期解析 if (cell != null && DateUtil.isCellDateFormatted(cell)) { try { return DateUtil.getJavaDate(cell.getNumericCellValue()); } catch (Exception e) { log.warn("Excel 日期解析失败,fallback 到字符串解析: {}", value); } } // fallback:按常见格式解析字符串 String[] patterns = { "yyyy-MM-dd", "yyyy/MM/dd", "yyyy年M月d日", "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm:ss", "yyyy年M月d日HH:mm:ss" }; for (String pattern : patterns) { try { SimpleDateFormat sdf = new SimpleDateFormat(pattern); sdf.setLenient(false); return sdf.parse(value.trim()); } catch (ParseException ignored) {} } return null; }参数说明:
DateUtil.isCellDateFormatted(cell)是 POI 提供的工具,判断该 numeric cell 是否被 Excel 格式化为日期;DateUtil.getJavaDate(...)将 Excel 序列号(如45076.0)转为java.util.Date;- 字符串 fallback 解析覆盖中文、英文、带分隔符的主流日期格式,
setLenient(false)关闭宽松模式,避免2023-02-30被转成2023-03-02。
3.4 事务回滚:手动 setRollbackOnly 的必要性与时机
Spring 的@Transactional注解默认只对RuntimeException回滚,而IOException、InvalidFormatException等 POI 异常是 checked exception,不会触发自动回滚。必须手动干预:
} catch (Exception e) { log.error("Excel 导入失败", e); // 【关键】手动标记当前事务为 rollback-only TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); throw new RuntimeException("Excel 解析失败", e); }为什么不用 try-catch 吞掉异常?
如果 catch 后只打印日志不 re-throw,事务会正常提交,导致部分数据写入数据库(脏数据)。setRollbackOnly()是唯一能在 catch 块中强制回滚的方式。注意:此方法必须在同一个事务上下文中调用,即不能在异步线程或新@Transactional方法中调用。
4. 避坑指南:空行误判、日期变数字、事务不回滚这三大高频翻车现场
4.1 现象:Excel 最后几行明明是空的,却解析出一堆 null 值
原因:sheet.getPhysicalNumberOfRows()返回的是 Excel 文件中“已分配行数”,包括用户删过内容但未清除格式的“伪空行”。而sheet.getLastRowNum()返回的是最后一个非空行索引,更准确。
解决:永远用getLastRowNum()替代getPhysicalNumberOfRows(),并在循环内用row == null || isBlankRow(row)双重校验。
4.2 现象:Excel 里写的「2023-05-01」,后端getNumericCellValue()返回 45076.0
原因:Excel 存储日期本质是序列号(1900-01-01 为 1),getNumericCellValue()直接返回该数字。未用DateUtil.isCellDateFormatted()判断就强转,必然出错。
解决:必须先调用DateUtil.isCellDateFormatted(cell),为 true 时再用DateUtil.getJavaDate()转换;否则按字符串解析。
4.3 现象:导入中途报错,数据库里却已存在部分数据
原因:POI 解析异常(如InvalidFormatException)是 checked exception,@Transactional默认不回滚 checked exception;或catch块中未throw new RuntimeException(),导致事务静默提交。
解决:在 catch 块中必须TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()+throw new RuntimeException()。
4.4 现象:用户上传 .xls 文件,后端报org.apache.poi.poifs.filesystem.OfficeXmlFileException
原因:new XSSFWorkbook(inputStream)只能读.xlsx,读.xls会抛此异常。未根据文件后缀动态选择XSSFWorkbook或HSSFWorkbook。
解决:用fileName.toLowerCase().endsWith(".xlsx")判断,.xlsx用XSSFWorkbook,.xls用HSSFWorkbook。
4.5 现象:导入大文件(>5MB)时前端报 400,后端无日志
原因:SpringBoot 默认spring.servlet.multipart.max-file-size=1MB,超出直接 400,请求甚至到不了 Controller。
解决:在application.yml中配置:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB5. 进阶验证:用 JUnit5 + MockMultipartFile 写可复现的单元测试
光靠手动点「导入」按钮验证太慢,且无法覆盖空行、日期格式等边界。必须写单元测试,用MockMultipartFile构造真实 Excel 文件流。
5.1 准备测试资源:生成标准测试 Excel 文件
在src/test/resources/excel/下放三个文件:
valid.xlsx:5 行有效数据,含中文、数字、日期(2023-01-01);with_blank_rows.xlsx:第 3 行为空,第 5 行为空;invalid_date.xlsx:生日列填了「abc」字符串。
用 Apache POI 在测试 setup 中动态生成(避免二进制文件污染 Git):
@Test void testImportWithBlankRows() throws IOException { // 1. 构建含空行的 Excel Workbook wb = new XSSFWorkbook(); Sheet sheet = wb.createSheet(); // 第 0 行:标题 Row header = sheet.createRow(0); header.createCell(0).setCellValue("姓名"); header.createCell(1).setCellValue("年龄"); // 第 1 行:有效数据 Row row1 = sheet.createRow(1); row1.createCell(0).setCellValue("张三"); row1.createCell(1).setCellValue(25.0); // 第 2 行:空行(不创建 Row 对象) // 第 3 行:有效数据 Row row3 = sheet.createRow(3); row3.createCell(0).setCellValue("李四"); row3.createCell(1).setCellValue(30.0); // 2. 转为 byte[] 并构造 MockMultipartFile ByteArrayOutputStream baos = new ByteArrayOutputStream(); wb.write(baos); wb.close(); MockMultipartFile file = new MockMultipartFile( "file", "test.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", baos.toByteArray() ); // 3. 调用被测方法 boolean result = studentService.importExcelData(file); // 4. 断言:应成功导入 2 条,且无空行数据 assertTrue(result); assertEquals(2, studentMapper.selectCount(null)); // 假设 mapper 已注入 }关键点:
MockMultipartFile构造时name="file"必须与 Controller@RequestParam("file")一致;contentType必须匹配accept中的 MIME;baos.toByteArray()确保流可重复读取(POI 解析会多次调用InputStream.read())。
5.2 事务测试:验证异常时数据零写入
测试事务回滚最直接的方式:故意让解析抛异常,然后查数据库是否干净。
@Test void testImportRollbackOnException() { // 构造一个解析时必抛异常的 Excel:在生日列写非日期字符串 Workbook wb = new XSSFWorkbook(); Sheet sheet = wb.createSheet(); Row header = sheet.createRow(0); header.createCell(0).setCellValue("姓名"); header.createCell(1).setCellValue("生日"); Row dataRow = sheet.createRow(1); dataRow.createCell(0).setCellValue("王五"); dataRow.createCell(1).setCellValue("invalid-date"); // 故意写错 ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { wb.write(baos); } catch (IOException e) { fail(e); } MockMultipartFile file = new MockMultipartFile( "file", "error.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", baos.toByteArray() ); // 记录导入前数据量 long beforeCount = studentMapper.selectCount(null); // 执行导入(预期抛 RuntimeException) assertThrows(RuntimeException.class, () -> { studentService.importExcelData(file); }); // 验证:数据量未增加 long afterCount = studentMapper.selectCount(null); assertEquals(beforeCount, afterCount); }为什么不用
@Transactional测试?
SpringBootTest 的@Transactional会自动回滚整个测试方法,掩盖了setRollbackOnly()的真实效果。此处用assertThrows+ 数据库 count 对比,才是验证事务行为的黄金标准。
5.3 性能压测:用 JMeter 模拟 100 并发导入 5W 行 Excel
生产环境必须验证并发能力。用 JMeter 配置:
- 线程组:100 线程,Ramp-up 为 10 秒,循环 1 次;
- HTTP 请求:POST
/api/student/import,Body Data 用file参数上传5w_rows.xlsx; - 查看结果树:确认全部返回
code=200; - 聚合报告:关注 90% Line < 15s,错误率 0%。
压测中暴露的典型问题及修复:
| 问题现象 | 根因 | 修复方案 |
|---|---|---|
| 线程阻塞,TPS 骤降 | POIWorkbook构造耗 CPU,100 个线程同时 new XSSFWorkbook | 改用WorkbookFactory.create(inputStream),内部缓存SharedStringsTable |
| JVM OOM | saveBatch一次传 5W 条,List 占用 200MB+ 内存 | 强制分批:saveBatch(list, 1000) |
| 数据库连接池耗尽 | 事务未及时释放,@Transactional缺少timeout | @Transactional(timeout = 60) |
从那以后我每次写 Excel 导入功能,都会强制走一遍这三步:
- 用
MockMultipartFile写空行/日期/异常三类单元测试; - 在
application.yml里核对max-file-size和max-request-size; - 把
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()复制到每个 catch 块开头。
这三行代码,就是我在某高校教务系统上线前夜,从凌晨两点 debug 到四点,用 17 次失败换来的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取