首页 / 资讯中心 / 文章详情

SpringBoot+AntDesignVue Excel导入四道关口与避坑

SpringBoot+AntDesignVue Excel导入四道关口与避坑 ★ FEATURED ARTICLE
简介这份PDF面向使用SpringBoot与AntDesignVue开发管理系统的前后端开发者聚焦Excel导入这一常见业务场景提供从组件配置到接口落地的完整实现参考。内容围绕Ant Design Vue的upload组件展开涵盖通过accept限定xlsx与xls文件类型、以customRequest接管默认上传、用change监听上传中、完成、失败状态并结合loading图标与按钮禁用避免大数据量导入时的重复点击同时说明FormData封装文件参数、沿用项目axios请求方法的做法后端则给出Controller使用MultipartFile接收文件、Service解析并写入学生信息表的示例。资源包仅含1个PDF文件约198KB篇幅轻量、重点集中适合快速查阅关键代码与前后端联调思路。目前已有2440人学习可作为管理系统导入功能的实现模板也便于开发者对照梳理参数命名、状态反馈与异常处理等细节。1. 从「导入按钮被连点三次」说起SpringBoot AntDesignVue 的 Excel 导入真正难的是那四道关口一个两百来行的学生信息表用户嫌页面没反应连点了三次导入数据库里多出六百条重复记录——这是 SpringBoot AntDesignVue 做 Excel 导入时最典型的出错场景。前端 a-upload 组件不会拦第二次点击后端 saveBatch 也不会帮你查重依赖用户自觉就一定会出问题。这套方案的骨架并不复杂前端用 Ant Design Vue 的 upload 组件配合 customRequest 接管上传后端用 MultipartFile 接收再用 POI 的 XSSFWorkbook 逐行解析最后 MyBatis-Plus 批量写入数据库。决定它能不能上生产的不是这几行代码写得漂不漂亮而是文件类型校验、上传状态机、单元格类型判定、事务回滚这四道关口有没有堵死。这篇拆解适合已经搭好前后端架子、准备把导入做成通用能力的同学前端部分看得懂 Vue后端部分看得懂 Spring 注解就能跟上。2. 前端上传链路customRequest 接管之后a-upload 还替你管什么a-upload 有两种活法给一个 action 让它自己发请求或者用 customRequest 把发请求的活抢过来自己干。项目里百分之九十的麻烦都来自第一种——组件内部的 XMLHttpRequest 不经过你的 axios 拦截器token 带不上、统一错误码解析不了、baseURL 和代理配置还可能冲突。抢过来之后组件退化成「选文件 维护状态」的角色剩下的全归你管这才有可控性。2.1 默认 action 方式在这里为什么走不通用 action 的写法组件在内部创建请求字段名固定、请求头固定、返回体拿到的也是原始响应对象。项目里通常有一层统一封装请求头塞 token、响应体按 code 判断成功失败、失败时统一弹提示。这套东西在 action 模式下全用不上你得在 onSuccess 里再解析一遍响应体等于把拦截器的活重写一份。customRequest 接管的是一次完整的「发送」动作参数对象里带着 file、onProgress、onSuccess、onError、action、data、filename、headers、withCredentials 这些字段。你只需要关心三件事文件怎么塞进 FormData、请求发给谁、结果怎么回传给组件。回传这一步是很多人漏掉的后面第 5 章会专门说。还有一层隐性收益接管之后可以对同一个文件做二次处理比如上传前算 MD5 做幂等、压缩图片、拆分大文件分片。默认 action 模式下这些都得绕路。2.2 accept 只是浏览器的过滤器beforeUpload 才是硬校验看这段模板代码accept 里写了两个 MIMEa-upload acceptapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel :customRequestcustomRequest :beforeUploadbeforeUpload :disableduploadDisabled :file-listfileList changecustomChange a-button typeprimary :iconuploadIcon :disableduploadDisabled导入/a-button /a-uploadaccept 对应的是原生input typefile的 accept 属性作用只有一个在系统文件选择框里做一次过滤。用户在弹窗里把筛选切成「所有文件」或者直接拖拽一个改了扩展名的文件进来accept 完全拦不住。所以真正的校验必须写在 beforeUpload 里export default { data() { return { uploadIcon: to-top, uploadDisabled: false, fileList: [], // 受控文件列表用于手动清空 allowExt: [xlsx, xls], maxSize: 5 * 1024 * 1024 }; }, methods: { /** * 上传前的最后一道前端校验 * param {File} file 原生 File 对象 * returns {boolean} false 表示中止不会进入 customRequest */ beforeUpload(file) { const ext (file.name.split(.).pop() || ).toLowerCase(); if (!this.allowExt.includes(ext)) { this.$message.error(只支持 .xlsx 或 .xls 文件); return false; } if (file.size this.maxSize) { this.$message.error(单个文件不要超过 5MB); return false; } return true; } } };allowExt决定放行哪些扩展名判断用的是文件名后缀而不是 MIME因为 MIME 在不同系统上不稳定同样是 .xls有的浏览器给application/vnd.ms-excel有的给application/octet-stream只靠 MIME 判断会误杀。maxSize卡的是体积它和后端max-file-size是两道独立的闸前端这道只为了少发一次无效请求真正起作用的是后端那道。fileList这个受控列表值得单独提一句。不绑定它的时候a-upload 自己在内部维护列表用户第二次选同一个文件change 事件可能压根不触发表现就是「点了没反应」。绑定成受控之后在 done 或 error 分支里把它清空下一次选同名文件就能正常走流程。2.3 FormData 字段名和请求头是后端接不到文件的两大原因文件上传只有一条路把 File 塞进 FormData然后让浏览器自己生成 boundary。customRequest(option) { const { file, onProgress, onSuccess, onError } option; const formData new FormData(); // 这个 key 必须和后端 RequestParam(file) 里的名字一模一样 formData.append(file, file); // 手动把组件状态推到 uploading否则按钮不会有任何变化 onProgress({ percent: 30 }); batchImportGetRecord(formData, { // 关键不要手写 Content-Type交给浏览器补 boundary headers: { Content-Type: undefined }, timeout: 120000, onUploadProgress: e { if (e.total) { // 把 axios 的真实进度同步给组件 onProgress({ percent: Math.round((e.loaded / e.total) * 100) }); } } }).then(res { if (res res.code 0) { onSuccess(res, file); } else { onError(new Error(res.msg || 导入失败)); } }).catch(err { onError(err); }).finally(() { this.switchIconAndStatus(false); }); }三个参数点逐个说明。formData.append(file, file)的第一个参数是字段名后端RequestParam(file)括号里写的必须是同一个字符串差一个字母就是 400 或者「Required request part file is not present」。headers: { Content-Type: undefined }的作用是覆盖掉 axios 实例上可能统一设置的application/json——如果拦截器里把 Content-Type 写死了FormData 会被序列化成一串 JSON 字符串发出去后端收到的永远是空文件。最省事的做法是让浏览器自己加multipart/form-data; boundary----xxx手动写死反而不带 boundary。timeout单独设长一点默认的 10 秒在几千行数据面前根本不够用接口超时了前端报错、后端还在写库用户重试又是一份重复数据。3. 上传按钮的状态机用 file.status 和 loading 图标阻止重复提交按钮的视觉反馈不是装饰它承担的是「并发控制」的职责。用户在等待期间看不到任何变化第一反应就是再点一次这个动作在几百毫秒内可能重复三到五次。把按钮从「可点」变成「转圈且禁用」比后端加分布式锁便宜得多也直观得多。3.1 file.status 的四个取值和真实触发顺序a-upload 抛给 change 事件的data.file.status有四种取值status含义什么时候出现uploading上传中调用了onProgress()之后done上传成功调用了onSuccess()之后error上传失败调用了onError()之后removed已移除用户点了文件列表的删除按钮注意这里的主语状态是「你调用了回调之后」才出现的不是组件自己感知到的。组件不知道你的 axios 请求什么时候返回它只认你手动调的那三个回调。这就是第 5 章第一条问题的根源——只调 onProgress 不调 onSuccessstatus 会永远停在 uploading按钮一直转圈用户等到不耐烦刷新页面后端可能已经写入数据了。完整的 change 处理长这样customChange(data) { const status data.file.status; if (status uploading) { this.switchIconAndStatus(true); } else if (status done || status error) { this.switchIconAndStatus(false); // 清空受控列表保证同名文件能再次触发 change this.fileList []; } else if (status removed) { this.switchIconAndStatus(false); } }3.2 switchIconAndStatus 的封装以及 finally 放在哪原文把图标和禁用状态抽成一个方法这个思路是对的两个状态必须同进同出否则会出现「转圈但按钮还能点」或者「按钮灰了但图标还是静态」的割裂。/** * 统一切换导入按钮的图标与禁用状态 * param {boolean} flag true正在导入false空闲 */ switchIconAndStatus(flag) { this.uploadIcon flag ? loading : to-top; this.uploadDisabled flag; }调用点只有三处change 的 uploading 分支、change 的 done/error 分支、customRequest 的 finally。第三处是兜底——如果请求链路上某一段抛了未捕获的异常没有 finally 的话按钮会永久卡在转圈状态用户只能刷新页面。关于图标loading在 Ant Design Vue 的图标体系里是自带旋转动画的to-top是一个静态箭头视觉上足够区分「空闲」和「处理中」。不要用两个静态图标换来换去用户分辨不出来。3.3 超时、进度和取消大文件场景必须补的三件事小文件测试没问题一上千行就开始出问题往往是这三项没做。第一是超时。axios 默认超时通常在 10 秒量级五千行 Excel 走完解析加批量插入十几秒很正常。要么把 timeout 提到 120 秒要么改成异步任务模式接口立刻返回一个任务 ID前端轮询进度这样按钮状态和真实进度能对上。第二是进度。customRequest 的onProgress是给组件用的真实进度得从 axios 的onUploadProgress里取。注意区分上传进度和后台处理进度——文件传完只是第一步后端还在解析和写库此时进度条到 100% 但按钮不能恢复所以进度只用来安抚用户状态切换依然以 finally 为准。第三是取消。用户选错文件想中止可以用 AbortController 挂到 axios 请求上在 removed 分支里调用 abort。不强求但导入这种重操作留个退出通道体验差别很明显。4. 后端解析链路MultipartFile 到 XSSFWorkbook 的坐标模型前端把所有事情做完交给后端的只是一个字节流。后端要做的是接住流、识别格式、按行列坐标把值取出来、映射成实体、批量写库、出错能回滚。这五步里任何一步想当然都会在线上演变成脏数据或者内存溢出。4.1 Controller 层和 multipart 配置第一道限制是文件大小SpringBoot 的 multipart 默认限制是单文件 1MB、单请求 10MB。这个默认值在测试阶段很容易被忽略因为几百行的小表根本到不了 1MB一旦换成真实业务数据直接抛MaxUploadSizeExceededException前端只看到 500。spring: servlet: multipart: max-file-size: 10MB # 单文件上限默认 1MB max-request-size: 20MB # 整个请求上限默认 10MB file-size-threshold: 2MB # 超过这个体积写临时文件不再全放内存file-size-threshold这个参数容易被跳过它决定超出的部分是否落盘成临时文件。导入场景把它设小一点比如 512KB能显著降低高并发时的堆内存压力。控制器只做三件事判空、判后缀、转交服务层。RestController RequestMapping(/api/student) public class StudentController { Resource private StudentService studentService; /** * Excel 导入入口 * param file 前端 FormData 中 key 为 file 的文件名字必须一致 */ PostMapping(/import) public ResultInteger importExcel(RequestParam(file) MultipartFile file) { if (file null || file.isEmpty()) { return Result.fail(上传文件为空); } String name file.getOriginalFilename(); if (name null) { return Result.fail(文件名解析失败); } String lower name.toLowerCase(); if (!lower.endsWith(.xlsx) !lower.endsWith(.xls)) { return Result.fail(仅支持 .xlsx 与 .xls 格式); } return Result.ok(studentService.importExcelData(file)); } }RequestParam(file)的字符串是硬约束和前端formData.append(file, ...)必须完全一致。后缀判断是第二道防线防止有人改了扩展名硬传一个 CSV 或 ZIP 上来POI 解析时抛出的异常信息极难定位。4.2 行与列的遍历先搞清楚「下标」和「数量」的区别POI 的坐标模型有两个容易混淆的 API方法返回什么用错会怎样sheet.getPhysicalNumberOfRows()实际有内容的行数中间有空行时下标错位后面的行被跳过sheet.getLastRowNum()最后一行的下标从 0 开始正确但需要自己处理空行row.getPhysicalNumberOfCells()实际有内容的单元格数中间空单元格会让列错位row.getLastCellNum()最后一列的下标 1正确配合 RETURN_BLANK_AS_NULL 使用原文用的是getPhysicalNumberOfRows()和getPhysicalNumberOfCells()这两个方法在数据规整的表里没毛病一旦用户在中间删了几行留白或者某一列忘了填后面的列就整体前移一位姓名被塞进年龄字段映射全乱。4.3 单元格类型判定getStringCellValue 直接调用会抛什么这是后端解析里最常见的一处硬伤。cell.getStringCellValue()在单元格实际是数字类型时会抛java.lang.IllegalStateException: Cannot get a STRING value from a NUMERIC cell用户的 Excel 里「年龄」一列是数字程序按字符串去取整批导入直接中断。同类问题还有getNumericCellValue()遇到文本单元格、公式单元格没有缓存值、日期单元格在不同区域设置下变成一串 5 位数字。通用解法是 DataFormatter它按单元格的「显示值」取字符串数字、日期、百分比都能拿到你肉眼看到的样子Override Transactional(rollbackFor Exception.class) public Integer importExcelData(MultipartFile file) { ListStudent list new ArrayList(); try (InputStream in file.getInputStream(); Workbook workbook WorkbookFactory.create(in)) { // 自动识别 xls / xlsx Sheet sheet workbook.getSheetAt(0); DataFormatter formatter new DataFormatter(); // 按显示值取字符串 int lastRow sheet.getLastRowNum(); for (int i 1; i lastRow; i) { // 从 1 开始跳过表头 Row row sheet.getRow(i); if (row null) { continue; // 整行为空 } Student student new Student(); short lastCell row.getLastCellNum(); // 最后一列下标 1 for (int j 0; j lastCell; j) { Cell cell row.getCell(j, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL); if (cell null) { continue; // 跳过空单元格 } String text formatter.formatCellValue(cell).trim(); switch (j) { case 0: student.setName(text); break; case 1: student.setAge(text.isEmpty() ? null : Integer.valueOf(text)); break; case 2: student.setHobby(text); break; case 3: student.setAddress(text); break; case 4: student.setBirthday(readDate(cell, text)); break; default: break; } } if (student.getName() null || student.getName().isEmpty()) { continue; // 姓名为空视为无效行 } list.add(student); } } catch (Exception e) { // 包成运行时异常抛出交给 rollbackFor 处理 throw new IllegalStateException(Excel 解析失败 e.getMessage(), e); } if (!list.isEmpty()) { studentService.saveBatch(list, 500); // 每 500 条一批 } return list.size(); } /** * 日期列读取优先按真实日期单元格取退化到字符串解析 */ private Date readDate(Cell cell, String text) throws ParseException { if (cell.getCellType() CellType.NUMERIC DateUtil.isCellDateFormatted(cell)) { return cell.getDateCellValue(); } String[] patterns {yyyy年M月d日HH:mm:ss, yyyy年M月d日, yyyy-MM-dd HH:mm:ss, yyyy-MM-dd}; for (String p : patterns) { try { return new SimpleDateFormat(p).parse(text); } catch (ParseException ignore) { // 换下一个格式继续试 } } throw new ParseException(无法识别的日期格式 text, 0); }几个参数说清楚。WorkbookFactory.create(in)会根据文件头自动选择 HSSF 或 XSSF这是兼容 .xls 的唯一正解直接 new XSSFWorkbook 只会读 .xlsx。Row.MissingCellPolicy.RETURN_BLANK_AS_NULL保证空单元格返回 null 而不是抛异常。saveBatch(list, 500)的第二个参数是每批条数默认 1000取 500 是在 SQL 长度和往返次数之间折中字段多、有长文本的时候调到 200 更稳。4.4 事务和 saveBatch为什么回滚要在 catch 里手动做Transactional默认只在遇到 RuntimeException 和 Error 时回滚检查型异常不在其列。原文的 catch 块吞掉异常、返回 false再调TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()——这套写法能工作但有三个前提方法必须走代理调用同类内部自调用不生效、必须已经开启事务否则抛 NoTransactionException、必须用对 ThreadLocal 上的事务状态。更干净的写法是Transactional(rollbackFor Exception.class)配合 catch 里重新抛出一个运行时异常让事务切面自己处理。如果业务上确实需要「返回 false 而不是抛异常给前端」那就保留 setRollbackOnly但要清楚它是最后的保险措施不能替代异常设计。批量插入本身也有取舍逐条 insert 意味着 N 次网络往返几千行就是几千次saveBatch 把多条拼成一条insert into ... values (...),(...)往返次数降到个位数。代价是单条 SQL 变长超过max_allowed_packet会直接失败所以每批条数不能盲目调大。5. 避坑与排查五条从日志里翻出来的真实记录这一章的五条每一条都在不同项目的上线后暴露过。共同点是测试环境用一张规规矩矩的表格能跑通换成真实业务数据就出问题。它们的现象各不相同排查路径却高度相似——先看前端 status再看后端日志的第一个异常行最后看数据库里的实际条数。5.1 上传提示成功按钮却一直转圈现象接口返回 200Toast 也弹了「导入成功」但按钮的 loading 图标不消失用户只能刷新页面。原因customRequest 里只调了onProgress()没调onSuccess()。组件的 status 停留在 uploadingchange 事件的 done 分支永远不触发switchIconAndStatus(false)只写在 finally 里能恢复图标但如果 finally 在组件状态更新之前执行加上没有受控 fileList界面就会卡住。解决把onSuccess(res, file)和onError(err)补到对应分支先把组件状态推到终态finally 只做兜底复位。5.2 .xls 文件报 OfficeXmlFileException现象前端 accept 放行了 .xls用户选中后后端抛org.apache.poi.openxml4j.exceptions.OfficetmlFileException: The supplied data appears to be in the OLE2 Format。原因服务层直接new XSSFWorkbook(file.getInputStream())XSSF 只认 OOXML 格式的 .xlsx遇到 BIFF 格式的 .xls 就报这个错。前端放行两种格式后端只支持一种接口契约对不上。解决换成WorkbookFactory.create(in)按文件头自动派发到 HSSF 或 XSSF。如果确定业务上只允许 .xlsx那就把前端 accept 和后端后缀校验一起收紧别让用户白传一次。5.3 数字列全部报 Cannot get a STRING value现象前几行正常到了某个用户的数据就抛出IllegalStateException整批回滚。原因cell.getStringCellValue()只对文本单元格有效。用户的姓名列是文本年龄列是数字同一套代码按字符串取遇到第一个数字单元格就炸。表现上看起来是「某些文件能导、某些不能」其实是列的数据类型不一致。解决统一用DataFormatter.formatCellValue(cell)取显示值需要数值时再对结果做Integer.valueOf。要保留日期语义的列先用DateUtil.isCellDateFormatted(cell)判断是不是日期单元格再用getDateCellValue()取原始值。5.4 导入到一半失败前 300 条已经写入数据库现象前端提示「导入失败」但数据库里多出了几百条记录用户重试后数据翻倍。原因catch 块里e.printStackTrace()之后直接return false异常被吞掉事务切面认为方法正常结束于是正常提交。setRollbackOnly 那一行如果放在 return 之后或者方法上有Transactional(readOnly true)之类的配置都不会生效。解决Transactional(rollbackFor Exception.class)加到方法上catch 里重新抛运行时异常或者把 setRollbackOnly 提到 catch 的第一行。同时在业务上做幂等——导入前按「批次号 唯一键」查重别指望事务能替你去重。5.5 十万行 Excel 直接 OOM现象堆内存监控看着涨几秒后java.lang.OutOfMemoryError: Java heap space服务实例被拖垮。原因XSSFWorkbook 采用全量加载模型整个文件解析成一棵 DOM 树放进内存十万行乘二十列的对象树轻松吃掉几个 G。堆再大也只是推迟爆炸时间不是解决问题。解决超过两三万行的场景换成流式解析EasyExcel 或 POI 的 SAX 事件模型都可以内存占用跟行数基本无关。如果短期不想换库至少给导入接口加一个行数上限超过阈值直接拒绝把「导入失败」变成「请分批导入」——这比让整个服务挂掉体面得多。5.6 一套可以照着走的自检清单按这个顺序排查八成问题能在五分钟内定位前端先看 Network 里的请求体是不是 multipart 且带 boundary再看响应体 code 是否为成功后端先把日志过滤到第一个 Caused byPOI 的异常信息写得很直白格式不对、类型不对、文件损坏都会明说最后对一遍数据库条数与 Excel 有效行数对不上就说明事务或者校验环节有漏。养成这个顺序比对着代码一行行看快得多。6. 进阶把「能跑通」推到「敢给别人用」功能跑通只是起点。真实场景里运营会改列顺序、会插隐藏列、会在中间加一列备注硬编码的 switch case 遇上这些就得改代码。有两种做法收益最高把列下标映射改成表头名映射以及把导入结果做成可下载的错误清单。6.1 用表头名映射列下标抵御列顺序变化思路是把第一行读成一个「列名 → 下标」的字典后续按名字取值而不是按固定位置。// 解析表头建成 列名 - 下标 的索引 MapString, Integer header new HashMap(); Row headRow sheet.getRow(0); for (int c 0; c headRow.getLastCellNum(); c) { Cell cell headRow.getCell(c, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL); if (cell ! null) { header.put(formatter.formatCellValue(cell).trim(), c); } } // 之后取值就变成按名字拿列顺序随便换 Integer nameIdx header.get(姓名); Integer ageIdx header.get(年龄); if (nameIdx null || ageIdx null) { throw new IllegalStateException(表头缺少必填列姓名 / 年龄); } student.setName(formatter.formatCellValue(row.getCell(nameIdx)).trim());代价是需要跟运营约定表头文案换来的是「加一列备注不用改代码」。表头校验放在解析最前面缺列直接整批拒绝别让半张表的数据流进数据库。6.2 导入结果回执把「成功/失败」变成一张清单只返回 true/false 的接口在上线第一周就会被投诉用户不知道哪几行没过、为什么没过。把返回值改成一个结构体记录成功条数、失败条数和每一条失败原因字段类型说明totalintExcel 有效行数不含表头successCountint成功入库条数failCountint校验不通过条数errorsList每条含行号、列名、原始值、失败原因错误清单本身可以直接序列化成一张新的 Excel 让用户下载行号对得上原表用户改完再传一次。这一步做完导入功能才算真正交出去了——用户能自己解决问题你就不用每天在群里回复「第几行错了」。我踩过最贵的一次坑是给一个内部系统做学员信息导入图省事只返回了布尔值。上线当天有三十多个人反馈「导入失败」我一个人对着日志逐条比对行号弄了一个下午。从那以后凡是带批量导入的接口我强制自己先写返回值结构体把行号和原因带出来再写解析逻辑。希望你不用重复这个下午。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站