ASP.NET Core Web API Excel 导入导出实战

发布时间:2026/10/30 6:02:27
ASP.NET Core Web API Excel 导入导出实战

简介:这是一份面向ASP.NET Core WebAPI开发者的Excel处理实战示例包,围绕EPPlus与ExcelDataReader两套常用库,演示服务端读取和导出Excel文件的完整实现,帮助初中级.NET开发者摆脱Office Interop依赖,理解流式读取与文件响应输出等常见需求。压缩包共208个文件,约21.89MB,以149个dll运行与依赖库、12个cs源码及11个json配置为主,另含少量pdb、so、exe等调试和跨平台文件,可直接对照项目结构梳理WebAPI工程组成。已有400人学习下载。读者可从中获得可运行的示例代码、控制器导出写法、依赖配置与项目组织参考,并据此扩展到上传校验、批量数据处理和性能优化等场景,适合用作学习模仿与二次开发的基础工程。

1. 从一张合并表头的订单表说起:excel-handler 解决什么

运营临时甩来一份带合并表头的订单表,要求当天做成可上传、可下载的接口,这类需求在 ASP.NET Core Web API 项目里很常见。excel-handler 这个资源把两条路线放在一起:ExcelDataReader 负责把 .xls/.xlsx 读成数据表,EPPlus 负责带样式、公式和列宽的导出。它适合正在做后台管理、财务对账、批量导入导出的 C# 开发者,也适合想把 Excel 处理从控制器里拆出来的维护者。读表不是难点,难的是表头漂移、日期格式和内存峰值同时出现时还能定位。

2. 把 Excel 读进 Web API:ExcelDataReader 的流式读取与字段映射

2.1 先拆成流、表头、单元格类型三件事

ExcelDataReader 的工作方式偏“读一遍就往前走”,不是先把整个工作簿解析成完整对象树。它拿到Stream后按行推进,适合上传场景:文件从IFormFile.OpenReadStream()进来,服务层只认流,不关心前端是表单上传还是后台任务写入。这个边界很重要,控制器里只做文件大小、扩展名和错误包装,读表逻辑放到独立服务,后面接对象存储或消息队列时不用重写。

表头是第二件事。业务表常见三种表头:第一行就是字段名、第一行是大标题第二行才是字段名、合并单元格导致列名错位。ExcelDataReader 的UseHeaderRow = true只解决第一种,遇到第二种要自己先Read()跳到字段行。合并单元格读出来经常是第一个格有值、后面为null,不要把空列名直接当契约字段,映射层要能报“缺列”而不是默默写空字符串。

单元格类型是第三件事。Excel 里“数量”可能是数字,也可能是文本;日期可能是DateTime,也可能是序列号45200。我一般要求模板固定列名,读取后统一转成字符串再按业务类型解析,解析失败就把行号和列名写进错误列表,不抛一个笼统的 500。这样前端能看到“第 7 行数量不是整数”,而不是“服务器错误”。

提示:部署到 Linux 容器时,老式 .xls 可能涉及代码页编码,先在启动阶段注册编码提供程序,不要等线上报错才补。

2.2 ExcelDataReader 的最小读取服务

下面这段代码只做一件事:把上传流读成DataSet,每个工作表一个DataTable,首行作为列名。它适合中小表和管理后台的导入预览,后面再接强类型映射。

using ExcelDataReader; using System.Data; using System.Text; public sealed class ExcelReadService { public DataSet Read(Stream stream) { // 老式 .xls 可能依赖代码页,启动时注册一次即可 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); using var reader = ExcelReaderFactory.CreateReader(stream); var config = new ExcelDataSetConfiguration { ConfigureDataTable = _ => new ExcelDataTableConfiguration { // 第一行当表头;如果模板有标题行,改用 reader.Read() 手动跳行 UseHeaderRow = true, // 限制列数,防止异常表把内存打满 FilterColumn = columnIndex => columnIndex < 30 } }; return reader.AsDataSet(config); } }

逻辑说明:CreateReader根据文件内容识别格式,调用方不用先判断 xls 还是 xlsx;AsDataSet会把当前读取结果装进DataSet,所以它不是“超大文件零内存”方案,而是“读取 API 简单、适合中小表”的方案。参数上,UseHeaderRow决定是否消费首行;FilterColumn用列索引做白名单,返回false的列不会进入结果。超过几十万行的表,建议改成while (reader.Read())逐行处理,不要一次AsDataSet。

Program.cs里把服务注册进去,控制器只依赖接口或具体类:

var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddScoped<ExcelReadService>(); builder.Services.AddScoped<ExcelExportService>(); var app = builder.Build(); app.MapControllers(); app.Run();

这段注册代码没有复杂参数,关键是生命周期。读表服务无状态,用Scoped足够;如果服务里缓存了工作簿对象,就不要注册成Singleton,否则并发上传会互相污染。导出服务同理,EPPlus 的ExcelPackage必须在单次请求内创建和释放,不能跨请求复用。

2.3 把 DataTable 映射成强类型 DTO

控制器里直接table.Rows[0]["订单号"]写业务,短期快,长期会变成黑匣子:列名一改,编译不报错,运行时才空引用。更稳的做法是固定一个导入行模型,把行号、业务字段和错误列表一起返回。

using System.Data; using System.Globalization; public sealed record ImportRow( int RowNumber, string OrderNo, int Quantity, DateTime? OrderDate, List<string> Errors); public static class ImportRowMapper { public static ImportRow ToRow(DataRow row, int sheetRowNumber) { var errors = new List<string>(); string Text(string columnName) { if (!row.Table.Columns.Contains(columnName)) return string.Empty; return Convert.ToString(row[columnName], CultureInfo.InvariantCulture)?.Trim() ?? string.Empty; } var orderNo = Text("订单号"); if (string.IsNullOrWhiteSpace(orderNo)) errors.Add("订单号为空"); var quantity = 0; var rawQuantity = Text("数量"); if (!int.TryParse(rawQuantity, NumberStyles.Integer, CultureInfo.InvariantCulture, out quantity)) errors.Add("数量不是整数"); DateTime? orderDate = null; if (row.Table.Columns.Contains("下单日期")) { var rawDate = row["下单日期"]; if (rawDate is DateTime dateTime) { orderDate = dateTime; } else if (double.TryParse(Text("下单日期"), NumberStyles.Any, CultureInfo.InvariantCulture, out var oaDate)) { // Excel 日期底层可能是序列号,转成 OADate orderDate = DateTime.FromOADate(oaDate); } else if (DateTime.TryParse(Text("下单日期"), CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) { orderDate = parsed; } else { errors.Add("下单日期格式无法识别"); } } else { errors.Add("缺少下单日期列"); } return new ImportRow(sheetRowNumber, orderNo, quantity, orderDate, errors); } }

逻辑说明:sheetRowNumber传 Excel 里的真实行号,方便运营对照修改;Text方法统一做空值处理和InvariantCulture转换,避免服务器区域设置把小数逗号、日期格式解释成另一种结果;数量解析失败不抛异常,而是写进Errors,让接口一次返回前 20 条问题行。参数上,NumberStyles.Integer只接受整数;日期先判断DateTime,再判断double序列号,最后才走字符串解析,顺序反了容易把数字日期当文本。

3. 用 EPPlus 导出:列宽、样式、公式与大数据量的取舍

3.1 导出为什么优先 EPPlus:样式、公式和列宽都要落地

读表可以用 ExcelDataReader,导出侧我一般选 EPPlus,原因是业务方不只要求“能打开”,还要表头加粗、金额千分位、日期格式、自动筛选、冻结首行,偶尔还要一个合计公式。EPPlus 的ExcelWorksheet能直接按单元格写值、设Numberformat、加AutoFilter和FreezePanes,对后台导出足够直接。

选型时要认清边界:EPPlus 不是流式写出库,GetAsByteArray()会把整个工作簿放在内存里。几千到几万行、十几列的表,这样写最省事;几十万行还硬塞进一个请求,内存峰值就会翻车。我的做法是把“模板简单、行数可控”的导出放在同步接口,把“全量对账、跨月流水”放到后台任务,生成临时文件后返回下载地址。

另一个边界是授权配置。EPPlus 不同使用场景对授权上下文有要求,代码里的LicenseContext要按项目实际情况设置,不要从示例里照抄到商业项目就上线。样式和公式属于表现层,真正要保证的是列顺序、数据类型和空值策略,这三件事在导出前就要定死。

3.2 从 DTO 到工作表的导出代码

下面这段导出代码包含表头样式、金额和日期格式、合计公式、自动列宽和冻结首行。OrderRow用记录类型承接业务查询结果,控制器不需要知道单元格坐标。

using OfficeOpenXml; using OfficeOpenXml.Style; public sealed record OrderRow( string OrderNo, string CustomerName, int Quantity, decimal Amount, DateTime OrderDate); public sealed class ExcelExportService { public byte[] ExportOrders(IReadOnlyList<OrderRow> rows) { // 按项目实际授权方式设置,不要直接照搬 ExcelPackage.LicenseContext = LicenseContext.NonCommercial; using var package = new ExcelPackage(); var ws = package.Workbook.Worksheets.Add("订单"); var headers = new[] { "订单号", "客户名称", "数量", "金额", "下单日期" }; for (var c = 0; c < headers.Length; c++) { ws.Cells[1, c + 1].Value = headers[c]; } ws.Cells[1, 1, 1, headers.Length].Style.Font.Bold = true; ws.Cells[1, 1, 1, headers.Length].AutoFilter = true; var rowIndex = 2; foreach (var item in rows) { ws.Cells[rowIndex, 1].Value = item.OrderNo; ws.Cells[rowIndex, 2].Value = item.CustomerName; ws.Cells[rowIndex, 3].Value = item.Quantity; ws.Cells[rowIndex, 4].Value = item.Amount; ws.Cells[rowIndex, 5].Value = item.OrderDate; rowIndex++; } if (rows.Count > 0) { var lastDataRow = rowIndex - 1; ws.Cells[2, 4, lastDataRow, 4].Style.Numberformat.Format = "#,##0.00"; ws.Cells[2, 5, lastDataRow, 5].Style.Numberformat.Format = "yyyy-mm-dd"; ws.Cells[lastDataRow + 1, 3].Value = "合计"; ws.Cells[lastDataRow + 1, 3].Style.Font.Bold = true; ws.Cells[lastDataRow + 1, 4].Formula = $"SUM(D2:D{lastDataRow})"; ws.Cells[lastDataRow + 1, 4].Style.Numberformat.Format = "#,##0.00"; } ws.Cells[ws.Dimension.Address].AutoFitColumns(8, 28); ws.View.FreezePanes(2, 1); return package.GetAsByteArray(); } }

逻辑说明:先写表头再写数据,rowIndex从 2 开始,避开表头行;Numberformat.Format只影响显示,不改变底层值,金额仍然是decimal,日期仍然是DateTime;合计公式写在最后一个数据行下方,列宽用AutoFitColumns(8, 28)限定最小 8、最大 28,防止客户名称过长把列撑得没法看。参数上,FreezePanes(2, 1)冻结第一行和第一列;如果导出模板没有合计行,就把公式块删掉,避免空表时出现SUM(D2:D1)这类无效区域。

3.3 大表导出:分页写、临时文件与内存峰值控制

同步接口返回byte[]的方式,在行数上来后会出现两个问题:请求线程被占住,内存里同时存在业务列表、工作簿对象和最终字节数组。我的处理顺序是:先限制同步导出的最大行数,超过阈值走后台任务;后台任务用SaveAs写临时文件,而不是GetAsByteArray;临时文件用 GUID 命名,下载完成后由清理任务删除。

public string ExportToTempFile(IReadOnlyList<OrderRow> rows) { ExcelPackage.LicenseContext = LicenseContext.NonCommercial; var tempPath = Path.Combine( Path.GetTempPath(), $"orders_{Guid.NewGuid():N}.xlsx"); using var package = new ExcelPackage(); var ws = package.Workbook.Worksheets.Add("订单"); ws.Cells[1, 1].Value = "订单号"; ws.Cells[1, 2].Value = "金额"; for (var i = 0; i < rows.Count; i++) { ws.Cells[i + 2, 1].Value = rows[i].OrderNo; ws.Cells[i + 2, 2].Value = rows[i].Amount; } package.SaveAs(new FileInfo(tempPath)); return tempPath; }

这段代码把工作簿直接写到磁盘,返回值是临时文件路径,控制器再用PhysicalFile返回。参数上,临时目录不要写死成某个业务目录,优先用系统临时目录并确保运行账号有写权限;下载完成后要清理,至少加一个按创建时间扫描的后台清理任务。分页写不是必须一次把所有数据交给 EPPlus,如果数据来自数据库,可以按页查询、按页写行,工作簿对象仍然在内存里,但业务列表不会无限增长。

4. 接口层设计:上传、下载、校验与异步任务怎么排

4.1 上传接口:文件大小、扩展名、表头校验

上传接口最先挡的不是业务数据,而是文件本身。IFormFile的Length可以做第一层限制,RequestSizeLimit做第二层,扩展名做第三层。不要只信前端accept,服务端仍要判断.xlsx和.xls。表头校验放在读取之后,缺关键列直接返回BadRequest,不要进入逐行映射。

using Microsoft.AspNetCore.Mvc; using System.Data; [ApiController] [Route("api/excel")] public sealed class ExcelController : ControllerBase { private readonly ExcelReadService _readService; private readonly ExcelExportService _exportService; public ExcelController( ExcelReadService readService, ExcelExportService exportService) { _readService = readService; _exportService = exportService; } [HttpPost("import")] [RequestSizeLimit(20 * 1024 * 1024)] public IActionResult Import([FromForm] IFormFile? file, [FromForm] int sheetIndex = 0) { if (file is null || file.Length == 0) return BadRequest(new { message = "文件为空" }); var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); if (extension != ".xlsx" && extension != ".xls") return BadRequest(new { message = "只支持 .xlsx 或 .xls" }); using var stream = file.OpenReadStream(); DataSet dataSet; try { dataSet = _readService.Read(stream); } catch (Exception ex) { return BadRequest(new { message = "Excel 解析失败", detail = ex.Message }); } if (sheetIndex < 0 || sheetIndex >= dataSet.Tables.Count) return BadRequest(new { message = "工作表不存在" }); var table = dataSet.Tables[sheetIndex]; var requiredColumns = new[] { "订单号", "数量", "下单日期" }; var missing = requiredColumns .Where(column => !table.Columns.Contains(column)) .ToArray(); if (missing.Length > 0) return BadRequest(new { message = "缺少必需列", columns = missing }); var rows = table.Rows.Cast<DataRow>() .Select((row, index) => ImportRowMapper.ToRow(row, index + 2)) .ToList(); var errors = rows .Where(row => row.Errors.Count > 0) .Take(20) .Select(row => new { row.RowNumber, row.Errors }) .ToList(); var validRows = rows.Where(row => row.Errors.Count == 0).ToList(); return Ok(new { total = rows.Count, valid = validRows.Count, errors }); } }

逻辑说明:RequestSizeLimit是请求级限制,单位是字节;扩展名判断用ToLowerInvariant,避免大小写绕过;sheetIndex允许前端选择工作表,默认第一张;缺列检查在逐行映射前做,能减少无效计算。行号从 2 开始,是因为第一行被当作表头。错误列表只返回前 20 条,避免脏表把响应撑大,完整错误可以落日志或让前端按行号定位。

4.2 下载接口:Content-Disposition 文件名编码与错误返回

下载接口不要手写一堆响应头,ASP.NET Core 的File方法会处理Content-Disposition和中文文件名的编码。文件内容类型用 xlsx 的标准 MIME,文件名带时间戳,避免用户重复下载时覆盖。出错时返回 JSON,不要返回一个内容类型为 Excel 的错误页,否则浏览器会尝试下载一个坏文件。

[HttpGet("export")] public IActionResult Export() { // 这里换成实际查询,示例只演示返回结构 var rows = new List<OrderRow> { new("A001", "某客户", 2, 19.90m, new DateTime(2024, 1, 2)) }; var bytes = _exportService.ExportOrders(rows); var fileName = $"订单导出_{DateTime.Now:yyyyMMddHHmmss}.xlsx"; return File( bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); }

这段代码里,File的第三个参数是下载文件名,框架会生成合适的Content-Disposition;如果前端或代理对中文文件名处理不一致,可以在网关层确认是否重写了该响应头。参数上,DateTime.Now只用于文件名,不参与业务日期;文件名不要带/、\、:等路径字符,时间戳格式已经避开这些字符。导出内容为空时,仍然返回带表头的文件,比返回 204 更容易让业务方确认接口通了。

4.3 批处理接口与后台任务边界

导入几万行时,同步接口会长时间占用请求线程,前端超时后用户重复点,数据库里可能出现重复批次。我的做法是给导入加批次号:控制器只做文件校验和落临时文件,后台服务负责解析、校验、入库,接口立刻返回Accepted和批次号;查询接口按批次号返回进度和错误文件。

[HttpPost("import-task")] [RequestSizeLimit(50 * 1024 * 1024)] public IActionResult StartImportTask([FromForm] IFormFile? file) { if (file is null || file.Length == 0) return BadRequest(new { message = "文件为空" }); var taskId = Guid.NewGuid().ToString("N"); var tempPath = Path.Combine(Path.GetTempPath(), $"{taskId}.xlsx"); using (var target = System.IO.File.Create(tempPath)) using (var source = file.OpenReadStream()) { source.CopyTo(target); } // 这里把 taskId 和 tempPath 投递到后台队列,示例不绑定具体队列 // _importQueue.Enqueue(new ImportTask(taskId, tempPath)); return Accepted(new { taskId }); }

逻辑说明:接口里只做流拷贝,不在这里解析 Excel,避免请求时间不可控;taskId同时作为临时文件名和进度查询键;CopyTo完成后文件句柄释放,后台任务才能稳定读取。参数上,后台任务要设置并发上限,不要一个队列开几十个 EPPlus 实例;临时文件要有过期清理,失败任务也要记录原因。同步接口适合“预览前 200 行”,真正入库走后台,边界清楚后排查问题会轻松很多。

5. 避坑与排查:四个高频问题的现象、原因和解决

5.1 上传成功但读出来整列为空

现象:接口返回 200,total有行数,但订单号、数量全是空字符串,前端以为文件没数据。

原因:模板第一行是大标题,第二行才是字段名,UseHeaderRow = true把大标题当成了列名;合并单元格又让部分列名为空,映射层按“订单号”找不到列,Text方法返回空字符串。

解决:遇到非标准表头,不要用AsDataSet一把梭,改成手动跳行并自己收集表头。先reader.Read()跳过标题行,再读字段行,把列索引和字段名建映射,后续按索引取值。模板能固定的项目,直接在导入页提供标准模板下载,减少“第一行大标题”的玄学问题。

using var reader = ExcelReaderFactory.CreateReader(stream); reader.Read(); // 跳过第一行大标题 reader.Read(); // 第二行作为字段行 var headers = Enumerable.Range(0, reader.FieldCount) .Select(i => reader.GetValue(i)?.ToString()?.Trim() ?? $"Column{i + 1}") .ToArray(); while (reader.Read()) { var orderNo = reader.GetValue(Array.IndexOf(headers, "订单号"))?.ToString(); // 继续按列索引取值 }

这段代码的关键是FieldCount和GetValue按当前行读取,表头行只读一次,数据行循环推进。参数上,Array.IndexOf找不到列会返回 -1,取值前要先判断,否则会抛越界异常。手动读行的代价是代码多一些,收益是表头不规则时能明确报错,而不是整列静默为空。

5.2 日期变成 45200 或带时分秒的字符串

现象:Excel 里显示2024-01-02,接口读出来是45200;或者变成2024-01-02 00:00:00,再入库时格式校验失败。

原因:Excel 日期本质是序列号,显示格式只是单元格样式;DataTable里可能给double,也可能给DateTime,还可能是带时间的字符串。直接用Convert.ToDateTime会受服务器区域设置影响,出现月份和日期颠倒。

解决:按“先DateTime、再double转 OADate、最后固定格式字符串”的顺序解析,所有字符串解析都带CultureInfo.InvariantCulture。导入模板里把日期列设成文本格式,或者要求用户按yyyy-MM-dd填写,能减少混合类型。

DateTime? ParseExcelDate(object? value) { if (value is DateTime dateTime) return dateTime.Date; if (value is double oaDate) return DateTime.FromOADate(oaDate).Date; var text = Convert.ToString(value, CultureInfo.InvariantCulture); if (DateTime.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact)) return exact.Date; return null; }

逻辑说明:DateTime.FromOADate只接受 Excel 的日期序列号范围,传太大或太小的double会抛异常,生产代码要加范围判断;TryParseExact比TryParse更严格,能挡住2024/1/2和01-02-2024这类歧义格式。参数上,返回.Date去掉时分秒,业务只关心日期时不要保留时间部分。

5.3 导出二十万行时内存飙高、接口超时

现象:小表导出正常,行数上来后接口响应很慢,服务器内存上涨,严重时触发回收,用户收到 502。

原因:GetAsByteArray()会同时持有工作簿对象和最终字节数组,业务列表还占一份;同步请求一直占用线程,前端超时后重试,多个导出任务叠加。

解决:同步导出设行数上限,超过上限走后台任务;后台用SaveAs写临时文件,再用PhysicalFile返回;临时文件用 GUID 命名,下载后清理。不要在一个请求里开多个Task.Run并行导出,EPPlus 对象不是线程安全的,并发写同一工作簿会出问题。

var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx"); using (var package = new ExcelPackage()) { var ws = package.Workbook.Worksheets.Add("数据"); ws.Cells[1, 1].Value = "订单号"; for (var i = 0; i < rows.Count; i++) ws.Cells[i + 2, 1].Value = rows[i].OrderNo; package.SaveAs(new FileInfo(tempPath)); } return PhysicalFile( tempPath, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "导出结果.xlsx");

这段代码把工作簿写到磁盘,PhysicalFile返回文件路径,响应结束后由框架读取文件。参数上,临时目录要确认运行账号有写权限;返回值没有自动删除文件,需要额外的清理任务按时间扫描。生产环境还要限制同时导出的任务数,队列满了直接告诉用户稍后再试,比把服务器拖垮更可控。

5.4 下载文件名乱码,浏览器保存成 download

现象:接口返回的 Excel 能打开,但下载文件名变成download、乱码,或者中文被截断。

原因:手写Content-Disposition时只放了filename=,中文没有按规范编码;反向代理又重写了响应头,最终浏览器拿到的文件名不可用。

解决:优先用File(bytes, contentType, fileName)让框架生成响应头;如果必须手写,使用filename*的 UTF-8 编码形式,并保留一个 ASCII 的filename兜底。网关层要确认没有把Content-Disposition当成普通文本重写。

var fileName = $"订单导出_{DateTime.Now:yyyyMMddHHmmss}.xlsx"; return File( bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);

逻辑说明:File的第三个参数会进入Content-Disposition,框架处理中文编码和引号转义;文件名里不要带路径分隔符。参数上,contentType用 xlsx 的标准 MIME,不要写成application/octet-stream,否则部分客户端会强制按二进制下载。排查时先看浏览器开发者工具里的响应头,再看网关日志,别只在代码里猜。

6. 进阶验证:用契约测试和样本回放把 Excel 接口跑稳

Excel 接口最容易在“模板微调”后翻车,单测只测服务方法还不够,我会加一层样本回放:准备正常表、空行表、混合日期表三份固定样本,导入后断言总行数、有效行数、错误行号;导出后用 EPPlus 重新打开字节流,断言表头、金额格式和合计公式。这样改列宽、改样式时,至少业务字段不会被顺手改坏。

[Fact] public void Export_ShouldKeepHeaderAndSumFormula() { ExcelPackage.LicenseContext = LicenseContext.NonCommercial; var service = new ExcelExportService(); var bytes = service.ExportOrders(new[] { new OrderRow("A001", "某客户", 2, 19.90m, new DateTime(2024, 1, 2)) }); using var package = new ExcelPackage(new MemoryStream(bytes)); var ws = package.Workbook.Worksheets[0]; Assert.Equal("订单号", ws.Cells[1, 1].Text); Assert.Contains("SUM", ws.Cells[3, 4].Formula); }

断言不要只盯单元格文本,金额列要检查Style.Numberformat.Format,日期列要检查底层值类型;导入侧则检查错误列表里的行号是否和 Excel 行号一致。血泪经验是:只测“能打开”不够,坏数据往往在第二周才被运营发现。我现在每次改导入模板或导出列顺序,都会先把三份样本表跑一轮,再让接口进联调;这份 excel-handler 资源里的读取服务、导出服务和样本回放思路,拿去接现有项目时也按这个顺序走一遍。希望帮到你。

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