☰
SpringBoot + Ant Design Vue实现Excel导入:从上传到落库的完整方案
2026/10/15 21:59:32 网站建设 项目流程

简介:面向SpringBoot与AntDesignVue前后端分离项目,这份Excel导入功能实现教程PDF可帮助Java全栈开发者快速掌握文件上传解析的完整链路。资源为单文件PDF压缩包,体积仅198KB,已有2437人学习下载,内容精炼,是同类场景下实用的小型参考。教程从前端a-upload组件切入,详细讲解accept属性限定xlsx/xls类型、customRequest自定义上传方法、change事件对uploading/done/error状态的监控,并给出上传中切换loading图标、禁用按钮等交互细节。后端部分则演示Controller通过MultipartFile接收文件,Service层完成学生信息表数据的批量解析导入,前后端代码对照清晰。通过这份资源,读者可掌握从文件选择、上传请求封装到后端解析入库的完整实现思路。

1. 从Excel导入需求说起:一个贯穿前端上传到后端落库的完整链路

做管理后台时,Excel 导入基本是躲不开的需求:操作员手里拿着整理好的名单或业务数据,想一口气灌进系统,而不是在表单里一行行手工录入。SpringBoot + AntDesignVue 这套组合里,导入功能通常由 Ant Design Vue 的 upload 组件承接文件选择与上传动作,后端再用 Apache POI 解析工作簿并批量写入数据库。这个链条不长,但每一环都有不少隐性问题:前端文件类型过滤不严、后端参数名对不上、单元格类型判断不到位,轻则导入失败,重则数据错乱。这篇文章就围绕一个典型的「学生信息表导入」场景,把前端组件配置、后端解析、批量落库和事务回滚讲清楚,适合正在做后台管理系统、想快速接入 Excel 导入的开发者。

2. AntDesignVue 前端导入组件:accept 白名单与 customRequest 的取舍

2.1 upload 组件接入:accept 谁能传、customRequest 谁去传

导入功能的第一步是把上传组件放到页面上。Ant Design Vue 的 a-upload 本身封装了文件选择、进度展示、上传动作,默认行为是组件内部自己发起 Ajax 请求。但在实际项目里,网络请求大多已经封装在统一的 axios 实例里,包含鉴权、拦截器、错误提示,这时再用组件默认的上传逻辑反而不方便。于是 customRequest 就该登场了:它把上传动作的控制权交还给我们,组件只负责帮你弹出文件选择框、拿到 File 对象。

先看最小可用的模板代码:

<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 类型字符串,多个类型用逗号隔开。application/vnd.openxmlformats-officedocument.spreadsheetml.sheet对应 .xlsx,application/vnd.ms-excel对应 .xls。需要注意,accept 只是「建议性过滤」,用户依然可以在文件选择框里切换为「所有文件」选择一个非 Excel 文件。所以前端还可以在 customRequest 里对 file.type 做二次校验,后端也必须做类型校验,三层都做才算稳。

customRequest 是覆盖默认上传行为的关键入口。组件会传入一个参数对象,里面至少包含 file、onProgress、onSuccess、onError 这几个字段。我们把 file 取出来,装进 FormData,然后调用项目里封装好的请求方法。onProgress 可以用来手动触发进度条更新,不过大多数导入场景不需要精细进度,只要让按钮转圈就够了。

data 里需要提前声明的属性就两个:一个记录当前图标的类型,一个控制按钮和上传组件的禁用状态。代码中 icon 用的是 Ant Design Vue 内置的 to-top 上传图标,loading 状态则切换成 loading 图标。注意,Vue 2 的写法里 icon 属性直接传字符串名称就可以,Vue 3 + Ant Design Vue 3.x 的写法略有差异,如果用图标组件方式引入,记得改成<upload-outlined />这类组件形式。

2.2 自定义上传方法:FormData 传参和网络请求封装

customRequest 的实现看起来简单,但写的时候有几个容易忽略的点。先看代码:

customRequest(data) { const file = data.file; // 需要上传的 excel 文件 const formData = new FormData(); formData.append('file', 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")括号里的名字保持一致,这是导入功能里最容易翻车的地方。前端写了 file,后端写成了 excelFile,请求发出去后端直接报「缺少参数 file」,或者 MultipartFile 为 null。我一般会把这个字段名抽成常量,前端后端各写一个枚举或常量类,避免两边的字符串静默漂移。

batchImportGetRecord 是项目里封装好的 axios 请求方法。拿不到这个封装时,直接用 axios 也是同样的思路:

import axios from 'axios'; export function batchImportGetRecord(formData) { return axios.post('/api/student/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(res => res.data); }

这里有一个细节:FormData 上传时请求头不要手动指定Content-Type: multipart/form-data,让浏览器自动带上 boundary 边界符才是正确的。但如果你的项目里 axios 有全局的 Content-Type 默认值,就必须在单次请求里覆盖掉,否则后端解析 MultipartFile 会失败。具体表现是后端能收到请求,但 file 参数始终为 null,或者 Spring 直接抛出 MultipartException。这类问题不要靠猜,打开浏览器开发者工具看请求头,对比 Content-Type 是否带上了 boundary 就能定位。

data.onProgress() 这行代码在拿到文件后立刻调用,作用是通知 upload 组件进入上传中的状态。如果省略这行,change 事件里的 status 可能不会切到 uploading,按钮的 loading 动画就不触发。调用时机放在 formData 构造完成后、请求发出前即可。

请求完成后的处理逻辑里,res 的判定依据要和你后端接口的返回值对齐。后端返回 Boolean,前端就直接用if (res)判断。如果后端返回的是{ code: 200, data: true }这类结构,那这里就得改成if (res.code === 200 && res.data)。前后的响应结构约定,最好在设计接口时就用统一格式包裹,不要一个接口返回裸 Boolean,另一个返回 JSON 包,前端每个方法写一套判断逻辑,后期维护成本很高。

Finally 块里的switchIconAndStatus(false)保证了请求无论是成功还是失败,按钮都能恢复到可点击状态。如果不写 finally,一旦请求异常且 then 里没有对应的 catch,按钮就会一直卡在 loading 的禁用态,用户想重新上传都点不动,只能刷新页面。

2.3 上传状态机:change 事件和按钮防重复点击

change 事件在文件上传过程中会被多次触发,每次触发时 file.status 都对应不同阶段。代码里用三个分支处理 uploading、done、error,本质上是一个微型状态机。

customChange(data) { if (data.file.status === 'uploading') { this.switchIconAndStatus(true); } else if (data.file.status === 'done') { this.switchIconAndStatus(false); } else if (data.file.status === 'error') { this.switchIconAndStatus(false); } }

uploading 状态把按钮禁用、图标切换为 loading,目的是阻止用户在导入过程中再次点击提交。这一点对大数据量的 Excel 尤其重要:如果文件里有几千行数据,后端解析加批量插入可能要好几秒,期间用户因为没反馈而重复点击,就会出现多个导入任务并发执行,重复数据落库、数据库连接被占满,甚至导入顺序错乱。

switchIconAndStatus 方法把状态切换收敛到了一个函数里,避免在多个分支里重复写赋值逻辑:

switchIconAndStatus(flag) { if (flag) { this.uploadIcon = 'loading'; this.uploadDisabled = true; } else { this.uploadIcon = 'to-top'; this.uploadDisabled = false; } }

这个方法本身不复杂,但它的意义不只是换图标:uploadDisabled 同时控制着 a-upload 和 a-button 的禁用状态,挡住了「选择新文件」和「点击导入」两个入口。用户看到的交互反馈是图标变成转圈、按钮置灰,这比单纯弹一个「导入中」提示更加直观。

一个小细节是 file.status 在 Ant Design Vue 1.x 和 2.x 中行为基本一致,但在 React 版 antd 和 Vue 版之间有一些实现差异。如果你在 Vue 项目里参考了 React 版的写法,比如直接用data.file.response取后端返回值,可能会拿到 undefined。Vue 版里要拿到后端响应,建议通过 customRequest 内部的 Promise 链传递,或者用data.onSuccess(res)手动标记成功并把响应挂到 file.response 上。原代码里 customRequest 没有调用 onSuccess,文件状态可能不会自动变成 done。我实际调试时会额外调用一次data.onSuccess(res),这样 change 事件里才能稳定收到 done 状态。

3. SpringBoot 后端接收文件:Controller 参数绑定与 Apache POI 解析细节

3.1 Controller 层:MultipartFile 接收和参数名对齐

前端把文件传过来,后端第一步是在 Controller 里接收它。SpringBoot 处理文件上传依赖 MultipartFile,它把上传的文件内容包装成 InputStream 或 byte 数组供后续解析。

@PostMapping(value = "import") public Boolean importExcelData(@RequestParam("file") MultipartFile file) { return studentService.importExcelData(file); }

这段代码有两个关键点:第一是@RequestParam("file")里的file必须和前端 FormData 的 append 字段名一致;第二是请求方式必须为 POST,MultipartFile 不会绑定到 GET 请求上。如果前端传参名是 excelFile,后端却写@RequestParam("file"),Spring 会直接抛出 MissingServletRequestParameterException。从表现上看,前端请求能发出,后端接口能进,但参数绑定失败,返回 400。

SpringBoot 默认上传文件大小限制是 1MB。如果你导入的 Excel 文件超过这个值,前端请求会收到一个 500 错误,后端控制台输出 MaxUploadSizeExceededException。这时需要在配置文件中调整限制:

spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB

max-file-size 限制单个文件大小,max-request-size 限制整个请求体的大小。如果表单里除了文件还有其他字段,建议两个都调,避免出现「单个文件没超限但请求整体超限」的玄学错误。另外,如果项目前端用了网关或 Nginx 反向代理,代理层也可能有自己的 body 大小限制,本地能传、走正式环境就报 413,大概率是 Nginx 的 client_max_body_size 没配。

3.2 Apache POI 解析 Excel:Workbook 与 Sheet 加载原理

服务层是导入逻辑的核心。原代码使用 XSSFWorkbook 从文件输入流中构建工作簿对象,然后读取第一个工作表:

@Override public boolean importExcelData(MultipartFile file) { List<Student> list = new ArrayList<Student>(); try { XSSFWorkbook workbook = new XSSFWorkbook(file.getInputStream()); XSSFSheet sheet = workbook.getSheetAt(0); for (int i = 0; i < sheet.getPhysicalNumberOfRows(); i++) { if (i == 0) continue; XSSFRow row = sheet.getRow(i); if (row == null) continue; Student student = new Student(); for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) { XSSFCell cell = row.getCell(j); if (cell == null) continue; 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: SimpleDateFormat sdf = new SimpleDateFormat("yyyy年M月d日HH:mm:ss"); Date date = sdf.parse(cellValue); student.setBirthday(date); break; } } list.add(student); } studentService.saveBatch(list); return true; } catch (Exception e) { e.printStackTrace(); System.err.println("导入失败"); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); return false; } }

这段代码把 POI 的核心 API 用得很典型,但要真正用好,得先理解几个方法的边界。

XSSFWorkbook 只支持 .xlsx 格式。.xls是 Excel 97-2003 格式,对应的是 HSSFWorkbook。如果用户拿一个 .xls 文件走这套代码,运行时会在new XSSFWorkbook(InputStream)这一行抛异常,因为文件内部的 OLE2 容器结构不是 XSSF 能解析的。常见做法是用 POI 提供的 WorkbookFactory.create(InputStream) 统一创建,它会根据文件头自动选择 XSSFWorkbook 还是 HSSFWorkbook,代码只需面向 Workbook 接口编程。这也是社区主流的做法,省去了写两套解析逻辑的麻烦。

getPhysicalNumberOfRows 和 getLastRowNum 是容易混淆的两个方法。前者返回「物理行数」,也就是实际有内容的行数量,后者返回最后一行的行号(从 0 开始)。如果 Excel 中间有空行,这两个返回值就不一致。用 getPhysicalNumberOfRows 遍历时,必须配合if (row == null) continue来跳过空行,否则 getRow(i) 返回 null 后,下一行调用 row.getCell(j) 就是空指针异常。原代码的空行判断写得没问题,这是经验之谈,新手的常见误用是直接不判断就去取行,或者把判断条件写在for循环的条件表达式里,导致空行位置的行号统计混乱。

getSheetAt(0) 取的是第一个工作表。如果用户的 Excel 文件里有多个 Sheet,业务上应该只解析第一个,这个逻辑符合多数场景。但更严谨的做法是先通过workbook.getNumberOfSheets()校验文件里是否真的有 Sheet,避免用户上传一个空白工作簿时抛 IndexOutOfBoundsException。

3.3 单元格类型转换:字符串、数字、日期的取值策略

原代码里对单元格的处理有一个隐藏问题:它先getStringCellValue()又getNumericCellValue(),但一个单元格只会对应一种类型。POI 的 Cell 对象有 CellType 枚举,取值前应该先判断类型。

switch (cell.getCellType()) { case STRING: student.setName(cell.getStringCellValue()); break; case NUMERIC: student.setAge((int) cell.getNumericCellValue()); break; case BOOLEAN: student.setHobby(String.valueOf(cell.getBooleanCellValue())); break; case FORMULA: student.setAddress(cell.getCellFormula()); break; default: break; }

为什么原代码在类型不确定时还能跑?因为只要单元格存的是字符串,getStringCellValue()就能取到值;存的是数字,getNumericCellValue()也能取到值。但如果 Excel 里某个「年龄」列填写的是"18"(字符串),getStringCellValue()取出来是字符串18,赋值给 double 就变成走getNumericCellValue()分支去调用,直接抛 IllegalStateException。同理,导出的日期如果被识别成了数值单元格,取出来的就是一个序列号,比如 45123 这样的数字,必须经过 DateUtil 转换。

日期处理是另一个易错点。原代码把日期单元格当字符串解析,前提是 Excel 里这个列的单元格格式是文本。如果用户在 Excel 里设置了「日期」格式,POI 读到的 CellType 是 NUMERIC,getStringCellValue() 就会直接报错。正确做法是先判断DateUtil.isCellDateFormatted(cell),是日期格式则用 cell.getDateCellValue(),否则再按普通值处理。这个分支写起来代码量会增加,但能大幅减少生产环境里「同一个模板不同人填导致类型不统一」的翻车概率。

关于空单元格的判断,原代码if (cell == null) continue只是跳过 null 指针,但 CellType.BLANK 类型的单元格也能取到对象,继续走类型分支时 default 或空白处理必须有兜底。否则到了日期解析那一步,一个空白字符串传给 SimpleDateFormat.parse,就会抛出 ParseException。把「空值和空类型」统一归为默认跳过逻辑,才是更完整的策略。

4. 数据落库与事务回滚:批量插入、实体映射与 setRollbackOnly 的边界

4.1 逐行映射实体:从硬编码 switch 到列名映射

原代码里用switch (j)通过列索引映射实体字段,优点是直观、适合列数较少的场景,缺点是列顺序一变,映射就全乱。一个含 50 列的大表格,每一列都要数一遍下标,维护成本很高。

我一般会在项目里引入一个简单的列顺序约定:定义常量或枚举记录每列的期望标题,解析完表头后构建一个Map<列索引, 列名>,再用列名驱动实体字段映射。这种方式模板里列的顺序即使前后挪动,只要列名不变,解析逻辑就不用改。

如果不想引入太重的东西,也可以做一个工具方法:读第一行表头,按表头内容建立 columnIndex 到 ExcelColumnEnum 的映射,然后遍历数据行时通过映射取值。这个思路实现成本不高,但可读性比 switch (j) 提升不少。尤其是当团队里有新同学接手维护时,「第 4 列是出生日期」这种隐式约定远不如一行case BIRTHDAY: student.setBirthday(...)来得清晰。

4.2 MyBatis-Plus saveBatch 批量插入的效率和等价写法

解析完成后的 list 集合,最后通过studentService.saveBatch(list)一次性写入数据库。MyBatis-Plus 的 saveBatch 并不像名字暗示的那样生成一条「insert into ... values (), (), ()」的多值 SQL,它内部默认使用 SqlSession 批量提交,分批执行 insert,每批默认 1000 条。

那为什么推荐批量而不是循环单条插入?代码里已经说明白了:每执行一条 SQL,应用和数据库之间就有一轮网络往返。1000 条数据循环插入就要 1000 次网络请求,耗时和数据库负载都会直线上升。saveBatch 将这 1000 条分到若干个批次里,每批一次预处理、执行、提交,整体效率提升在数据量越大时越明显。实测中,2000 条数据从循环逐条插入改为 saveBatch,耗时常能从十几二十秒降到两三秒,主要原因就是网络往返从 2000 次降到几次。

使用 saveBatch 有一个前提:实体类上的 @TableName、@TableId 等注解要配好。如果主键策略配错,批量插入时可能会因为主键冲突或主键为空而整体失败。MyBatis-Plus 默认主键策略是 ASSIGN_ID(雪花算法),如果你在数据库里用的自增主键,记得在实体类主键字段上加@TableId(type = IdType.AUTO),否则数据库自增不生效,批插入就会带一个雪花 ID 到库里。

4.3 事务回滚:@Transactional 与 setRollbackOnly 的适用区别

原代码在 catch 块里手写了TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),这行代码本身能工作,但依赖一个重要前提:当前线程必须已经处于 Spring 管理的事务中。如果没有开启事务,currentTransactionStatus() 会直接返回 null,调用 setRollbackOnly() 就抛空指针。所以正确的使用姿势是给 Service 方法加上 @Transactional 注解:

@Transactional(rollbackFor = Exception.class) public boolean importExcelData(MultipartFile file) { ... }

@Transactional 的默认行为是只对 RuntimeException 回滚,对 checked exception 不回滚。但代码里 catch 块捕获的是 Exception,包括 IOException、ParseException 这类受检异常。如果方法上只写@Transactional不指定 rollbackFor,导入流程中抛出 ParseException 后,方法返回 false,事务却可能已经提交了部分数据。这就是「导出一半成功、一半失败的脏数据」问题的根源。由于原代码把异常 catch 住并返回 false,事务框架看到的不是异常,而是方法正常返回,自然不会回滚。所以有两个修正方向:要么在注解里声明rollbackFor = Exception.class,让 Spring 对受检异常也回滚;要么在 catch 里重新抛出运行时异常,把异常控制权还给事务切面。

还有一点:setRollbackOnly 只标记事务需要回滚,并不会立即触发回滚,事务边界要等方法执行完才由事务管理器统一处理。如果在 try 块里已经做了部分数据库写入,最终回滚后这些写入都不会落库。只要事务边界跨整个 importExcelData 方法,这里的回滚就没有遗漏。

@Transactional(rollbackFor = Exception.class) @Override public boolean importExcelData(MultipartFile file) { ... try { ... return true; } catch (Exception e) { log.error("导入失败", e); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); return false; } }

不过,事务边界覆盖的范围是整个解析和批量插入。如果 Excel 文件很大,这批数据在解析阶段并不占用数据库连接,但事务是从方法开头就开启的,数据库连接会被占住较长时间。一个折中做法是:先解析文件并完成校验,不操作数据库,全部校验通过后再开新事务插入数据。但 MyBatis-Plus 的 saveBatch 在 Service 方法内部,只要它在事务内就会参与同一事务。如果非要分离解析和写入,通常是把 saveBatch 放在另一个事务传播机制为 REQUIRES_NEW 的方法里,或者干脆去掉外层 @Transactional,只在写入方法上加注解。大多数场景保持原代码思路即可,但连接占用问题要心里有数。

5. Excel 导入常见踩坑:现象、原因与解决方案

5.1 前端上传成功,后端收不到 file 参数

现象:浏览器 Network 面板里能看到请求发出,并且请求方法是 POST,但后端接口报 MissingServletRequestParameterException,或者进到方法里 MultipartFile 为 null。

原因:前端 FormData 的 append 字段名与后端 @RequestParam 的值不一致。比如前端写formData.append('excel', file),后端写@RequestParam("file")。另一种常见可能:axios 请求头手动设置了Content-Type: multipart/form-data,导致请求体里缺少 boundary,Spring 无法解析 multipart 内容。

解决:统一前后端字段名,建议直接复制同一个字符串到两端;删除手动设置的 Content-Type,让浏览器或 axios 自动生成带 boundary 的请求头;如果项目封装层强制设置了 Content-Type,在单次请求配置里覆盖为空或用headers: {}绕过全局默认。

5.2 .xls 文件解析报错或读取为乱码

现象:用户上传 .xls 文件,后端抛异常,日志显示Office XmlException或UnsupportedFileFormatException,上传 .xlsx 则正常。

原因:XSSFWorkbook 只处理 OOXML 格式,也就是 .xlsx。.xls 是 OLE2 复合文档格式,必须用 HSSFWorkbook 解析。

解决:使用WorkbookFactory.create(file.getInputStream())替代直接 new XSSFWorkbook,POI 会根据文件内容的魔数自动选择 XSSF 或 HSSF。返回类型声明为 Workbook,sheet 和 row 的类型也用接口声明,代码即可同时兼容两种格式。前端 accept 里虽然两种 MIME 都加了,但并不保证用户不手动选择其他格式,后端要做一层扩展名校验,比如判断原始文件名以 .xlsx 或 .xls 结尾,否则直接返回「文件格式不支持」。

5.3 空行和空白单元格导致 NullPointerException

现象:Excel 文件中间有一行完全空白,或某一行里连续几个单元格没填。解析执行到row.getCell(j)后调用取值方法报 NPE,或者日期解析 ParseException。

原因:POI 中 getRow(i) 在行不存在时返回 null,getCell(j) 在单元格不存在时返回 null。原代码判断了 row == null,但如果整行有几个空格子,cell.getCellType()或getStringCellValue()依然可能触发Cannot invoke "org.apache.poi.xssf.usermodel.XSSFCell.getCellType()" because "cell" is null。

解决:循环内对 row 和 cell 都做 null 判断;对 CellType.BLANK 和 CellType._NONE 统一走跳过或默认值;日期单元格必须先用 DateUtil.isCellDateFormatted 判断,再决定取 Date 还是字符串。不要依赖「模板一定会填全」的假设。生产环境中,同一个文件被不同人编辑过,格式差异远超预期。

5.4 单元格里是数字,但代码调用了 getStringCellValue

现象:读取年龄列时抛 IllegalStateException,提示Cannot get a STRING value from a NUMERIC cell。

原因:Excel 单元格类型是数值,但代码里直接用 getStringCellValue(),没有先判断 CellType。

解决:解析前先判断 cell.getCellType(),分类型处理:

switch (cell.getCellType()) { case STRING: value = cell.getStringCellValue(); break; case NUMERIC: if (DateUtil.isCellDateFormatted(cell)) { value = cell.getDateCellValue().toInstant().toString(); } else { double numericValue = cell.getNumericCellValue(); BigDecimal decimal = BigDecimal.valueOf(numericValue).stripTrailingZeros(); value = decimal.toPlainString(); } break; default: value = null; }

这段代码里有几个值得注意的点:DateUtil.isCellDateFormatted(cell)用来识别日期格式的数值单元格;BigDecimal 转字符串可以避免科学计数法带来的问题,比如 Excel 里的身份证号常用数字格式存储,直接用 getNumericCellValue() 拿到的可能是 1.2345678901234567E18,转成字符串后必须用 BigDecimal 处理才能还原身份证号。数字转字符串时,很多资料建议用String.valueOf((long) cell.getNumericCellValue()),这个写法在数字超过 15 位时会丢精度,不建议用在身份证、手机号这类长数字列上。最稳妥的做法是单元格格式在模板里就设为文本,后端校验时如果发现是数值单元格,可以用上述 BigDecimal 方案转换。

5.5 导入失败但数据部分落库,事务回滚没生效

现象:Excel 第 100 行数据格式错误,方法返回 false,但数据库里已经插入了前 99 行数据。

原因:最常见的是 Service 方法上没有加 @Transactional,或者加了但没有指定 rollbackFor = Exception.class。原代码在 catch 里捕获异常后返回 false,事务管理器看到方法正常返回,认为可以提交;没有异常抛到事务边界,自然不会回滚。

解决:给 importExcelData 方法添加@Transactional(rollbackFor = Exception.class)。如果想保留 catch 块记录日志和返回 Boolean 的写法,就在 catch 里调用 setRollbackOnly() 但必须保证当前线程有活动事务。另一种更干净的做法是:解析阶段不操作数据库,把错误直接抛给 Controller,由全局异常处理器统一返回错误信息;Service 方法不做 try/catch 吞异常,事务边界才能完整生效。实践里我倾向于后者:数据校验失败时抛一个自定义的业务异常,事务回滚由框架接管,前端拿到统一错误提示。

6. 把导入功能做得更稳:模板校验、异步导入和二次封装思路

先说说模板校验。原实现几乎没做「用户传错模板」的兜底:哪怕文件第一列不是姓名,后端也会尝试解析并落库。一个低成本的改进是预先定义一个模板列名数组,解析时先读第一行表头,用row.getCell(j).getStringCellValue()和预期列名比对。如果对不上,直接在解析前结束,返回「请使用标准模板导入」。这个逻辑能在数据入口处挡掉大部分人为错误,比等到数据行解析了一半才报错体验好得多。

再看异步导入。当 Excel 行数到达上万行时,同步解析加批量插入可能要几十秒,前端按钮长时间转圈,用户的浏览器连接也容易超时。常见做法是后端先把文件内容暂存,然后丢到一个线程池里异步处理,立即返回「正在导入」的状态。前端再去请求一个导入进度接口,定时轮询或走 WebSocket 推送进度。这个方案的实现成本主要在线程池管理和任务状态存储,但对大文件导入场景是值得的。

最后说封装。原代码里 Controller、Service、POI 解析、实体映射全部揉在一个方法里。接手新项目时我会把这些拆成三层:一个通用的 ExcelReader 工具类,负责把流解析成 List<Map<String, Object>>,按表头列名取值;一个业务转换层,把 Map 列表转成实体列表并做数据校验;一个持久层调用 saveBatch。前端也可以抽一个 ExcelImportButton 组件,把 accept、customRequest、状态切换封装进去,外部只传一个上传接口地址和成功回调。这样每个新模块要加导入功能时,前后端的重复代码量都会大幅减少。

我记得有一次对接一个外部系统给的 Excel,里面列顺序和文档里写的不一致,用户反馈「明明文件没问题却导不进去」。排查到最后发现是表头里多了个空格,列名匹配失效。从那以后我每接一个导入需求,都强制在联调清单里加一条:用真实业务文件走一遍全链路,并且刻意删掉中间一两列、在日期列填一个乱值,验证校验逻辑和错误提示是不是真的能被用户看懂。这套「破坏性测试」流程虽然麻烦,但每次都帮我省下了上线后半夜查数据的功夫。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询