上周我们团队遇到一个很典型的协作场景:产品经理拿着一份满是红色修订标记的 Excel 排期表找到我,说这些修订是几个同事在不同时间改的,现在需要我帮忙决定哪些保留、哪些恢复原样。我打开文件一看,几十处修订,手动去点“接受”或“拒绝”得花不少时间,而且这种活以后每周都得干一次。当时我就想,这事能不能用 Java 直接在后端搞定?查了一圈资料,还真可以,Apache POI 提供了专门处理 Excel 修订的 API,不仅能读修订记录,还能直接调用 accept() 和 reject() 方法完成接收和拒绝操作。
这篇文章我会从团队协作里的实际痛点出发,讲清楚 Java 处理 Excel 修订的整体思路,再深入到 POI 的 XSSFRevision 相关 API,最后给出可以直接复制使用的完整代码和踩坑记录。无论你是做办公自动化、文件审批流,还是只是想在 Java 里批量处理带修订的 Excel 文件,这篇文章都值得你花几分钟看完。
1. 为什么需要 Java 处理 Excel 修订
1.1 多人协作修改 Excel 时的修订管理困境
Excel 的修订功能,本质上是把工作簿切换到一个“追踪模式”,之后任何一个单元格的增删改都会被记录成一条修订,包含修改人、修改时间、原始值、新值这些关键信息。这是 Excel 自带的功能,不需要额外装插件,但问题在于——接收或拒绝修订这个动作,在原生界面里只能人工手动操作。
场景放大到团队协作层面就很痛苦了。比如一个项目计划表,市场部改了时间节点,技术部改了负责人,财务部调整了预算列,等到汇总的时候,你面对的是几十上百条修订记录。这个时候靠人工去判断每条修订该不该保留,效率极低不说,还容易漏掉某条关键修改。如果这种文件每周都要处理一轮,那就更折磨人了。
1.2 Java 在这个场景里的定位和价值
Java 能够承担的角色是把“人工审核修订”变成“程序化处理修订”。具体来说,后端可以读取 Excel 文件里的所有修订记录,按照业务规则自动接收或者拒绝,比如:只看指定作者的修订、只接收某个时间段内的修改、只接受某个工作表区域的变化。这些判断用代码写出来之后,整条处理链路就可以自动化、定时化。
这件事的价值不在于“能用代码操作 Excel”这个表面事实,而在于它把团队协作里最琐碎、最容易出错的复核环节,变成了一套可重复执行的程序逻辑。人只需要在规则设计阶段参与一次,后续所有文件都按同一套标准处理,稳定性比人工点击高得多。而且纯 Java 实现意味着不依赖 Windows 环境和 Office 客户端,部署在 Linux 服务器上也能跑。
1.3 适合参考这篇文章的人群
我大概梳理了一下,下面几类人会比较需要这套方案:
- 做办公自动化系统、文档中台、审批流的后端开发,需要在服务端处理带修订标记的 Excel。
- 团队里负责收集多方反馈文件,再做汇总整理的运营或项目管理人员,希望用脚本减轻手工操作。
- 对 Apache POI 只知道读写单元格,想进一步了解 POI 高级功能(修订、批注、共享工作簿)的 Java 开发者。
- 准备 Java 面试、想扩充“项目难点”素材的候选人。很多人知道 POI 能读写 Excel,但能说出“POI 还可以接收和拒绝修订”的,确实不多,这是一个不错的亮点。
2. 技术选型:为什么是 Apache POI
2.1 市面上的替代方案对比
先摆结论:在 Java 生态里处理 Excel 修订,Apache POI 基本是唯一靠谱的选择。但为了把道理讲透,我还是把市面上几个方案放一起对比一下。
| 方案 | 跨平台 | 依赖环境 | 修订支持 | 适用场景 |
|---|---|---|---|---|
| Apache POI | 优秀,纯 Java | 无 | 支持读取 XSSFRevision、接收/拒绝 | 服务端自动化处理 |
| EasyExcel(阿里) | 优秀,纯 Java | 无 | 不支持修订操作 | 大数据量读写 Excel |
| 调用 Office COM 接口 | 差,仅限 Windows | 需要安装 Office 且配置 DCOM | 通过 Excel 对象模型可以操作 | 客户端工具,不适合服务端 |
| VBA 宏 | 差,仅限 Windows | 需要 Office | 支持调用 AcceptAllChanges | 单机自动化 |
| Python openpyxl | 一般 | 需要 Python 环境 | 有部分修订读取支持,但写支持不完整 | 数据处理脚本 |
EasyExcel 在大批量数据读写方面性能确实好,但它的定位是数据导入导出,根本没做修订跟踪这块功能。COM 和 VBA 方案在 Windows 桌面环境下很好用,但部署在 Linux 服务器上就完全不可行了。openpyxl 能读一些修订信息,但接收/拒绝这种写回操作不够稳定。综合来看,POI 是唯一能在 Java 服务端独立完成“读修订 + 改修订状态 + 写回文件”的库。
2.2 POI 处理修订的核心能力边界
POI 的 XSSF 模型(也就是处理 .xlsx 格式的那套 API)从 4.0.0 版本开始提供了 XSSFRevision 相关能力,可以读取工作簿中的修订记录,并调用方法接收或拒绝修订。这里有个边界要提前说明:POI 只支持 .xlsx 格式(XSSF),不支持老版的 .xls 格式(HSSF)。因为 .xls 的修订机制和 .xlsx 完全不同,POI 一直没有实现 HSSF 的修订处理。
另一个边界是工作簿必须是“处于修订跟踪状态”的文件。如果文件没有启用修订跟踪,那 getRevisions() 拿到的就是空列表。这听起来像废话,但实际处理别人发来的文件时,经常遇到“看起来有修订痕迹,其实只是加了批注或者手动标色”的情况,要能分辨清楚。
2.3 POI 修订机制的底层实现思路
POI 读取修订的原理,本质上是解析 .xlsx 文件内部的一个隐藏工作表——revision 相关的 XML 数据。.xlsx 本质上是一个 zip 包,里面有一堆 XML 文件描述工作簿结构。修订记录存储在 sheet 的 XML 片段里,POI 把这些 XML 解析成 XSSFRevision 对象,每个对象代表一条修订记录。
当调用 accept() 时,POI 做的事情是:先查看这条修订对应的单元格,把修订后的值写入单元格的当前数据区,然后把这条修订的日志标记为已接受;当调用 reject() 时,则把修订前的原始值重新写回单元格,同时清除修订日志。这个过程在低层是对单元格数据和修订日志 XML 的双重操作,理解这一点,后面遇到“接收后值没变”“拒绝后数据恢复错位”这类问题,就不容易一头雾水。
3. 核心 API 拆解与修订信息读取
3.1 关键类与方法速览
直接看代码之前,先对 POI 修订相关的主要 API 有一个整体认识。
- XSSFWorkbook.getRevisions():返回 List ,就是当前工作簿的全部修订记录。
- XSSFWorkbook.isTrackedForRevisions():判断当前工作簿是否启用了修订跟踪。
- XSSFRevision.accept():接收这条修订,把修改应用到单元格。
- XSSFRevision.reject():拒绝这条修订,把单元格恢复为修改前的值。
- XSSFRevision.getAuthor():获取修订作者。
- XSSFRevision.getDate():获取修订时间。
- XSSFRevision.getRevisionType():获取修订类型。
- XSSFRevision.getCells():获取这条修订涉及的所有单元格,返回 List 。
- XSSFRevisionCell.getRow() 和 getColumn():获取单元格行列索引。
- XSSFRevisionCell.getOriginalValue() 和 getRevisedValue():获取修改前、修改后的值。
这里要注意一个容易混淆的点:XSSFRevision 的 getRevisionType() 返回的是修订的大类型,比如“插入行”“删除行”“修改单元格”“修改批注”等。而 XSSFRevisionCell 层面可以拿到更精确的单元格前后值。实际开发中一般建议两个层面的信息一起看,才能完整理解一条修订。
3.2 修订类型常量与典型业务判断场景
POI 在 XSSFRevision 内部定义了一批 int 类型的常量,常见的有这些:
public static final int REV_INSERT_ROWS = 1; public static final int REV_DELETE_ROWS = 2; public static final int REV_INSERT_COLUMNS = 3; public static final int REV_DELETE_COLUMNS = 4; public static final int REV_MODIFY_CELL = 5; public static final int REV_MODIFY_COMMENT = 6;实际业务中用得最多的是 REV_MODIFY_CELL,也就是单元格内容被修改。比如“只接收数据类的单元格修改,拒绝行删除类的修订”这个规则,就是先判断 getRevisionType() 是否等于 REV_MODIFY_CELL,再决定接不接收。行删除、列插入这类结构性修改对表格影响很大,通常需要格外警惕,有时候选“拒绝”是更稳妥的选择。
3.3 读取修订信息的完整示例
用一个完整的示例来看怎么读取所有修订信息。假设有个带修订的模板文件,我要把每个修订的作者、时间、类型、涉及单元格、前后值都打印出来。
package com.example.revision; import org.apache.poi.xssf.usermodel.XSSFRevision; import org.apache.poi.xssf.usermodel.XSSFRevisionCell; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import java.io.FileInputStream; import java.io.InputStream; import java.util.List; public class RevisionReader { public static void main(String[] args) throws Exception { String filePath = "revision_demo.xlsx"; XSSFWorkbook workbook; try (InputStream in = new FileInputStream(filePath)) { workbook = new XSSFWorkbook(in); } System.out.println("是否跟踪修订: " + workbook.isTrackedForRevisions()); List<XSSFRevision> revisions = workbook.getRevisions(); System.out.println("修订总数: " + revisions.size()); int index = 1; for (XSSFRevision revision : revisions) { System.out.println("------ 修订 " + index++ + " ------"); System.out.println("作者: " + revision.getAuthor()); System.out.println("时间: " + revision.getDate()); System.out.println("类型编码: " + revision.getRevisionType()); List<XSSFRevisionCell> cells = revision.getCells(); for (XSSFRevisionCell cell : cells) { int row = cell.getRow(); int col = cell.getColumn(); System.out.println("单元格: 第" + (row + 1) + "行, 第" + (col + 1) + "列"); System.out.println(" 原始值: " + cell.getOriginalValue()); System.out.println(" 修订值: " + cell.getRevisedValue()); } } workbook.close(); } }运行这段代码,服务器控制台就能输出一份完整的修订清单。如果团队需要做“修订报表”或者“待审核记录”,拿到这份数据后存进数据库就行。我在实际项目里就是这么干的:每天凌晨定时扫描指定目录下的 Excel,解析完修订后生成待办事项推送给审核人,审核人在页面上一键决定接收还是拒绝,后端再调用 accept/reject 完成处理。
3.4 需要注意的版本与依赖问题
POI 修订 API 在不同版本上有一些差别。4.x 早期版本存在一些已知 bug,比如某些场景下 getCells() 会漏掉单元格。我在乱踩坑之后,项目里统一锁定了 POI 5.2.3 版本,目前跑了大半年没出过修订丢失的问题。如果你们是新项目,建议直接上 5.x,别在 4.x 上折腾了。
Maven 依赖这样加:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.3</version> </dependency>注意 poi-ooxml 会自动拉入 poi、poi-ooxml-lite、xmlbeans 等依赖,不需要自己一个个手动声明。如果项目里还有其他组件在使用 xmlbeans,建议看一下依赖版本是否冲突,一般 maven 依赖树里能查出来。
4. 完整实操:接收与拒绝修订的 Java 实现
4.1 核心代码:接收所有修订
接收修订就是把文件里所有未处理的修订都标记为“已接受”,并将修订后的值真正写入单元格。代码看起来并不复杂,核心就是遍历 getRevisions() 然后逐个调用 accept()。
package com.example.revision; import org.apache.poi.xssf.usermodel.XSSFRevision; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.util.List; public class RevisionAcceptor { public static void main(String[] args) throws Exception { String inputPath = "revision_demo.xlsx"; String outputPath = "revision_demo_accepted.xlsx"; XSSFWorkbook workbook; try (InputStream in = new FileInputStream(inputPath)) { workbook = new XSSFWorkbook(in); } List<XSSFRevision> revisions = workbook.getRevisions(); System.out.println("待处理修订数: " + revisions.size()); int acceptCount = 0; for (XSSFRevision revision : revisions) { revision.accept(); acceptCount++; } System.out.println("已接收修订数: " + acceptCount); try (FileOutputStream out = new FileOutputStream(outputPath)) { workbook.write(out); } workbook.close(); System.out.println("文件已保存: " + outputPath); } }这里有几个操作细节要说明。调用 accept() 之后,必须通过 workbook.write() 把工作簿写回文件,接收状态才会持久化。如果你只是调用 accept() 却不保存,那一切修改都在内存里,程序退出就全丢了。输出文件建议用新文件名,别直接覆盖原文件,这样万一处理结果不对还能留有原始版本。
4.2 核心代码:拒绝指定条件下的修订
还有一种更常见的情况:只拒绝某些修订,比如“拒绝不是王工修改的修订”,或者“拒绝删除行的修订”。代码逻辑就是在遍历时加判断条件,匹配上了就调用 reject()。
package com.example.revision; import org.apache.poi.xssf.usermodel.XSSFRevision; import org.apache.poi.xssf.usermodel.XSSFRevisionCell; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.util.List; public class RevisionRejector { public static void main(String[] args) throws Exception { String inputPath = "revision_demo.xlsx"; String outputPath = "revision_demo_rejected.xlsx"; XSSFWorkbook workbook; try (InputStream in = new FileInputStream(inputPath)) { workbook = new XSSFWorkbook(in); } List<XSSFRevision> revisions = workbook.getRevisions(); int rejectCount = 0; for (XSSFRevision revision : revisions) { // 示例规则1:拒绝删除行的修订 if (revision.getRevisionType() == XSSFRevision.REV_DELETE_ROWS) { revision.reject(); rejectCount++; continue; } // 示例规则2:拒绝某个作者的所有修订 if ("张三".equals(revision.getAuthor())) { revision.reject(); rejectCount++; continue; } // 示例规则3:拒绝某个区域内的单元格修改 List<XSSFRevisionCell> cells = revision.getCells(); for (XSSFRevisionCell cell : cells) { if (cell.getColumn() == 4 && cell.getRow() == 9) { revision.reject(); rejectCount++; break; } } } System.out.println("已拒绝修订数: " + rejectCount); try (FileOutputStream out = new FileOutputStream(outputPath)) { workbook.write(out); } workbook.close(); System.out.println("文件已保存: " + outputPath); } }这条规则看起来很简单,但实际业务中这些“规则”往往是从配置中心或者数据库表里动态加载的。比如我在项目里就把规则设计成了一张策略表,字段包括规则名称、作者过滤、时间范围、修订类型、处理动作(接收或拒绝)。每次处理文件之前先查一次策略表,拼出条件再执行。这样产品经理想调整规则,不需要改代码,直接在后台配置就行了。
4.3 按作者和时间范围批量处理
接着上面的思路,再往前走一步:把几个维度组合起来,形成一个更完整的批处理流程。需求大概是这样的:接收李工在 2025-01-01 之后修改的单元格内容,同时拒绝其他任何人的所有修订。这个需求在团队协作里非常典型。
package com.example.revision; import org.apache.poi.xssf.usermodel.XSSFRevision; import org.apache.poi.xssf.usermodel.XSSFRevisionCell; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Date; import java.util.List; public class RevisionBatchHandler { public static void main(String[] args) throws Exception { String inputPath = "revision_demo.xlsx"; String outputPath = "revision_demo_result.xlsx"; XSSFWorkbook workbook; try (InputStream in = new FileInputStream(inputPath)) { workbook = new XSSFWorkbook(in); } String acceptedAuthor = "李工"; LocalDateTime cutoff = LocalDateTime.of(2025, 1, 1, 0, 0, 0); List<XSSFRevision> revisions = workbook.getRevisions(); int acceptCount = 0; int rejectCount = 0; for (XSSFRevision revision : revisions) { boolean shouldAccept = false; if (acceptedAuthor.equals(revision.getAuthor())) { Date revisionDate = revision.getDate(); if (revisionDate != null) { LocalDateTime dateTime = LocalDateTime.ofInstant( revisionDate.toInstant(), ZoneId.systemDefault()); if (!dateTime.isBefore(cutoff)) { shouldAccept = true; } } } if (shouldAccept) { revision.accept(); acceptCount++; } else { revision.reject(); rejectCount++; } } System.out.println("接收修订: " + acceptCount + " 条"); System.out.println("拒绝修订: " + rejectCount + " 条"); try (FileOutputStream out = new FileOutputStream(outputPath)) { workbook.write(out); } workbook.close(); System.out.println("处理完成,结果文件: " + outputPath); } }这个例子把 author、时间、动作三要素都体现出来了。拿到修订时间时,POI 返回的是 java.util.Date,需要先转成 LocalDateTime 再做区间比较。如果修订时间字段为 null,注意做空判断,避免比较时抛空指针。实际项目里,时间比较的边界是用 >= 还是 > 也要和业务方确认清楚,差一分钟都可能导致修订被错误处理。
4.4 保存文件的格式选择与兼容性
处理完修订后写文件,POI 提供两种方式:XSSFWorkbook.write(OutputStream) 直接写 .xlsx 格式,或者调用 workbook.write(OutputStream) 前先设置 Sheet 的缩放比例等参数,这些跟修订无关,就不展开了。但有一个和修订强相关的点值得提醒:如果你用 POI 打开一个 .xlsx,里面带了修订记录,处理完保存时最好保持原格式 .xlsx,不要另存为 .xls。因为 HSSF 不支持修订,强行转格式的话修订信息会丢失,甚至可能直接报错。
另外,保存时建议用 try-with-resources 或者 finally 里关闭工作簿和流。XSSFWorkbook 内部持有 zip 相关的资源,不关闭的话,处理大量文件时文件句柄会越积越多,最后导致 Linux 服务器报“Too many open files”。
4.5 内存与性能优化:大文件处理经验
带修订的 Excel 文件通常不会小到哪去。一次集中会签的文件,几十个工作表、几千条修订都是正常的。POI 的 XSSF 模型默认会把整个 workbook 读入内存,文件一大就很容易 OOM。我自己遇到过一次 60MB 的 xlsx,直接默认配置加载,堆内存给了 1GB 还是崩了。
对这种场景,我的处理经验是分几步走:
第一,尽量用 SXSSFWorkbook 可以省内存,但 SXSSF 不支持读修订,所以这个方案只适合写新文件,不适合处理已有修订。处理带修订的文件,还是得用 XSSFWorkbook。
第二,给 JVM 分配足够的堆内存。这个文件多的时候,建议 -Xms1024m -Xmx2048m 起步。如果你用的是 Spring Boot,可以在启动脚本里调 JAVA_OPTS。
第三,尽量减少不必要的对象持有。解析完修订信息,如果只是判断接收/拒绝,不需要把整个 XSSFRevisionCell 列表攒在手里,用循环处理完一个就释放引用。代码里别把 List 存到一个成员变量上,用完就置空。
第四,如果文件真的特别大(比如超过 100MB),可以考虑用流式解析先提取修订信息,再用 DOM 方式加载处理。但这种方式代码复杂度会明显上升,非极端场景不推荐。
5. 常见问题与避坑指南
5.1 修订读不到?先检查这几个原因
“getRevisions() 返回空列表”,这大概是所有人遇到的第一个坑。我梳理了几种常见原因,按出现频率排个序:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 修订列表为空 | 原文件没启用修订跟踪 | 在 Excel 里“审阅 -> 修订 -> 突出显示修订”勾选跟踪,再保存 |
| 修订列表为空 | 文件是 .xls 老格式 | 用 Excel 另存为 .xlsx,POI 不支持 HSSF 修订 |
| 只有部分修订 | 修订被处理过 | 检查 isTrackedForRevisions 状态,确认文件是否处于跟踪模式 |
| 读取报错 | POI 版本太低 | 升级到 POI 5.2.3 或以上 |
| 修订信息混乱 | 用 WPS 编辑过文件 | 建议用 Office 生成标准修订文件,WPS 的修订存储格式存在兼容差异 |
这里特别提一句 WPS 的兼容问题。团队里如果存在用 WPS 编辑 Excel 的情况,文件里的修订 XML 可能会和 Office 生成的结构略有差异,导致 POI 解析出的修订数量变少或者数据对不上。不是完全不能处理,但解析结果要以实际测试为准,别在正式环境直接上线。
5.2 拒绝修订后数据恢复错位怎么办
我在测试时遇到过一种情况:A 先修改了 B2 单元格,B 又修改了同一单元格。此时文件里有两条修订,如果全部 reject(),单元格最终显示的是原始值,这没问题。但如果只拒绝 A 的修订、接收 B 的修订,逻辑上应该让单元格保持 B 修改后的值。可是实测某些版本里,只拒绝 A 会导致单元格值变成 B 修改前的值,也就是 A 修改后的值,相当于 B 的修改被连带影响了。
这个问题的根因是修订之间的“链式依赖”。POI 在处理单条修订时,是依据该修订原始记录的上下文来恢复单元格值的,如果同单元格存在覆盖式修改,恢复结果可能不满足直觉预期。我的建议是:在做同单元格多条修订的接收/拒绝时,先构建好单元格维度的修订链,按时间顺序重放,再决定最终值。简单粗暴的逐条处理对同单元格多修订场景会有风险。
5.3 accept 和 reject 混用时的状态一致性
还有一个小坑是 accept 和 reject 混用时,工作簿的内部状态可能短暂不一致。比如你先 accept 了一条修改单元格的修订,然后又 rej 另一条引用该单元格的公式修订,后续单元格计算时可能会拿到旧值。这属于 POI 修订实现的一个限制,目前没有特别完美的解决办法。
面对这个问题,我的处理方案是:把批量操作拆成两个阶段,第一阶段只做 accept,保存一次;第二阶段重新加载文件再做 reject。这样每条修订的落库顺序和业务预期一致,不容易出状态交叉问题。代价是性能稍微差点,多一次文件 IO,但换来的是结果稳定,值。
5.4 中文乱码和路径问题
处理中文文件名、中文作者名时,要确保代码文件的编码是 UTF-8,文件读写流不要手动指定 GBK 之类的字符集。另外,Excel 修订作者字段实际上存的是原始用户配置的用户名,如果 Excel 在 Windows 上配置的登录名是中文,POI 读出来的也是中文,代码里做字符串匹配时注意全角/半角空格问题。
路径这块,Windows 和 Linux 的路径分隔符不同,建议统一用 java.nio.file.Paths 或者 File.separator,别在代码里硬编码“\”。我就吃过一次亏,本地 Windows 跑得好好的,部署到 Linux 服务器上直接文件找不到,排查半天发现是路径分隔符写死了。
5.5 POI 版本升级带来的兼容性变化
POI 5.x 相比 4.x,在修订 API 上做了一些内部调整。网上搜到的不少旧代码用的还是 4.x 的包名或者方法签名,直接粘到 5.x 项目里可能编译不通过。最简单的办法是升级时把报错信息贴出来搜一下,POI 官方在升级文档里列出了主要的 breaking changes。我这边测试过的结论是:XSSFRevision 的 accept()、reject()、getAuthor()、getDate()、getCells() 这些核心方法在 5.x 都还稳定,可以放心使用。
5.6 实操心得与团队落地建议
最后分享几点我自己的习惯。在正式做修订自动处理之前,先准备一个小工具类,把“读取修订 -> 按规则过滤 -> 执行动作 -> 保存结果”这几个步骤封装好。规则部分尽量做成可配置的,不要写死在代码里。这样后续接入不同的业务线,只需要配置对应的规则就行,不需要每来一个新需求就改一遍逻辑。
文件处理完后,建议保留一份处理日志,记录每一条修订的作者、时间、类型、处理结果。一旦后续发现数据异常,可以通过日志回溯当时发生了什么事。日志埋点虽然增加了开发量,但在协作场景里很值得,因为你没法预估哪天会有人问“上次那个文件为什么把预算列改回去了”。
还有一点是关于测试文件的准备。别拿真实业务文件直接测,太危险。我在本地留了一个专门用于测试的 xlsx 模板,里面故意设置了几种典型修订:单元格修改、行删除、批量修改,还包含一个同单元格连续修改的场景。每次改完代码,先用这个模板跑一遍全流程,确认没问题之后再拿真实文件处理。这个习惯帮我避免了好几次线上事故。