企业系统里,Excel 导入导出看起来是小功能,实际上很容易成为上线后的高频问题。用户说“我就是传个表”,系统却可能遇到模板版本不一致、列名被改、日期格式混乱、编码重复、必填项为空、字典值写错、权限范围不清、导入一半失败等问题。
最糟糕的体验不是导入失败,而是只弹一句“导入失败”。用户不知道哪一行错了,不知道怎么改,也不知道已经成功了多少条。开发再去查日志,业务人员再重新整理文件,时间全浪费在来回沟通上。
所以 Excel 导入导出不是“读文件、写数据库”这么简单。更稳的设计应该包含:模板版本、字段说明、预校验、错误行回执、导入策略、事务边界、重复导入控制、导出权限和导出字段脱敏。
本文用一个通用场景做示例:批量导入产品资料。第 17 行规格为空,第 18 行产品编码和系统已有数据重复,用户需要拿到一份带错误说明的回执文件,修完后重新上传。这个例子也适用于客户资料、资产台账、供应商、合同清单、库存期初数据等批量维护场景。
示例环境:Java 17、Spring Boot 风格服务层、MySQL 8.x。代码只保留关键骨架,重点放在导入导出的设计边界和验证方式。
目录
- Excel导入导出先分清四个阶段
- 输入案例:批量导入产品资料
- 模板设计:别让用户猜字段含义
- MySQL表结构:批次、行明细和错误回执
- 实现骨架:先校验再入库
- 导出设计:权限、字段和脱敏
- SQL验证:上线后查导入质量
- 异常边界和上线验收
- 小结和延伸阅读
一、Excel导入导出先分清四个阶段
一个可靠的 Excel 导入,不应该从“直接入库”开始,而应该分成四个阶段。
| 阶段 | 目标 | 产物 |
|---|---|---|
| 模板下载 | 告诉用户该填什么、怎么填 | 带版本号、字段说明和示例行的模板 |
| 预校验 | 找出格式、必填、字典、重复、权限问题 | 校验结果和错误行 |
| 确认导入 | 用户确认采用全回滚还是部分成功策略 | 导入批次和行状态 |
| 结果回执 | 告诉用户成功多少、失败多少、失败原因 | 成功统计和错误回执文件 |
导出也要分阶段:先按 DataScope 查数据,再按角色决定字段,再做脱敏,最后生成文件。不能因为“用户能看到列表”就默认能导出所有字段。
很多系统的问题出在把这几个阶段揉成一个按钮。用户上传 Excel 后,系统边读边入库,遇到错误就抛异常。这样做简单,但一旦数据量稍大、错误稍多,就很难解释结果。
图1:模板、预校验、确认导入和结果回执要分开设计。
二、输入案例:批量导入产品资料
本文固定一组输入,用来贯穿后面的模型、代码和 SQL 验证。
导入批次:IMP-20260925-001 导入对象:产品资料 模板版本:PRODUCT_IMPORT_V3 上传人:user_id = 1008,产品运营 所属公司:company_id = 1 导入策略:先预校验,确认后导入;默认全回滚,可配置部分成功 文件行数:100 行数据,另有 1 行表头和 1 行示例说明 第17行错误:规格为空 第18行错误:产品编码 P-1008 已存在 预期结果:98 行可导入,2 行失败;生成错误回执,标出 Excel 原始行号和错误原因这个案例里的关键点不是产品资料,而是用户需要知道“哪一行错了、为什么错、怎么改”。如果系统只告诉他“导入失败”,他只能一行一行猜。
因此导入结果要保留原始行号。用户在 Excel 里看到的是第 17 行、第 18 行,系统回执也必须对应这个行号,而不是告诉他“第 15 条数据错误”。表头、示例行、隐藏行都可能让行号错位。
三、模板设计:别让用户猜字段含义
好的导入模板不是空白表头,而是一个小型说明文档。
模板至少要包含这些信息:
| 内容 | 作用 |
|---|---|
| 模板版本 | 判断用户是不是用了旧模板 |
| 字段中文名 | 让业务人员知道填什么 |
| 字段编码 | 让系统稳定识别列 |
| 是否必填 | 提前减少空值错误 |
| 示例值 | 说明日期、金额、字典写法 |
| 字典说明 | 告诉用户可选值 |
| 注意事项 | 说明编码唯一、名称长度、导入策略 |
不要只靠列顺序识别字段。用户可能插入列、隐藏列、移动列。更稳的做法是在隐藏行或模板元数据里保存字段编码,例如product_code、product_name、spec、unit。解析时按字段编码映射,而不是按第几列硬读。
模板版本也很重要。系统升级后,如果新增了“产品分类”必填字段,旧模板继续上传就会产生一批解释不清的错误。模板里应该有版本号,上传时先校验版本,版本不匹配时提示重新下载模板。
图2:模板不是空白表头,要把版本、字段编码、必填和示例说清楚。
四、MySQL表结构:批次、行明细和错误回执
导入不能只记录最终数据。至少要有批次表和行明细表,方便用户回看导入结果,也方便开发排查。
CREATETABLEimport_batch(idBIGINTPRIMARYKEYAUTO_INCREMENT,company_idBIGINTNOTNULL,batch_noVARCHAR(64)NOTNULL,template_codeVARCHAR(80)NOTNULL,template_versionVARCHAR(32)NOTNULL,import_objectVARCHAR(80)NOTNULL,import_strategyVARCHAR(32)NOTNULL,statusVARCHAR(32)NOTNULL,total_rowsINTNOTNULLDEFAULT0,success_rowsINTNOTNULLDEFAULT0,failed_rowsINTNOTNULLDEFAULT0,source_file_idBIGINTNULL,receipt_file_idBIGINTNULL,create_byBIGINTNOTNULL,create_timeDATETIMENOTNULL,finish_timeDATETIMENULL,UNIQUEKEYuk_import_batch_no(company_id,batch_no),KEYidx_import_batch_status(company_id,status,create_time));CREATETABLEimport_row_result(idBIGINTPRIMARYKEYAUTO_INCREMENT,batch_idBIGINTNOTNULL,excel_row_noINTNOTNULL,row_keyVARCHAR(120)NULL,row_statusVARCHAR(32)NOTNULL,error_codeVARCHAR(80)NULL,error_messageVARCHAR(500)NULL,raw_json JSONNULL,normalized_json JSONNULL,create_timeDATETIMENOTNULL,UNIQUEKEYuk_import_row(batch_id,excel_row_no),KEYidx_import_row_status(batch_id,row_status));import_batch记录一次导入的总体情况。用户回来查历史导入时,应该能看到总行数、成功行数、失败行数、源文件和回执文件。
import_row_result记录每一行的校验结果。错误行必须保存 Excel 原始行号、错误原因和原始数据。这样才能生成回执,也能解释为什么某一行没入库。
import_strategy可以是ALL_OR_NOTHING或PARTIAL_SUCCESS。前者表示有一行失败就整批不入库,适合财务、库存期初等强一致场景;后者表示正确行可以入库,错误行回执给用户修复,适合产品资料、客户资料等维护场景。
图3:批次表看整体结果,行明细表解释每一行成功或失败。
五、实现骨架:先校验再入库
导入实现可以分成两步:解析校验、确认入库。
第一步只解析文件和校验,不直接写业务表。它负责检查模板版本、必填字段、数据类型、字典值、重复编码和权限范围。
publicImportPreviewpreview(ImportFilefile,LongoperatorId){TemplateMetatemplate=templateService.readTemplateMeta(file);templateService.requireSupported("PRODUCT_IMPORT",template.version());List<ImportRow>rows=excelReader.readRows(file,template);List<RowResult>results=newArrayList<>();for(ImportRowrow:rows){RowValidatorvalidator=RowValidator.forRow(row);validator.required("product_code","产品编码");validator.required("product_name","产品名称");validator.required("spec","规格");validator.dictionary("unit","product_unit",dictService);validator.unique("product_code",productRepository::existsByCode);results.add(validator.result());}returnimportBatchRepository.savePreview("PRODUCT_IMPORT",template.version(),operatorId,results);}第二步根据用户确认的策略入库。
@Transactional(rollbackFor=Exception.class)publicImportResultconfirmImport(LongbatchId,ImportStrategystrategy,LongoperatorId){ImportBatchbatch=importBatchRepository.lockById(batchId);List<RowResult>rows=importRowRepository.listByBatch(batchId);longfailed=rows.stream().filter(RowResult::failed).count();if(strategy==ImportStrategy.ALL_OR_NOTHING&&failed>0){thrownewServiceException("存在错误行,不能执行整批导入");}for(RowResultrow:rows){if(row.failed()){continue;}Productproduct=productMapper.from(row.normalizedJson());productRepository.insert(product);importRowRepository.markImported(row.id());}importBatchRepository.finish(batchId,strategy,operatorId);returnimportBatchRepository.summary(batchId);}这两段代码只保留骨架,实际项目里还要考虑大文件流式读取、批量插入、缓存字典、批量查重、错误回执生成等。但核心原则不变:先让用户看见错误,再决定是否入库。
六、导出设计:权限、字段和脱敏
导出经常被低估。很多系统列表页做了权限过滤,但导出接口直接查全表;页面隐藏了成本价,导出却把成本价带出去;页面手机号脱敏,导出却是明文。
导出至少要控制三件事。
第一,数据范围要和列表一致。用户在页面只能看本部门数据,导出也只能导出本部门数据。不要为导出单独写一条绕过 DataScope 的 SQL。
第二,字段范围要按角色控制。普通员工可以导出产品编码、名称、规格;管理员可以导出更多字段;成本价、供应商底价、客户手机号等字段要按权限决定。
第三,敏感字段要脱敏或审批。导出比页面风险更高,因为文件可以转发、复制、上传到外部。对敏感数据量较大的导出,可以做异步任务和下载有效期。
可以把导出也当成一次业务动作记录下来:
exportType=PRODUCT operatorId=1008 dataScope=DEPT fields=product_code,product_name,spec,unit rowCount=1280 fileExpireTime=2026-09-26 10:30:00导入和导出是一组能力。导入强调“别把坏数据写进去”,导出强调“别把不该看的数据带出去”。
图4:导出要同时控制数据范围、字段范围和敏感字段。
七、SQL验证:上线后查导入质量
导入功能上线后,要能查导入成功率、错误类型和重复导入情况。
查看最近导入批次:
SELECTbatch_no,template_version,import_strategy,status,total_rows,success_rows,failed_rows,create_timeFROMimport_batchWHEREcompany_id=1ORDERBYcreate_timeDESCLIMIT20;查看错误类型分布:
SELECTr.error_code,COUNT(*)AScntFROMimport_row_result rJOINimport_batch bONb.id=r.batch_idWHEREb.import_object='PRODUCT_IMPORT'ANDb.create_time>=DATE_SUB(NOW(),INTERVAL7DAY)ANDr.row_status='FAILED'GROUPBYr.error_codeORDERBYcntDESC;预期结果示例:
error_code | cnt REQUIRED_SPEC | 12 DUPLICATE_CODE | 5 INVALID_UNIT | 3检查是否有批次统计和行明细不一致:
SELECTb.id,b.batch_no,b.total_rows,COUNT(r.id)ASrow_countFROMimport_batch bLEFTJOINimport_row_result rONr.batch_id=b.idGROUPBYb.id,b.batch_no,b.total_rowsHAVINGb.total_rows<>COUNT(r.id);预期结果:
empty set检查导出是否出现超权限字段,可以从导出日志或文件生成记录里查:
SELECTexport_no,operator_id,fields,create_timeFROMexport_taskWHEREfieldsLIKE'%cost_price%'ANDcreate_time>=DATE_SUB(NOW(),INTERVAL7DAY);这条不一定为空,但每条都应该能解释:谁导出的,为什么有权限导出成本价。
图5:导入验收看错误行和批次一致性,导出验收看权限字段和敏感数据。
八、异常边界和上线验收
Excel 功能上线前,至少要考虑这些边界。
旧模板上传:
模板版本不匹配时,不要尝试“兼容一下”。应该明确提示用户下载新模板,避免字段错位造成脏数据。
错误行号错位:
错误回执必须显示 Excel 原始行号。用户不关心系统内部第几条数据,他只关心 Excel 第几行要改。
重复导入:
同一文件重复上传、同一批次重复确认、同一编码重复入库,都要有防护。可以用批次号、文件摘要、业务唯一键共同控制。
部分成功策略:
部分成功适合基础资料维护,但不适合所有场景。财务期初、库存期初、组织架构导入更适合全回滚,否则会出现数据不完整。
大文件导入:
不要把大文件一次性读进内存。超过一定行数后,建议异步任务处理,并让用户在任务中心查看结果。
导出超时:
大批量导出不要同步阻塞页面。可以生成导出任务,完成后通知用户下载,文件设置有效期。
上线验收可以按下面清单执行:
- 模板包含版本号、字段编码、必填说明和示例行。
- 上传旧模板会被明确拒绝。
- 必填为空、字典错误、编码重复都能定位到 Excel 原始行号。
- 错误回执能保留原始数据和错误原因。
- 全回滚策略下,有错误行不会写入任何业务数据。
- 部分成功策略下,成功行入库,失败行生成回执。
- 重复确认同一批次不会重复入库。
- 导入批次统计和行明细数量一致。
- 导出复用列表 DataScope。
- 导出字段按角色控制,敏感字段有脱敏或审批。
九、小结和延伸阅读
Excel 导入导出的核心,不是把文件读出来,也不是把数据写进去,而是让用户和系统对“哪份模板、哪一行、哪个字段、错在哪里、是否入库、能否导出”有同一套可验证口径。
实际落地时,可以先把模板版本、预校验、错误回执和批次记录做好。代码不一定一开始很复杂,但流程要清楚。只要用户能拿到明确错误行,开发能用 SQL 查清导入结果,后续再优化大文件、异步任务和导出审批就有基础。
延伸阅读:
- Apache POI 官方文档
- Spring Framework:声明式事务管理
- MySQL 8.4:CREATE TABLE