Java跨格式文档密码检测实战:基于Apache POI与PDFBox实现
1. 项目概述与核心价值
最近在做一个文档管理系统,客户提了个挺实际的需求:他们有一批历史文档,有些是加了密的,但时间一长,密码记不清了,或者交接时没传下来。他们希望系统能自动扫描这些文档,识别出哪些是受密码保护的,最好还能做个简单的分类。这个需求听起来简单,但真做起来,发现市面上现成的、能同时处理Word、Excel、PPT、PDF这四大主流格式的Java库还真不好找。总不能为了这个功能,让用户装一堆不同的软件吧?所以,我们就决定自己动手,基于Apache POI和PDFBox这些成熟的Java库,封装一个轻量级的文档密码检测工具。
这个工具的核心目标很明确:给定一个文档文件路径,快速、准确地判断它是否被密码保护。它不负责破解密码(那是另一个完全不同的、且涉及法律和伦理的复杂领域),只做“检测”这一件事。这在很多场景下非常有用,比如企业文档资产盘点、数据迁移前的合规性检查、或者像我们项目里这样,作为文档管理流程中的一个前置环节。实现起来,技术点主要在于熟悉不同文档格式的底层结构,以及对应的Java库如何打开并感知密码保护状态。下面,我就把这次实战中积累的经验、踩过的坑,以及最终的实现方案,详细拆解一遍。
2. 技术选型与依赖库解析
要实现跨格式的文档密码检测,选对工具库是第一步。我们不能用一个库打天下,必须针对不同格式选用最专业、最稳定的解决方案。
2.1 办公文档三剑客:Apache POI
对于微软系的Word(.doc, .docx)、Excel(.xls, .xlsx)、PowerPoint(.ppt, .pptx),Apache POI是Java生态中毋庸置疑的事实标准。它提供了完整的API来读写这些OLE2和Office Open XML格式的文件。
- POI-HSLF:用于处理.ppt格式(PowerPoint 97-2003)。
- POI-XSLF:用于处理.pptx格式(PowerPoint 2007+)。
- POI-HSSF:用于处理.xls格式(Excel 97-2003)。
- POI-XSSF:用于处理.xlsx格式(Excel 2007+)。
- POI-HWPF:用于处理.doc格式(Word 97-2003)。注意:这个模块对复杂.doc文件的支持有限,但用于检测密码足够了。
- POI-XWPF:用于处理.docx格式(Word 2007+)。
在Maven项目中,我们通常引入poi-ooxml和poi-scratchpad来覆盖所有新旧格式:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.3</version> <!-- 请使用最新稳定版 --> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-scratchpad</artifactId> <version>5.2.3</version> </dependency>注意:POI的不同模块版本号必须严格保持一致,否则可能引发难以排查的兼容性问题。建议使用
poi-ooxml-full来避免依赖冲突,但会引入较多依赖。
2.2 PDF文档专家:Apache PDFBox
PDF格式自成一体,POI无能为力。这里我们选择Apache PDFBox,它是一个功能强大且活跃的开源Java库,专门用于处理PDF文档。
- 核心能力:PDFBox可以创建、渲染、打印、拆分、合并PDF,当然也包括我们需要的“尝试用空密码或特定密码加载文档以判断是否加密”的功能。
- Maven依赖:
<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.27</version> <!-- 请使用最新稳定版 --> </dependency>2.3 其他格式的考量
除了上述四大件,有时还会遇到其他格式,比如WPS的.et、.dps,或者苹果的.pages、.numbers。对于这些非主流或私有格式,通用检测方法往往不奏效。一个务实的做法是:
- 明确需求边界:与项目干系人确认,是否必须支持这些格式。很多时候,支持主流格式已覆盖90%以上的场景。
- 寻找专用库或转换方案:如果必须支持,需要寻找特定的SDK(如WPS可能提供),或者考虑先将文档转换为标准格式(如PDF)再进行检测,但这会引入转换失败、信息丢失等新风险。
- 兜底策略:对于无法识别的格式,工具应明确返回“格式不支持”或“未知状态”,而不是抛出令人困惑的异常。
在我们的项目中,优先保障了Word、Excel、PPT、PDF这四种格式的稳定检测。
3. 核心检测原理与实现方案
不同格式的文档,其加密机制和POI/PDFBox提供的检测接口各不相同。理解其原理,才能写出健壮的代码。
3.1 Word文档(.doc/.docx)密码检测
Word文档的密码保护分为两种:“打开密码”和“修改密码”。我们通常说的密码检测指的是“打开密码”。
- .docx格式:基于Open XML格式,其加密信息存储在
/encryption包中。POI的XWPFDocument在尝试用空密码或错误密码打开一个加密文档时,会抛出InvalidFormatException或EncryptedDocumentException。 - .doc格式:基于二进制的OLE2复合文档格式,加密信息在文件头。
HWPFDocument在打开加密文档时,同样会抛出异常。
实现思路:尝试用空密码(或一个肯定错误的密码)去加载文档。如果加载成功,说明无密码或密码已知(但已知密码不符合我们场景);如果捕获到特定的异常(如EncryptedDocumentException),则说明文档受密码保护。
public boolean isWordEncrypted(File file) { String fileName = file.getName().toLowerCase(); try { if (fileName.endsWith(".docx")) { try (FileInputStream fis = new FileInputStream(file); XWPFDocument doc = new XWPFDocument(fis)) { // 如果能成功创建XWPFDocument对象,说明没有打开密码,或者密码为空(某些情况) return false; } } else if (fileName.endsWith(".doc")) { try (FileInputStream fis = new FileInputStream(file); HWPFDocument doc = new HWPFDocument(fis)) { return false; } } } catch (EncryptedDocumentException e) { // POI抛出的明确加密异常 return true; } catch (InvalidFormatException e) { // 对于.docx,InvalidFormatException也可能是加密导致的 if (e.getMessage() != null && e.getMessage().contains("Encrypted")) { return true; } // 其他格式错误,按非加密处理或抛出 return false; } catch (Exception e) { // 其他IOException等,可能是文件损坏,这里我们保守地返回false或记录错误 // 在实际项目中,最好将异常信息日志记录下来 return false; } return false; // 非Word格式或未处理情况 }实操心得:
InvalidFormatException的异常信息可能因POI版本不同而略有差异,直接判断字符串包含“Encrypted”可能不够健壮。更稳妥的做法是,在捕获到InvalidFormatException时,可以尝试读取文件魔数或进行更深入的二进制分析,但考虑到我们只是检测,用异常信息判断在大多数情况下是可行的折中方案。
3.2 Excel文档(.xls/.xlsx)密码检测
Excel的密码保护更复杂一些,有“工作簿打开密码”、“工作表保护密码”、“工作簿结构保护密码”等。我们这里检测的也是“工作簿打开密码”。
- .xlsx格式:与.docx类似,加密信息在XML包中。
XSSFWorkbook在打开加密文件时会抛出InvalidPasswordException(POI 4.0+)或EncryptedDocumentException。 - .xls格式:
HSSFWorkbook的行为类似。
实现思路:与Word检测高度相似,尝试用默认(空)密码打开。
public boolean isExcelEncrypted(File file) { String fileName = file.getName().toLowerCase(); try { if (fileName.endsWith(".xlsx")) { try (FileInputStream fis = new FileInputStream(file); XSSFWorkbook workbook = new XSSFWorkbook(fis)) { return false; } } else if (fileName.endsWith(".xls")) { try (FileInputStream fis = new FileInputStream(file); HSSFWorkbook workbook = new HSSFWorkbook(fis)) { return false; } } } catch (InvalidPasswordException e) { // 这是最明确的“需要密码”异常 return true; } catch (EncryptedDocumentException e) { return true; } catch (Exception e) { // 处理其他异常 return false; } return false; }注意事项:这里检测的是“打开密码”。即使返回
false,文档内部的工作表仍可能受“保护密码”锁定,防止编辑。这属于另一种粒度的保护,通常需要不同的API(如sheet.getProtect())来检测,不在本文“打开密码检测”范畴内,但你的系统如果需求更细,可以后续扩展。
3.3 PowerPoint文档(.ppt/.pptx)密码检测
PPT的密码保护机制与Word/Excel类似。
- .pptx格式:使用
XMLSlideShow。 - .ppt格式:使用
HSLFSlideShow。
实现思路:完全遵循相同的“尝试加载-捕获异常”模式。
public boolean isPptEncrypted(File file) { String fileName = file.getName().toLowerCase(); try { if (fileName.endsWith(".pptx")) { try (FileInputStream fis = new FileInputStream(file); XMLSlideShow ppt = new XMLSlideShow(fis)) { return false; } } else if (fileName.endsWith(".ppt")) { try (FileInputStream fis = new FileInputStream(file); HSLFSlideShow ppt = new HSLFSlideShow(fis)) { return false; } } } catch (EncryptedDocumentException | InvalidPasswordException e) { return true; } catch (Exception e) { return false; } return false; }3.4 PDF文档密码检测
PDF的加密检测逻辑与Office文档略有不同。PDFBox提供了更直接的API。
- 核心类:
PDDocument。 - 检测方法:使用
PDDocument.load()方法加载文件时,可以传入一个DecryptionMaterial对象来尝试解密。如果文档加密了,而你传入的密码不对(或为空),加载过程会抛出InvalidPasswordException。关键点:即使密码错误,PDDocument对象仍然会被创建,但文档处于“不可用”的加密状态。我们需要通过document.isEncrypted()方法来判断,并且要妥善处理异常和关闭文档。
实现思路:
- 尝试用空密码加载文档。
- 如果抛出
InvalidPasswordException,说明文档有密码,且密码不是空。此时捕获异常,并返回true。 - 如果没有抛出异常,成功加载,则检查
document.isEncrypted()。- 如果为
true,说明文档有密码,但密码恰好是空(这种情况很少见,但存在)。这通常也视为受密码保护。 - 如果为
false,说明文档无密码。
- 如果为
public boolean isPdfEncrypted(File file) { try (FileInputStream fis = new FileInputStream(file)) { // 尝试用空密码加载 PDDocument document = PDDocument.load(fis, (String)null); boolean encrypted = document.isEncrypted(); document.close(); // 务必关闭 return encrypted; } catch (InvalidPasswordException e) { // 捕获到无效密码异常,说明文档有密码且不是空密码 return true; } catch (Exception e) { // 文件损坏或其他IO异常 return false; } }重要提示:PDFBox的
PDDocument必须显式调用close()方法释放资源,否则可能导致内存泄漏或文件锁未被释放。务必使用try-with-resources语句或在finally块中确保关闭。
4. 工程化封装与性能优化
将上述分散的检测方法封装成一个统一、易用、健壮的工具类,是项目落地的关键。
4.1 统一接口设计
我们设计一个DocumentPasswordDetector类,提供静态方法或实例方法。
public class DocumentPasswordDetector { public enum EncryptionStatus { NOT_ENCRYPTED, // 未加密 ENCRYPTED, // 已加密 UNSUPPORTED_FORMAT, // 不支持的文件格式 ERROR // 读取文件时发生错误(如文件损坏) } /** * 检测单个文件的密码保护状态 * @param filePath 文件完整路径 * @return EncryptionStatus 枚举,表示状态 */ public static EncryptionStatus detect(String filePath) { File file = new File(filePath); if (!file.exists() || !file.isFile()) { return EncryptionStatus.ERROR; } String fileName = file.getName().toLowerCase(); try { if (fileName.endsWith(".docx") || fileName.endsWith(".doc")) { return isWordEncrypted(file) ? EncryptionStatus.ENCRYPTED : EncryptionStatus.NOT_ENCRYPTED; } else if (fileName.endsWith(".xlsx") || fileName.endsWith(".xls")) { return isExcelEncrypted(file) ? EncryptionStatus.ENCRYPTED : EncryptionStatus.NOT_ENCRYPTED; } else if (fileName.endsWith(".pptx") || fileName.endsWith(".ppt")) { return isPptEncrypted(file) ? EncryptionStatus.ENCRYPTED : EncryptionStatus.NOT_ENCRYPTED; } else if (fileName.endsWith(".pdf")) { return isPdfEncrypted(file) ? EncryptionStatus.ENCRYPTED : EncryptionStatus.NOT_ENCRYPTED; } else { return EncryptionStatus.UNSUPPORTED_FORMAT; } } catch (Exception e) { // 记录日志 e.printStackTrace(); 或使用log框架 return EncryptionStatus.ERROR; } } // 将之前实现的 isWordEncrypted, isExcelEncrypted 等方法改为private,放在这里 // ... }4.2 批量处理与并发优化
在实际场景中,往往是扫描整个目录。我们需要考虑性能和资源管理。
串行扫描:简单但慢,适用于文件数量少(<100)的情况。
public Map<File, EncryptionStatus> batchDetectSerial(List<File> files) { Map<File, EncryptionStatus> result = new HashMap<>(); for (File file : files) { result.put(file, detect(file.getAbsolutePath())); } return result; }并行流优化:利用Java 8+的并行流,可以轻松利用多核CPU加速I/O密集型(小文件)或CPU密集型(大文件解析)任务。
public Map<File, EncryptionStatus> batchDetectParallel(List<File> files) { return files.parallelStream() .collect(Collectors.toMap( file -> file, file -> detect(file.getAbsolutePath()) )); }踩坑记录:并行流虽然方便,但要注意线程安全。我们上面的
detect方法是无状态的(只依赖输入参数),因此是线程安全的。但如果方法内部使用了共享的可变静态资源,就必须加锁或使用ThreadLocal,否则会出大问题。线程池控制:对于超大批量(如上万个文件),或者需要更精细控制并发度、队列和拒绝策略的场景,应使用自定义的
ThreadPoolExecutor。public Map<File, EncryptionStatus> batchDetectWithExecutor(List<File> files, int threadPoolSize) throws InterruptedException { ExecutorService executor = Executors.newFixedThreadPool(threadPoolSize); Map<File, EncryptionStatus> resultMap = new ConcurrentHashMap<>(); List<Future<?>> futures = new ArrayList<>(); for (File file : files) { futures.add(executor.submit(() -> { EncryptionStatus status = detect(file.getAbsolutePath()); resultMap.put(file, status); })); } // 等待所有任务完成 for (Future<?> future : futures) { future.get(); // 可以添加超时参数 } executor.shutdown(); return resultMap; }线程池大小经验值:通常设置为
CPU核心数 * 2。但文档检测是混合型任务(I/O等待和CPU计算),可以适当调大,比如CPU核心数 * 4,并通过实际压测找到最优值。切忌盲目设置过大,以免线程上下文切换开销抵消并发收益。
4.3 资源管理与内存泄漏防范
这是最容易出问题的地方。POI和PDFBox在加载文档时,可能会在内存中持有文件流或大量DOM对象。
- 必须使用Try-With-Resources:确保每一个打开的
Workbook、Document、SlideShow、PDDocument以及底层的FileInputStream都在try-with-resources语句中,或者在有异常时在finally块中明确关闭。 - PDFBox的特别提醒:
PDDocument.close()至关重要。即使检测到加密并抛出InvalidPasswordException,如果之前成功创建了PDDocument对象,也必须关闭它。 - 大文件处理:对于超大的Excel或PDF文件,加载整个文档到内存可能引发OOM。POI和PDFBox都提供了部分读取(如SAX模式解析Excel)或设置内存阈值的方法。在纯检测场景下,如果只是判断加密,理论上不需要解析全部内容,但库的加载接口通常还是会读入大部分数据。如果遇到特大文件频繁OOM,需要考虑以下策略:
- 使用POI的
org.apache.poi.xssf.eventusermodel.XSSFReader(用于.xlsx)进行基于事件的流式解析,但代码复杂度激增。 - 为检测服务单独分配一个JVM实例,并设置较大的堆内存(如-Xmx2g)。
- 作为妥协,可以设定一个文件大小阈值(如100MB),超过此大小的文件直接标记为
ERROR或跳过,并记录日志,在需求层面进行沟通。
- 使用POI的
5. 常见问题排查与实战技巧
在实际开发和测试过程中,我遇到了不少典型问题,这里总结一下排查思路和解决方案。
5.1 问题速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 检测Word/.docx文件始终返回“未加密” | 1. 文件扩展名伪装(实为.txt)。 2. 文件已损坏。 3. 使用的是“修改密码”而非“打开密码”。 | 1. 使用Files.probeContentType()或检查文件魔数验证真实类型。2. 尝试用Office软件手动打开,确认文件完好。 3. 明确需求,确认要检测的是“打开密码”。 |
检测PDF抛出InvalidPasswordException但isEncrypted()返回false | 逻辑顺序错误。在捕获异常后,没有正确处理可能已部分加载的PDDocument对象。 | 确保在catch块中,如果PDDocument对象已创建,必须调用close()。最好将加载和关闭逻辑封装好。 |
| 批量处理时内存溢出(OOM) | 1. 同时加载了过多大文档到内存。 2. 未正确关闭文档资源,导致内存泄漏。 3. JVM堆内存设置过小。 | 1. 控制并发度,使用有界队列的线程池。 2.绝对确保每个文档处理完后,相关资源(InputStream, Document对象)被关闭。 3. 增加JVM堆内存。考虑使用 -XX:+HeapDumpOnOutOfMemoryError参数生成堆转储文件分析。 |
| 某些.xls文件检测很慢 | 文件可能包含大量复杂的公式或格式,POI的HSSF解析器需要全部处理。 | 考虑为检测操作设置超时时间。使用Future.get(timeout, TimeUnit)控制单个文件的检测任务。超时则标记为ERROR。 |
返回UNSUPPORTED_FORMAT,但文件确实是.docx | 文件扩展名大小写问题,或包含多个点(如报告.v1.docx)。 | 在提取扩展名时,使用fileName.toLowerCase()统一处理。使用String的substring和lastIndexOf('.')来准确获取扩展名,而不是简单的endsWith。 |
| 在Linux服务器上运行,检测部分Office文档报错 | 服务器可能缺少字体库,影响POI对某些文档样式的处理(虽然不影响加密判断,但可能触发其他异常)。 | 在服务器安装基础字体包,如yum install fontconfig或apt-get install fontconfig。 |
5.2 性能监控与日志
一个健壮的生产级工具离不开监控和日志。
日志记录:使用SLF4J + Logback记录关键事件。
INFO级别:记录开始批量检测、检测完成总结(如共处理X个,加密Y个)。WARN级别:记录不支持的文件格式、跳过的超大文件。ERROR级别:记录文件读取异常、解析异常(附带文件路径和异常信息),方便事后排查。
import org.slf4j.Logger; import org.slf4j.LoggerFactory; private static final Logger logger = LoggerFactory.getLogger(DocumentPasswordDetector.class); // 在detect方法中 catch (Exception e) { logger.error("检测文件加密状态时发生异常,文件: {}", filePath, e); return EncryptionStatus.ERROR; }性能统计:可以简单记录每个文件的检测耗时,用于发现性能瓶颈。
long startTime = System.currentTimeMillis(); EncryptionStatus status = detect(filePath); long cost = System.currentTimeMillis() - startTime; if (cost > 5000) { // 超过5秒视为慢操作 logger.warn("文件检测耗时过长: {}ms, 文件: {}", cost, filePath); }
5.3 扩展性思考
这个基础检测工具可以很容易地扩展:
- 集成到Spring Boot应用:将
DocumentPasswordDetector声明为@Component,通过REST API或消息队列接收检测任务。 - 添加结果持久化:将检测结果(文件路径、状态、检测时间)存入数据库(如MySQL)或搜索引擎(如Elasticsearch),便于查询和生成报表。
- 支持压缩包:结合Apache Commons Compress库,先解压ZIP/RAR文件,再对其中的文档进行递归检测。
- 更细粒度的保护检测:如前所述,可以扩展接口,不仅检测“打开密码”,还能检测Excel的“工作表保护”、Word的“限制编辑”等。
- 分布式检测:对于海量文件(如对象存储OSS/S3中的数百万文档),可以将文件列表和检测逻辑封装成任务,提交到分布式计算框架(如Spark)进行处理。
6. 完整示例代码与使用指南
最后,给出一个整合了上述所有要点的、可直接运行的示例类。
import org.apache.poi.EncryptedDocumentException; import org.apache.poi.hslf.usermodel.HSLFSlideShow; import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.apache.poi.hwpf.HWPFDocument; import org.apache.poi.openxml4j.exceptions.InvalidFormatException; import org.apache.poi.poifs.filesystem.NotOLE2FileException; import org.apache.poi.xslf.usermodel.XMLSlideShow; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.util.*; import java.util.concurrent.*; import java.util.stream.Collectors; public class DocumentPasswordDetector { private static final Logger log = LoggerFactory.getLogger(DocumentPasswordDetector.class); public enum EncryptionStatus { NOT_ENCRYPTED, // 文档未加密 ENCRYPTED, // 文档有打开密码 UNSUPPORTED_FORMAT, // 不支持的文件格式 ERROR // 文件读取/解析错误 } /** * 检测单个文档的加密状态 */ public static EncryptionStatus detect(String filePath) { File file = new File(filePath); if (!file.exists() || !file.isFile()) { log.warn("文件不存在或不是普通文件: {}", filePath); return EncryptionStatus.ERROR; } String fileName = file.getName().toLowerCase(); String extension = getFileExtension(fileName); try { switch (extension) { case "docx": case "doc": return isWordEncrypted(file) ? EncryptionStatus.ENCRYPTED : EncryptionStatus.NOT_ENCRYPTED; case "xlsx": case "xls": return isExcelEncrypted(file) ? EncryptionStatus.ENCRYPTED : EncryptionStatus.NOT_ENCRYPTED; case "pptx": case "ppt": return isPptEncrypted(file) ? EncryptionStatus.ENCRYPTED : EncryptionStatus.NOT_ENCRYPTED; case "pdf": return isPdfEncrypted(file) ? EncryptionStatus.ENCRYPTED : EncryptionStatus.NOT_ENCRYPTED; default: log.info("不支持的文件格式: {}, 文件: {}", extension, filePath); return EncryptionStatus.UNSUPPORTED_FORMAT; } } catch (Exception e) { log.error("处理文件时发生异常: {}", filePath, e); return EncryptionStatus.ERROR; } } // --- 各格式具体检测方法 (private) --- private static boolean isWordEncrypted(File file) throws IOException { String name = file.getName().toLowerCase(); try { if (name.endsWith(".docx")) { try (FileInputStream fis = new FileInputStream(file); XWPFDocument ignored = new XWPFDocument(fis)) { return false; } } else if (name.endsWith(".doc")) { try (FileInputStream fis = new FileInputStream(file); HWPFDocument ignored = new HWPFDocument(fis)) { return false; } } } catch (EncryptedDocumentException e) { return true; } catch (InvalidFormatException e) { // 对.docx,InvalidFormatException可能意味着加密 if (e.getMessage() != null && e.getMessage().toLowerCase().contains("encrypted")) { return true; } throw e; // 重新抛出其他格式错误 } return false; } private static boolean isExcelEncrypted(File file) throws IOException { String name = file.getName().toLowerCase(); try { if (name.endsWith(".xlsx")) { try (FileInputStream fis = new FileInputStream(file); XSSFWorkbook ignored = new XSSFWorkbook(fis)) { return false; } } else if (name.endsWith(".xls")) { try (FileInputStream fis = new FileInputStream(file); HSSFWorkbook ignored = new HSSFWorkbook(fis)) { return false; } } } catch (org.apache.poi.EncryptedDocumentException | org.apache.poi.InvalidPasswordException e) { return true; } return false; } private static boolean isPptEncrypted(File file) throws IOException { String name = file.getName().toLowerCase(); try { if (name.endsWith(".pptx")) { try (FileInputStream fis = new FileInputStream(file); XMLSlideShow ignored = new XMLSlideShow(fis)) { return false; } } else if (name.endsWith(".ppt")) { try (FileInputStream fis = new FileInputStream(file); HSLFSlideShow ignored = new HSLFSlideShow(fis)) { return false; } } } catch (EncryptedDocumentException | org.apache.poi.InvalidPasswordException e) { return true; } return false; } private static boolean isPdfEncrypted(File file) throws IOException { try (FileInputStream fis = new FileInputStream(file)) { // 尝试用空密码加载 PDDocument document = PDDocument.load(fis, (String) null); boolean encrypted = document.isEncrypted(); document.close(); return encrypted; } catch (InvalidPasswordException e) { // 捕获此异常说明有密码且密码错误 return true; } } // --- 批量处理方法 --- /** * 并行流批量检测 (适合中等数量文件) */ public static Map<File, EncryptionStatus> batchDetectParallel(List<File> files) { return files.parallelStream() .collect(Collectors.toConcurrentMap( file -> file, file -> detect(file.getAbsolutePath()) )); } /** * 使用固定线程池批量检测 (适合大批量,可控制并发度) */ public static Map<File, EncryptionStatus> batchDetectWithExecutor(List<File> files, int nThreads) throws InterruptedException, ExecutionException { ExecutorService executor = Executors.newFixedThreadPool(nThreads); ConcurrentHashMap<File, EncryptionStatus> resultMap = new ConcurrentHashMap<>(); List<Future<?>> futures = new ArrayList<>(); for (File file : files) { futures.add(executor.submit(() -> { EncryptionStatus status = detect(file.getAbsolutePath()); resultMap.put(file, status); })); } // 等待所有任务完成 for (Future<?> future : futures) { try { future.get(30, TimeUnit.SECONDS); // 设置单个任务超时时间 } catch (TimeoutException e) { log.warn("文件检测任务超时: {}", e.getMessage()); future.cancel(true); } } executor.shutdown(); if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); } return resultMap; } // --- 工具方法 --- private static String getFileExtension(String fileName) { int lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) { return fileName.substring(lastDotIndex + 1); } return ""; } // --- 主方法,示例用法 --- public static void main(String[] args) { // 示例:检测单个文件 String testFile = "/path/to/your/document.docx"; EncryptionStatus status = detect(testFile); System.out.println("文件状态: " + status); // 示例:批量检测一个目录 File dir = new File("/path/to/your/documents"); if (dir.isDirectory()) { List<File> fileList = Arrays.asList(Objects.requireNonNull(dir.listFiles((d, name) -> name.toLowerCase().matches(".*\\.(docx?|xlsx?|pptx?|pdf)$")))); System.out.println("开始批量检测,文件数: " + fileList.size()); long start = System.currentTimeMillis(); Map<File, EncryptionStatus> results = batchDetectParallel(fileList); long end = System.currentTimeMillis(); // 统计结果 Map<EncryptionStatus, Long> summary = results.values().stream() .collect(Collectors.groupingBy(s -> s, Collectors.counting())); System.out.println("检测完成,耗时: " + (end - start) + "ms"); System.out.println("结果统计: " + summary); // 输出加密文件列表 results.entrySet().stream() .filter(entry -> entry.getValue() == EncryptionStatus.ENCRYPTED) .forEach(entry -> System.out.println("加密文件: " + entry.getKey().getName())); } } }使用指南:
- 环境准备:确保项目pom.xml中正确引入了
poi-ooxml,poi-scratchpad,pdfbox以及日志框架(如logback-classic)的依赖。 - 运行:可以直接运行
main方法测试,或将该类集成到你的Spring Boot服务、命令行工具或定时任务中。 - 配置:根据实际文件数量和服务器资源,调整批量检测方法中的线程池大小(
nThreads)和超时时间。 - 日志:配置好
logback.xml,将日志输出到文件和控制台,便于监控和排查问题。
这个工具类已经过我们生产环境的检验,稳定处理了数十万个各类文档。记住,它的职责是“检测”而非“破解”,清晰的功能边界是项目成功的前提。希望这份详细的拆解和代码,能帮助你顺利实现类似的需求。