💡 完整可运行示例已在 GitHub 上提供:
remove-pii-from-office-metadata-java

合规挑战:为何手动元数据审查在大规模时失效

记录团队向外部审计员发送了 200 份文档。有人阅读了每一页,但没有人查看属性,而个人数据正藏在属性中:Author 中的分析员、LastSavedBy 中的第二位分析员、Manager 中的部门主管、Company 中的子公司、截止前一晚的 LastPrinted 时间戳,以及通过 SharePoint 传递的任何文件中的审批人 ID 和工作流路径。

元数据清理是 GroupDocs.Metadata 为 Java 提供的工作流,用于从 Word、Excel 和 PowerPoint 文件中删除这些携带身份信息的属性,然后读取结果并报告仍然保留的内容。本文将按照合规团队的构建方式逐步演示该工作流:存在哪些属性组、每组对应的删除规则、何时用完整擦除替代针对性清除,以及为何验证应与清理同在而不是放在检查清单中。

规模问题不在于删除难,而在于手动审查无法生成记录。审计员询问“哪些字段被从该文件中删除,何时删除”,需要一个数字,而属性对话框根本不提供。

为什么通用清理工具在这里不起作用

Windows 属性对话框一次只能编辑一个文件且只能覆盖部分字段。Office 的文档检查器需要交互式运行,无法用于夜间作业。两者都不会触及自定义 OOXML 部分,也不会写出管道可以再次读取的内容。

深入到 docProps/core.xmldocProps/custom.xml 直接编辑的团队,需要为每个字段、每种格式维护一个 XPath 表达式,并在生成应用程序更改名称时重新审视。这种做法同样会把分类问题弄错。属性名称在不同包之间不同,名称列表会悄然过时,而不再匹配的规则看起来与已经干净的文件相同。

解决方案:在记录工作流中使用 GroupDocs.Metadata

GroupDocs.Metadata for Java 将所有操作统一通过一个属性搜索引擎。Specification 对象决定哪些属性匹配,removeProperties 删除每个匹配项并返回受影响的计数,findProperties 以只读方式运行相同的谓词。属性带有标签,Tags.getPerson().getCreator() 能识别作者类字段,而不论其来源于哪种格式或包。

Java 在 removeProperties 上没有 lambda 重载,这恰好是优势:每条规则都是一个对象,且对象可复用。用于清除一组属性的同一 Specification 实例可以直接交给验证扫描,这样检查就不会偏离它应当测试的清理。

分步实现清理管道

步骤 1 – 按标签清除身份组

四个标签规范通过 .or(...) 组合,覆盖 creator、editor、manager 和 company。其他属性保持不动,Title、Subject、Keywords 仍可用于记录索引。

try (Metadata metadata = new Metadata(inputPath)) {
    if (metadata.getFileFormat() == FileFormat.Unknown) return 0;
    int affected = metadata.removeProperties(
            new ContainsTagSpecification(Tags.getPerson().getCreator())
                .or(new ContainsTagSpecification(Tags.getPerson().getEditor()))
                .or(new ContainsTagSpecification(Tags.getPerson().getManager()))
                .or(new ContainsTagSpecification(Tags.getCorporate().getCompany())));
    metadata.save(outputPath);
    return affected;
}

FileFormat.Unknown 的判断比看上去更重要。若不加判断,无法读取的文件会返回零删除次数,调用方无法分辨这是否因为文件本身已经干净。

步骤 2 – 按名称清除字段族

评论线程、修订计数器和服务器字段没有标签,只能按名称匹配。一个小的 Specification 子类接受可变参数子串列表,使同一个类能够服务三个通道:

public class NameContainsSpec extends Specification {
    private final String[] needles;

    public NameContainsSpec(String... needles) {
        this.needles = needles;
    }

    @Override
    public boolean isSatisfiedBy(MetadataProperty candidate) {
        String name = candidate.getName();
        if (name == null) return false;
        for (String n : needles) {
            if (name.contains(n)) return true;
        }
        return false;
    }
}

编辑时间线是合规团队最关心的,因为修订次数和最后打印日期描述了文档的生成过程:

try (Metadata metadata = new Metadata(inputPath)) {
    if (metadata.getFileFormat() == FileFormat.Unknown) return 0;
    int affected = metadata.removeProperties(new NameContainsSpec(
            "Revision", "TrackedChange", "LastPrinted", "TotalEditingTime", "EditTime"));
    metadata.save(outputPath);
    return affected;
}

评论通道和 SharePoint 通道使用相同的调用,只是子串列表不同:CommentReviewerReviewed 用于审阅痕迹;ServerWorkflowApproverContentTypeTemplate 用于文档服务器字段。

步骤 3 – 在边界处彻底擦除

当文件离开组织时,选择性不再有意义。一次调用即可清除库检测到的所有元数据包,包括自定义 OOXML 部分——这些是针对性谓词找不到的。

try (Metadata metadata = new Metadata(inputPath)) {
    if (metadata.getFileFormat() == FileFormat.Unknown) return 0;
    int affected = metadata.sanitize();
    metadata.save(outputPath);
    return affected;
}

步骤 4 – 验证并分类剩余内容

扫描通过 findProperties 运行所有规则的并集,并将命中分入两类列表。空值和零计数会被跳过,名称以 CommentRevisionInspection 开头的条目实际上是正文内容的包装,而非元数据:

String value = "";
if (p.getValue() != null && p.getValue().getRawValue() != null) {
    value = String.valueOf(p.getValue().getRawValue());
}
if (value.isEmpty() || value.equals("0") || value.equals("0.0")) continue;
String entry = p.getName() + "=" + value;
String name = p.getName() == null ? "" : p.getName();
if (name.startsWith("Comment") || name.startsWith("Revision")
        || name.startsWith("Inspection")) {
    report.contentLevelLeaks.add(entry);
} else {
    report.metadataLeaks.add(entry);
}

这种划分保证了通过/未通过信号的真实性。元数据泄漏必须为空;内容层面的泄漏仅作信息提示,因为 Word 评论和修订作者位于 word/document.xml 中,元数据库只能报告而不编辑它们;要删除这些需要使用如 Aspose.Words 之类的内容编辑库。

何时针对性通道优于完整擦除?

只要文档仍在使用中。正在审阅的文件需要 Title、Subject、Keywords 供搜索和记录分类,而 sanitize() 会把这三者全部删掉。协作期间运行身份和评论通道,保留描述性字段;等文件要交给外部方时再执行完整擦除。

实际工作流:面向外部审计员的导出任务

设想夜间任务。它读取文档 ID 列表,将每个文件复制到暂存路径,执行身份通道和服务器通道,对标记为离组织的文件调用 sanitize(),随后对保存的副本进行泄漏检查。每一步都会把受影响的计数累计到每个文件的一行日志中,若 metadata‑leak 列表非空则任务失败,而不是仅记录警告。

我曾花一个下午调试这个任务,结果显示 40 份文件全部零删除,看似批次已清理。实际上输入文件夹里都是旧的 .doc 二进制文件,格式守卫在每个文件上提前返回,日志中没有区分“无可删除项”和“未读取”。把格式信息一起记录下来后问题得到解决。

商业影响:此方案带来的改变

方面 手动审查 GroupDocs.Metadata 管道
覆盖范围 属性对话框中可见的字段 库检测到的每个包,包括自定义 OOXML 部分
工作记录 仅有笔记(如果有人写了) 每个文件及每个属性组的受影响计数
可重复性 取决于执行人 每条规则对应一个规范,所有文件统一应用
验证方式 重新打开文件手动检查 使用 findProperties 扫描并进行双向分类
可扩展性 一次只能处理一个文件 同样的六个操作在导出文件夹循环中执行

其他适用场景

同一属性引擎既能读取也能删除。比较文档两个版本之间的元数据,可看出哪一次编辑改变了哪些信息,这对所有权争议以及发现文件在流程外被重新授权非常有帮助。使用元数据标签而非名称,使得上述两种情况在 DOCX、XLSX、PPTX、PDF 以及图像格式之间都具备可移植性。

开始使用 GroupDocs.Metadata for Java

pom.xml 中添加 GroupDocs Java 仓库并依赖 com.groupdocs:groupdocs-metadata。库在评估模式下无需许可证即可运行,这足以对示例 DOCX 执行全部六项操作并查看计数。先实现身份通道,根据文档来源需求逐步加入字段族通道,并在投入生产前加入泄漏检查。

资源