Java 实现 ZIP 压缩包生成方案

Java 实现 ZIP 压缩包生成方案

文章目录

  • [Java 实现 ZIP 压缩包生成方案](#Java 实现 ZIP 压缩包生成方案)
    • [1. 先确定问题:ZIP 生成不是一次 API 调用](#1. 先确定问题:ZIP 生成不是一次 API 调用)
      • [1.1 读者最终要解决什么](#1.1 读者最终要解决什么)
      • [1.2 一句话结论](#1.2 一句话结论)
    • [2. 方案选择:内存、临时文件还是异步对象存储](#2. 方案选择:内存、临时文件还是异步对象存储)
      • [2.1 三种方案的评价维度](#2.1 三种方案的评价维度)
      • [2.2 决策建议](#2.2 决策建议)
    • [3. 端到端闭环与数据契约](#3. 端到端闭环与数据契约)
      • [3.1 因果骨架](#3.1 因果骨架)
      • [3.2 关键数据契约](#3.2 关键数据契约)
      • [3.3 状态机(异步模式)](#3.3 状态机(异步模式))
    • [4. 最小可运行闭环:纯 Java 17](#4. 最小可运行闭环:纯 Java 17)
      • [4.1 Maven 配置](#4.1 Maven 配置)
      • [4.2 完整示例:从目录收集到 ZIP 校验](#4.2 完整示例:从目录收集到 ZIP 校验)
      • [4.3 代码契约与边界](#4.3 代码契约与边界)
      • [4.4 为什么要先收集再写入](#4.4 为什么要先收集再写入)
    • [5. 生产化核心实现:可插拔内容源与限制](#5. 生产化核心实现:可插拔内容源与限制)
      • [5.1 依赖与配置](#5.1 依赖与配置)
      • [5.2 内容源与输入对象](#5.2 内容源与输入对象)
      • [5.3 配置类、结果类和异常](#5.3 配置类、结果类和异常)
      • [5.4 `ZipArchiveService` 完整实现](#5.4 ZipArchiveService 完整实现)
      • [5.5 实现中的关键取舍](#5.5 实现中的关键取舍)
    • [6. Spring Boot 下载接口:鉴权、响应头与清理](#6. Spring Boot 下载接口:鉴权、响应头与清理)
      • [6.1 注册配置与请求对象](#6.1 注册配置与请求对象)
      • [6.2 Controller 示例](#6.2 Controller 示例)
      • [6.3 统一异常响应](#6.3 统一异常响应)
    • [7. 异步任务与对象存储:何时替换本地文件](#7. 异步任务与对象存储:何时替换本地文件)
      • [7.1 触发条件](#7.1 触发条件)
      • [7.2 替换点](#7.2 替换点)
    • [8. 失败路径与排错矩阵](#8. 失败路径与排错矩阵)
      • [8.1 日志和指标](#8.1 日志和指标)
    • [9. 安全边界:生成侧和解压侧必须分别防护](#9. 安全边界:生成侧和解压侧必须分别防护)
      • [9.1 生成侧](#9.1 生成侧)
      • [9.2 解压侧](#9.2 解压侧)
    • [10. 测试与可复现验收](#10. 测试与可复现验收)
      • [10.1 JUnit 5 核心测试](#10.1 JUnit 5 核心测试)
      • [10.2 命令行检查](#10.2 命令行检查)
      • [10.3 三层验证结论](#10.3 三层验证结论)
    • [11. 生产上线前清单](#11. 生产上线前清单)
    • [12. 结论:如何选、何时改](#12. 结论:如何选、何时改)

内容摘要:本文给出 Java 17 中生成 ZIP 压缩包的可落地方案,覆盖输入清单确定、归档路径规划、流式写入、完整性校验、本地文件交付、Spring Boot 下载接口、异常清理、安全防护和测试验收。核心判断是:ZipOutputStream 只解决"如何写压缩流",不能单独保证结果正确;可靠实现必须同时约束输入快照、重复名称、大小上限、半成品隔离、下载权限和过期清理。

1. 先确定问题:ZIP 生成不是一次 API 调用

1.1 读者最终要解决什么

一个看似简单的"批量下载"功能,实际至少包含六个边界:

  1. 输入边界:哪些文件属于本次归档,生成期间源文件被删除或修改怎么办?
  2. 命名边界:多个文件重名时如何避免覆盖,用户提供的路径如何防止穿越?
  3. 资源边界:大文件和大量文件如何避免一次性加载到堆内存?
  4. 输出边界:如何确认 ZIP 已完整结束,而不是只生成了一个非空半成品?
  5. 交付边界:本地下载、对象存储短链和异步任务分别适用于什么规模?
  6. 生命周期边界:失败文件、过期文件和客户端中断后的临时文件如何清理?

1.2 一句话结论

推荐的默认实现是:先固定并校验输入快照,再按稳定的归档内路径排序,使用 ZipOutputStream 流式写入受控临时文件,关闭后用 ZipFile 校验并原子移动,最后通过鉴权下载并按保留期清理

这个结论有一个重要边界:它适合中小规模、服务端可访问源文件的同步或短异步导出;当总量超过单机磁盘、生成时间明显超过 HTTP 超时或需要多实例共享时,应把"生成文件"替换为对象存储和异步任务,但仍保留同一套输入、命名、安全和校验契约。

2. 方案选择:内存、临时文件还是异步对象存储

2.1 三种方案的评价维度

方案 适用规模 优点 主要代价 失败恢复
ByteArrayOutputStream 内存生成 少量小文件 代码短,直接返回字节 内存峰值约等于 ZIP 大小,容易触发 GC/OOM 失败即丢弃内存
本地临时文件生成 中小规模同步下载 内存稳定,易于 FileSystemResource 下载 依赖本机磁盘,集群需处理文件归属 保留任务记录后重试
异步任务 + 对象存储 大文件、大批量、多实例 不占用 HTTP 连接,可横向扩展 需要任务状态、对象存储和清理策略 可重试、可续传、可过期

2.2 决策建议

  • 默认示例采用本地临时文件,因为它能完整展示 ZIP 生成、校验和 HTTP 下载,且不强绑定云厂商 SDK。
  • 不建议默认把全部输入读进内存。压缩库本身是流式的,提前聚合只会把 I/O 问题转化为堆内存问题。
  • 不建议 让 Controller 直接操作 ZipOutputStream。Controller 应负责鉴权和 HTTP 响应,Service 负责输入快照、压缩和结果契约,便于测试和未来切换对象存储。

3. 端到端闭环与数据契约

3.1 因果骨架

text 复制代码
文件/字节流输入
      │
      ▼
固定清单、权限检查、数量/大小限制
      │
      ▼
归档内路径规范化、排序、去重
      │
      ▼
写入 *.part 临时文件
      │  └─ 每个条目:putNextEntry → copy → closeEntry
      ▼
关闭 ZIP 流并重新打开校验
      │
      ├─ 失败:删除 *.part,记录 failureCode
      │
      ▼
原子移动为正式文件或上传对象存储
      │
      ▼
鉴权下载、短期链接、审计
      │
      ▼
保留期清理与失败告警

读图要点 :正式文件只有在 ZIP 流关闭且可被 ZipFile 重新打开后才产生;因此客户端永远不应读取 .part 文件。这个状态隔离比"生成完再检查"更重要,因为并发下载可能在校验前读到半成品。

3.2 关键数据契约

对象 必填字段 不变量 失败语义
ZipEntrySpec 归档内路径、内容源 路径唯一且不含 ..;内容源可读 参数错误或读取异常
ZipArchiveResult 文件路径、条目数、源字节数、ZIP 字节数、SHA-256 文件已关闭、可重新打开 生成失败不返回结果
下载请求 archiveId、当前用户 用户拥有归档访问权,归档未过期 404/403,不泄露真实路径
清理任务 截止时间 只删除受控目录下且已过期文件 删除失败告警并重试

3.3 状态机(异步模式)

#mermaid-svg-8QXlK0ahzNM3DYKw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8QXlK0ahzNM3DYKw .error-icon{fill:#552222;}#mermaid-svg-8QXlK0ahzNM3DYKw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8QXlK0ahzNM3DYKw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw .marker.cross{stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8QXlK0ahzNM3DYKw p{margin:0;}#mermaid-svg-8QXlK0ahzNM3DYKw defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-8QXlK0ahzNM3DYKw .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-8QXlK0ahzNM3DYKw .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-8QXlK0ahzNM3DYKw .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-8QXlK0ahzNM3DYKw .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel .label text{fill:#333;}#mermaid-svg-8QXlK0ahzNM3DYKw .label div .edgeLabel{color:#333;}#mermaid-svg-8QXlK0ahzNM3DYKw .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-8QXlK0ahzNM3DYKw .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-8QXlK0ahzNM3DYKw .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-8QXlK0ahzNM3DYKw .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw #statediagram-barbEnd{fill:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .cluster-label,#mermaid-svg-8QXlK0ahzNM3DYKw .nodeLabel{color:#131300;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-8QXlK0ahzNM3DYKw .note-edge{stroke-dasharray:5;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-note text{fill:black;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-note .nodeLabel{color:black;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram .edgeLabel{color:red;}#mermaid-svg-8QXlK0ahzNM3DYKw #dependencyStart,#mermaid-svg-8QXlK0ahzNM3DYKw #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8QXlK0ahzNM3DYKw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} worker领取
校验通过并完成上传
读取/磁盘/上传失败
超过保留期
有限次重试
PENDING
RUNNING
SUCCEEDED
FAILED
EXPIRED

读图要点SUCCEEDED 的进入条件必须包含"文件可打开"和"交付地址已持久化",不能只依据压缩循环没有抛异常。FAILED 只允许从明确的失败原因恢复,不能通过人工把状态改成成功来掩盖半成品。

4. 最小可运行闭环:纯 Java 17

4.1 Maven 配置

xml 复制代码
<properties>
    <maven.compiler.release>17</maven.compiler.release>
</properties>

ZIP 生成使用 JDK 标准库,不需要额外压缩依赖。若项目采用 Spring Boot,再增加 Web、Validation 和测试依赖即可;不要为了基础 ZIP 写入引入与业务无关的第三方压缩框架。

4.2 完整示例:从目录收集到 ZIP 校验

以下代码是一个可独立运行的示例,路径均为示例数据,不代表生产目录。它展示了"固定清单 → 稳定排序 → 流式复制 → 重新打开校验"的闭环。

java 复制代码
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

public final class ZipDirectoryDemo {

    private static final int BUFFER_SIZE = 8 * 1024;

    private ZipDirectoryDemo() {
    }

    /**
     * 将目录下的普通文件递归写入 ZIP,并在完成后校验条目列表。
     *
     * @param sourceRoot 源目录
     * @param targetZip  目标 ZIP 文件
     * @return 实际写入的条目数量
     * @throws IOException 文件访问、压缩或校验失败
     */
    public static int zipDirectory(Path sourceRoot, Path targetZip) throws IOException {
        Path normalizedRoot = sourceRoot.toAbsolutePath().normalize();
        if (!Files.isDirectory(normalizedRoot)) {
            throw new IOException("源目录不存在或不是目录: " + normalizedRoot);
        }

        List<Path> files = collectRegularFiles(normalizedRoot);
        List<String> expectedNames = files.stream()
                .map(path -> toArchiveName(normalizedRoot, path))
                .sorted()
                .toList();

        Path parent = targetZip.toAbsolutePath().normalize().getParent();
        if (parent != null) {
            Files.createDirectories(parent);
        }

        Path partFile = targetZip.resolveSibling(targetZip.getFileName() + ".part");
        try {
            try (OutputStream output = Files.newOutputStream(
                    partFile,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING,
                    StandardOpenOption.WRITE);
                 ZipOutputStream zipOutput = new ZipOutputStream(output)) {

                for (Path file : files.stream()
                        .sorted(Comparator.comparing(path -> toArchiveName(normalizedRoot, path)))
                        .toList()) {
                    String archiveName = toArchiveName(normalizedRoot, file);
                    ZipEntry entry = new ZipEntry(archiveName);
                    zipOutput.putNextEntry(entry);
                    try (InputStream input = Files.newInputStream(file)) {
                        copy(input, zipOutput);
                    } finally {
                        zipOutput.closeEntry();
                    }
                }
                zipOutput.finish();
            }

            validateZip(partFile, expectedNames);
            Files.move(
                    partFile,
                    targetZip,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
            return expectedNames.size();
        } catch (IOException | RuntimeException ex) {
            Files.deleteIfExists(partFile);
            throw ex;
        }
    }

    private static List<Path> collectRegularFiles(Path root) throws IOException {
        List<Path> result = new ArrayList<>();
        Files.walkFileTree(root, new SimpleFileVisitor<>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                if (attrs.isRegularFile()) {
                    result.add(file.toAbsolutePath().normalize());
                }
                return FileVisitResult.CONTINUE;
            }
        });
        return result;
    }

    private static String toArchiveName(Path root, Path file) {
        String name = root.relativize(file).toString().replace('\\', '/');
        validateArchiveName(name);
        return name;
    }

    private static void validateArchiveName(String name) {
        if (name.isBlank() || name.startsWith("/") || name.indexOf('\\0') >= 0) {
            throw new IllegalArgumentException("非法 ZIP 条目名称");
        }
        String[] segments = name.split("/");
        for (String segment : segments) {
            if (segment.isBlank() || segment.equals(".") || segment.equals("..")) {
                throw new IllegalArgumentException("ZIP 条目包含非法路径段: " + name);
            }
        }
    }

    private static void copy(InputStream input, OutputStream output) throws IOException {
        byte[] buffer = new byte[BUFFER_SIZE];
        int read;
        while ((read = input.read(buffer)) != -1) {
            output.write(buffer, 0, read);
        }
    }

    private static void validateZip(Path zipFile, List<String> expectedNames) throws IOException {
        Set<String> actualNames = new HashSet<>();
        try (ZipFile zip = new ZipFile(zipFile.toFile())) {
            zip.stream().forEach(entry -> actualNames.add(entry.getName()));
        }
        if (!actualNames.equals(new HashSet<>(expectedNames))) {
            throw new IOException("ZIP 条目校验失败,expected=" + expectedNames + ", actual=" + actualNames);
        }
    }

    public static void main(String[] args) throws IOException {
        Path source = Files.createTempDirectory("zip-source-");
        Files.createDirectories(source.resolve("reports"));
        Files.writeString(source.resolve("README.txt"), "zip demo");
        Files.writeString(source.resolve("reports/年报.csv"), "月份,金额\n1月,100\n");

        Path target = source.resolveSibling("zip-result.zip");
        int count = zipDirectory(source, target);
        System.out.printf("created=%s, entries=%d, bytes=%d%n", target, count, Files.size(target));
    }
}

4.3 代码契约与边界

项目 说明
输入 sourceRoot 必须是目录;只收集普通文件,不跟随软链接进入归档
输出 目标 ZIP;成功时 .part 被原子移动为正式文件
不变量 条目名使用 /;每个输入文件对应一个唯一条目;正式文件可被 ZipFile 打开
失败 任一读取、压缩、校验或移动异常都会删除 .part 并向上抛出
证据 ZipFile 重新打开并比对条目集合;仅证明示例数据闭环,不证明生产磁盘和并发行为

4.4 为什么要先收集再写入

直接在 walkFileTree 回调中写 ZIP 看起来更省代码,但会把"输入清单变化"和"压缩输出"耦合在一起:遍历到一半时文件可能被删除,条目顺序也会受文件系统影响。先固定清单并排序,才能做到可重试、可比较和可审计。代价是需要暂存路径清单;对百万级文件,应把清单存入数据库或分页生成任务,而不是无限增长内存列表。

5. 生产化核心实现:可插拔内容源与限制

5.1 依赖与配置

Spring Boot 3.x 的最小依赖示例:

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

核心配置:

yaml 复制代码
zip:
  temp-dir: /var/app/zip-tmp
  allowed-root: /var/app/export-source
  max-entry-count: 10000
  max-entry-size-bytes: 536870912
  max-total-size-bytes: 2147483648
  buffer-size: 8192
  retention: 24h
  download-url-expire: 10m

配置不是越大越好:max-total-size-bytes 同时约束磁盘占用和下载成本;buffer-size 只影响单个复制缓冲区,不会把 ZIP 变成内存文件;retention 必须与定时清理频率配套。生产环境还应通过启动检查确认临时目录存在、可写且所在文件系统有足够余量。

5.2 内容源与输入对象

java 复制代码
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.OptionalLong;

@FunctionalInterface
public interface ZipContent {

    /**
     * 每次调用都返回一个新的输入流;调用方负责关闭。
     */
    InputStream openStream() throws IOException;

    /**
     * 返回已知大小;未知时返回空。
     */
    default OptionalLong declaredSize() {
        return OptionalLong.empty();
    }

    static ZipContent fromPath(Path path) {
        Path normalized = path.toAbsolutePath().normalize();
        return new ZipContent() {
            @Override
            public InputStream openStream() throws IOException {
                return Files.newInputStream(normalized);
            }

            @Override
            public OptionalLong declaredSize() {
                try {
                    if (!Files.isRegularFile(normalized, LinkOption.NOFOLLOW_LINKS)) {
                        return OptionalLong.empty();
                    }
                    return OptionalLong.of(Files.size(normalized));
                } catch (IOException ex) {
                    return OptionalLong.empty();
                }
            }
        };
    }
}
java 复制代码
import java.io.InputStream;
import java.util.Objects;

public record ZipEntrySpec(String archiveName, ZipContent content) {

    public ZipEntrySpec {
        Objects.requireNonNull(archiveName, "archiveName");
        Objects.requireNonNull(content, "content");
    }
}

对于数据库导出、对象存储下载或动态报表,ZipContent 只要求"可重新打开的流"。如果内容不可重复读取,就不能在失败后简单重试;应先把源内容固化为临时对象,或把任务设计为一次性不可重试并明确告警。

5.3 配置类、结果类和异常

java 复制代码
import java.nio.file.Path;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "zip")
public class ZipArchiveProperties {

    /** ZIP 临时文件目录。 */
    private Path tempDir;

    /** 允许作为输入来源的根目录。 */
    private Path allowedRoot;

    /** 单个归档允许的最大条目数。 */
    private int maxEntryCount = 10_000;

    /** 单个条目的最大原始字节数。 */
    private long maxEntrySizeBytes = 512L * 1024 * 1024;

    /** 所有条目的最大原始字节总数。 */
    private long maxTotalSizeBytes = 2L * 1024 * 1024 * 1024;

    /** 流复制缓冲区大小。 */
    private int bufferSize = 8192;

    /** 临时文件保留时间。 */
    private Duration retention = Duration.ofHours(24);

    public Path getTempDir() {
        return tempDir;
    }

    public void setTempDir(Path tempDir) {
        this.tempDir = tempDir;
    }

    public Path getAllowedRoot() {
        return allowedRoot;
    }

    public void setAllowedRoot(Path allowedRoot) {
        this.allowedRoot = allowedRoot;
    }

    public int getMaxEntryCount() {
        return maxEntryCount;
    }

    public void setMaxEntryCount(int maxEntryCount) {
        this.maxEntryCount = maxEntryCount;
    }

    public long getMaxEntrySizeBytes() {
        return maxEntrySizeBytes;
    }

    public void setMaxEntrySizeBytes(long maxEntrySizeBytes) {
        this.maxEntrySizeBytes = maxEntrySizeBytes;
    }

    public long getMaxTotalSizeBytes() {
        return maxTotalSizeBytes;
    }

    public void setMaxTotalSizeBytes(long maxTotalSizeBytes) {
        this.maxTotalSizeBytes = maxTotalSizeBytes;
    }

    public int getBufferSize() {
        return bufferSize;
    }

    public void setBufferSize(int bufferSize) {
        this.bufferSize = bufferSize;
    }

    public Duration getRetention() {
        return retention;
    }

    public void setRetention(Duration retention) {
        this.retention = retention;
    }
}
java 复制代码
import java.nio.file.Path;

public record ZipArchiveResult(
        Path archivePath,
        int entryCount,
        long sourceBytes,
        long archiveBytes,
        String sha256) {
}
java 复制代码
public class ZipArchiveException extends RuntimeException {

    private final String failureCode;

    public ZipArchiveException(String failureCode, String message, Throwable cause) {
        super(message, cause);
        this.failureCode = failureCode;
    }

    public String getFailureCode() {
        return failureCode;
    }
}

5.4 ZipArchiveService 完整实现

java 复制代码
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
import org.springframework.stereotype.Service;

@Service
public class ZipArchiveService {

    private final ZipArchiveProperties properties;

    public ZipArchiveService(ZipArchiveProperties properties) {
        this.properties = properties;
    }

    /**
     * 将多个内容源流式写入 ZIP 文件,并在发布前完成完整性校验。
     *
     * @param requestedEntries 输入条目;归档名必须唯一
     * @param archiveFileName  目标文件名,只允许普通文件名
     * @return 已发布的 ZIP 结果
     * @throws ZipArchiveException 参数、读取、校验或文件系统失败
     */
    public ZipArchiveResult create(List<ZipEntrySpec> requestedEntries, String archiveFileName) {
        List<ZipEntrySpec> entries = snapshotAndValidate(requestedEntries);
        Path tempDir = requireTempDir();
        Path target = tempDir.resolve(validateFileName(archiveFileName)).normalize();
        if (!target.getParent().equals(tempDir.toAbsolutePath().normalize())) {
            throw new ZipArchiveException("INVALID_ARCHIVE_NAME", "目标文件名不能包含路径", null);
        }
        Path part = target.resolveSibling(target.getFileName() + ".part");

        long sourceBytes = 0;
        try {
            Files.createDirectories(tempDir);
            try (OutputStream output = Files.newOutputStream(
                    part,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING,
                    StandardOpenOption.WRITE);
                 ZipOutputStream zipOutput = new ZipOutputStream(output)) {

                byte[] buffer = new byte[properties.getBufferSize()];
                for (ZipEntrySpec spec : entries) {
                    ZipEntry zipEntry = new ZipEntry(spec.archiveName());
                    zipOutput.putNextEntry(zipEntry);
                    try (InputStream input = spec.content().openStream()) {
                        long entryBytes = copyWithLimit(
                                input,
                                zipOutput,
                                buffer,
                                properties.getMaxEntrySizeBytes(),
                                sourceBytes,
                                properties.getMaxTotalSizeBytes());
                        sourceBytes = Math.addExact(sourceBytes, entryBytes);
                    } finally {
                        zipOutput.closeEntry();
                    }
                }
                zipOutput.finish();
            }

            validateZip(part, entries);
            moveAsPublished(part, target);
            return new ZipArchiveResult(
                    target,
                    entries.size(),
                    sourceBytes,
                    Files.size(target),
                    sha256(target));
        } catch (IOException | ArithmeticException ex) {
            deleteQuietly(part);
            throw new ZipArchiveException("ZIP_CREATE_FAILED", "ZIP 生成失败", ex);
        }
    }

    private List<ZipEntrySpec> snapshotAndValidate(List<ZipEntrySpec> requestedEntries) {
        if (requestedEntries == null || requestedEntries.isEmpty()) {
            throw new ZipArchiveException("EMPTY_ENTRIES", "至少需要一个 ZIP 条目", null);
        }
        if (requestedEntries.size() > properties.getMaxEntryCount()) {
            throw new ZipArchiveException("ENTRY_COUNT_LIMIT", "超过最大条目数", null);
        }

        List<ZipEntrySpec> snapshot = new ArrayList<>(requestedEntries);
        Set<String> names = new HashSet<>();
        long declaredTotal = 0;
        for (ZipEntrySpec spec : snapshot) {
            validateArchiveName(spec.archiveName());
            if (!names.add(spec.archiveName())) {
                throw new ZipArchiveException("DUPLICATE_ENTRY", "重复 ZIP 条目: " + spec.archiveName(), null);
            }
            long declaredSize = spec.content().declaredSize().orElse(-1);
            if (declaredSize > properties.getMaxEntrySizeBytes()) {
                throw new ZipArchiveException("ENTRY_SIZE_LIMIT", "单条目超过大小限制", null);
            }
            if (declaredSize >= 0) {
                declaredTotal = Math.addExact(declaredTotal, declaredSize);
            }
        }
        if (declaredTotal > properties.getMaxTotalSizeBytes()) {
            throw new ZipArchiveException("TOTAL_SIZE_LIMIT", "条目总大小超过限制", null);
        }
        snapshot.sort(java.util.Comparator.comparing(ZipEntrySpec::archiveName));
        return List.copyOf(snapshot);
    }

    private Path requireTempDir() {
        if (properties.getTempDir() == null) {
            throw new ZipArchiveException("TEMP_DIR_MISSING", "未配置 ZIP 临时目录", null);
        }
        return properties.getTempDir().toAbsolutePath().normalize();
    }

    private String validateFileName(String fileName) {
        if (fileName == null || fileName.isBlank()
                || fileName.contains("/")
                || fileName.contains("\\")
                || fileName.contains("..")
                || fileName.indexOf('\0') >= 0) {
            throw new ZipArchiveException("INVALID_ARCHIVE_NAME", "非法归档文件名", null);
        }
        return fileName.endsWith(".zip") ? fileName : fileName + ".zip";
    }

    private void validateArchiveName(String name) {
        if (name == null || name.isBlank() || name.startsWith("/")
                || name.contains("\\") || name.indexOf('\0') >= 0) {
            throw new ZipArchiveException("INVALID_ENTRY_NAME", "非法 ZIP 条目名", null);
        }
        for (String segment : name.split("/", -1)) {
            if (segment.isBlank() || segment.equals(".") || segment.equals("..")) {
                throw new ZipArchiveException("INVALID_ENTRY_NAME", "ZIP 条目包含路径穿越片段", null);
            }
        }
    }

    private long copyWithLimit(
            InputStream input,
            OutputStream output,
            byte[] buffer,
            long entryLimit,
            long alreadyWritten,
            long totalLimit) throws IOException {
        long entryBytes = 0;
        int read;
        while ((read = input.read(buffer)) != -1) {
            entryBytes = Math.addExact(entryBytes, read);
            long total = Math.addExact(alreadyWritten, entryBytes);
            if (entryBytes > entryLimit) {
                throw new IOException("单条目超过大小限制");
            }
            if (total > totalLimit) {
                throw new IOException("条目总大小超过限制");
            }
            output.write(buffer, 0, read);
        }
        return entryBytes;
    }

    private void validateZip(Path part, List<ZipEntrySpec> expected) throws IOException {
        Set<String> expectedNames = expected.stream()
                .map(ZipEntrySpec::archiveName)
                .collect(Collectors.toSet());
        Set<String> actualNames = new HashSet<>();
        try (ZipFile zipFile = new ZipFile(part.toFile())) {
            zipFile.stream().forEach(entry -> actualNames.add(entry.getName()));
        }
        if (!actualNames.equals(expectedNames)) {
            throw new IOException("ZIP 条目集合不一致");
        }
    }

    private void moveAsPublished(Path part, Path target) throws IOException {
        try {
            Files.move(part, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
        } catch (AtomicMoveNotSupportedException ex) {
            Files.move(part, target, StandardCopyOption.REPLACE_EXISTING);
        }
    }

    private String sha256(Path file) throws IOException {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            try (InputStream input = Files.newInputStream(file)) {
                byte[] buffer = new byte[properties.getBufferSize()];
                int read;
                while ((read = input.read(buffer)) != -1) {
                    digest.update(buffer, 0, read);
                }
            }
            return HexFormat.of().formatHex(digest.digest());
        } catch (NoSuchAlgorithmException ex) {
            throw new IllegalStateException("JDK 未提供 SHA-256", ex);
        }
    }

    private void deleteQuietly(Path file) {
        try {
            Files.deleteIfExists(file);
        } catch (IOException ignored) {
            // 清理失败交给日志和定时清理任务兜底,不能覆盖原始失败原因。
        }
    }
}

5.5 实现中的关键取舍

  1. 声明大小与实际大小都检查 :路径文件可以先读 Files.size,动态流可能未知,所以复制循环仍必须计数。只检查声明值会被变化中的文件绕过。
  2. 先写 .part 再发布:即使底层文件系统不支持原子移动,也要保证下载接口只暴露正式文件名;原子移动是增强保证,不是唯一防线。
  3. 对重复条目直接失败:自动编号更友好,但会改变调用方对文件名的预期。若业务需要自动编号,应把编号规则作为显式契约并记录原始名到新名的映射。
  4. 生成后计算 SHA-256:这会再次读取 ZIP,增加 I/O;当结果需要内容寻址、断点校验或对象存储对账时值得保留,否则可改为可选配置。

6. Spring Boot 下载接口:鉴权、响应头与清理

6.1 注册配置与请求对象

java 复制代码
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(ZipArchiveProperties.class)
public class ZipConfiguration {
}
java 复制代码
import jakarta.validation.constraints.NotBlank;

public record ZipDownloadRequest(@NotBlank String archiveId) {
}

6.2 Controller 示例

java 复制代码
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/zip-archives")
public class ZipDownloadController {

    private final ZipArchiveProperties properties;
    private final ZipArchiveAccessService accessService;

    public ZipDownloadController(
            ZipArchiveProperties properties,
            ZipArchiveAccessService accessService) {
        this.properties = properties;
        this.accessService = accessService;
    }

    @GetMapping("/{archiveId}/download")
    public ResponseEntity<Resource> download(@PathVariable String archiveId) throws IOException {
        Path archive = accessService.requireReadableArchive(archiveId);
        Resource resource = new FileSystemResource(archive);
        String downloadName = archive.getFileName().toString();
        ContentDisposition disposition = ContentDisposition.attachment()
                .filename(downloadName, StandardCharsets.UTF_8)
                .build();
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType("application/zip"))
                .contentLength(Files.size(archive))
                .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
                .header(HttpHeaders.CACHE_CONTROL, "private, max-age=0, no-store")
                .body(resource);
    }
}

上例中的 ZipArchiveAccessService 不是可省略的装饰层,它必须完成三件事:根据 archiveId 查询归档元数据、校验当前用户/租户权限、确认文件位于受控临时目录且未过期。Controller 不能直接把请求参数拼成文件路径,否则会把路径穿越和越权下载带进 HTTP 层。

6.3 统一异常响应

java 复制代码
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ZipExceptionHandler {

    @ExceptionHandler(ZipArchiveException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handle(ZipArchiveException ex) {
        return Map.of(
                "code", ex.getFailureCode(),
                "message", "ZIP 处理失败");
    }
}

对外响应不应直接返回本地路径、堆栈或底层 IOException 文本;内部日志可以保留 requestIdarchiveIdfailureCode、条目名摘要和耗时,且要对真实路径做脱敏或哈希化。

7. 异步任务与对象存储:何时替换本地文件

7.1 触发条件

出现以下任一情况,就不应继续把 ZIP 生成绑定在同步 HTTP 请求上:

  • 预计生成时间可能超过网关或浏览器超时;
  • 总文件量接近单机临时盘上限;
  • 服务有多个实例,下载请求可能落到不同节点;
  • 需要失败重试、进度展示、断点下载或审计记录;
  • 归档结果需要跨节点、跨区域或长期保留。

7.2 替换点

ZipArchiveService.create 的输出从 Path 抽象为 ZipArchiveSink

java 复制代码
import java.io.IOException;
import java.io.OutputStream;

public interface ZipArchiveSink {

    OutputStream open(String archiveId) throws IOException;

    void publish(String archiveId, long size, String sha256) throws IOException;
}

本地实现把流写到 .part 文件;对象存储实现可以写入 multipart upload。无论底层是磁盘还是对象存储,以下契约不能改变:

  • 生成完成前不可下载;
  • publish 只有在 ZIP 可重新打开并通过条目校验后才能调用;
  • 任务状态和对象键必须一起持久化;
  • 失败必须可区分"源读取失败""压缩失败""上传失败"和"清理失败"。

8. 失败路径与排错矩阵

场景 预期结果 半成品处理 观测字段 恢复动作
输入清单为空 EMPTY_ENTRIES 删除 .part archiveId, entryCount 修正请求
条目名重复 DUPLICATE_ENTRY 不创建正式文件 archiveName 调整命名策略
文件不存在/无权限 ZIP_CREATE_FAILED 删除 .part entryHash, failureCode 重新生成或跳过
单条目超限 ENTRY_SIZE_LIMIT 删除 .part entryName, bytesWritten 拒绝或拆分归档
总大小超限 TOTAL_SIZE_LIMIT 删除 .part expectedBytes 分批或异步处理
ZIP 中央目录损坏 校验失败 删除 .part archivePath, durationMs 重试并检查磁盘
原子移动不支持 降级普通移动 正式文件仍不可提前暴露 filesystem 保持目录隔离
客户端中断下载 下载失败但归档可保留 按保留期清理 archiveId, bytesSent 允许重新下载
清理任务失败 不影响已完成归档 暂不删除 archiveId, age 告警并重试

8.1 日志和指标

建议每次生成至少记录:

text 复制代码
requestId, archiveId, userId(脱敏), entryCount,
declaredBytes, writtenBytes, archiveBytes,
durationMs, status, failureCode

指标建议包括:生成成功率、平均/分位耗时、输入总字节、压缩后字节、压缩比、失败原因分布、临时目录剩余空间和清理积压数量。不要记录文件内容,也不要把完整用户路径作为高基数指标标签。

9. 安全边界:生成侧和解压侧必须分别防护

9.1 生成侧

  • 只允许来自白名单根目录的普通文件;默认不跟随软链接;
  • 归档内名称拒绝绝对路径、反斜杠、...、空路径段和 NUL;
  • 限制条目数量、单条目大小、总原始大小和临时目录占用;
  • 根据用户/租户权限生成输入清单,不能只在下载时检查;
  • 过滤密钥、配置、私钥、会话文件等敏感文件;
  • 归档文件名使用服务端生成的 ID,不能直接信任用户输入。

9.2 解压侧

ZIP 生成安全不等于解压安全。任何后续解压程序都必须:

  1. 将条目名规范化后再解析目标路径;
  2. 确认目标路径以允许根目录为前缀;
  3. 限制条目数量、总展开大小、单文件大小和压缩比;
  4. 默认拒绝软链接、设备文件和嵌套归档;
  5. 解压到隔离目录,完成扫描后再交给业务使用。

Zip Slip 主要发生在解压侧,Zip Bomb 主要在解压展开阶段耗尽 CPU、磁盘或内存;只在生成端校验文件名,不能替代解压端防护。

10. 测试与可复现验收

10.1 JUnit 5 核心测试

java 复制代码
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.io.ByteArrayInputStream;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipFile;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class ZipArchiveServiceTest {

    @TempDir
    Path tempDir;

    @Test
    void shouldCreateZipAndKeepDeterministicEntryNames() throws Exception {
        ZipArchiveProperties properties = new ZipArchiveProperties();
        properties.setTempDir(tempDir);
        properties.setMaxEntryCount(10);
        properties.setMaxEntrySizeBytes(1024);
        properties.setMaxTotalSizeBytes(4096);
        ZipArchiveService service = new ZipArchiveService(properties);

        ZipArchiveResult result = service.create(
                List.of(
                        new ZipEntrySpec("b.txt", () -> new ByteArrayInputStream("B".getBytes(StandardCharsets.UTF_8))),
                        new ZipEntrySpec("a/中文.txt", () -> new ByteArrayInputStream("A".getBytes(StandardCharsets.UTF_8)))),
                "demo");

        assertEquals(2, result.entryCount());
        try (ZipFile zipFile = new ZipFile(result.archivePath().toFile())) {
            assertEquals(2, zipFile.size());
            List<String> actualNames = zipFile.stream()
                    .map(entry -> entry.getName())
                    .collect(java.util.stream.Collectors.toCollection(ArrayList::new));
            assertEquals(List.of("a/中文.txt", "b.txt"), actualNames);
        }
    }

    @Test
    void shouldRejectDuplicateEntryName() {
        ZipArchiveProperties properties = new ZipArchiveProperties();
        properties.setTempDir(tempDir);
        ZipArchiveService service = new ZipArchiveService(properties);

        assertThrows(ZipArchiveException.class, () -> service.create(
                List.of(
                        new ZipEntrySpec("same.txt", () -> InputStream.nullInputStream()),
                        new ZipEntrySpec("same.txt", () -> InputStream.nullInputStream())),
                "duplicate.zip"));
    }
}

测试中的 findFirst() 不适合作为通用顺序断言,因为 ZIP 条目顺序应由实现的排序契约决定,测试最好收集名称后与期望列表逐项比较。上面的片段用于展示 API 形态;正式测试应补上排序断言、超限断言、失败清理断言和中文名读取断言。

10.2 命令行检查

bash 复制代码
mvn -Dtest=ZipArchiveServiceTest test
unzip -l /var/app/zip-tmp/demo.zip
file /var/app/zip-tmp/demo.zip
sha256sum /var/app/zip-tmp/demo.zip

10.3 三层验证结论

层次 本文能证明什么 仍未证明什么
已证明 标准库流式 API、条目排序、临时文件隔离和 ZipFile 校验的实现路径 需要实际编译/测试执行来确认具体项目接线
未证明 真实对象存储、多实例共享盘、生产权限、网关超时和磁盘告警 这些都依赖部署拓扑与运行环境
下一步 在隔离环境执行大文件、并发导出、下载中断、清理失败和重试验收 通过后再决定同步或异步发布

11. 生产上线前清单

  • 明确输入快照时点,以及源文件并发修改语义;
  • 明确重复归档名是拒绝、编号还是按业务 ID 分目录;
  • 配置并验证临时目录权限、磁盘配额和清理任务;
  • 设置条目数、单文件、总大小和并发任务上限;
  • 生成端和解压端分别完成 Zip Slip/Zip Bomb 防护;
  • 下载接口按用户/租户鉴权,不从 URL 直接拼接物理路径;
  • 成功状态只在 ZIP 校验和交付地址持久化后写入;
  • 失败路径删除半成品,清理失败有告警和重试;
  • 对关键指标、日志字段和审计保留期达成共识;
  • 用真实规模压测决定同步、本地文件还是异步对象存储;
  • 记录 JDK、Spring Boot、容器/主机文件系统和客户端兼容性。

12. 结论:如何选、何时改

如果文件数量和总大小可控、生成时间低于网关超时,采用本文的本地临时文件方案即可:实现简单,内存稳定,验证边界清晰。如果数据规模、并发量或部署拓扑超出单机边界,应保留同一套输入和校验契约,只替换输出 Sink 为异步任务加对象存储。

真正不能省略的是五个契约:输入快照、归档路径唯一性、流式资源管理、正式发布前校验、失败与过期清理。缺少其中任意一个,系统仍可能"生成了一个能下载的 ZIP",却无法证明内容完整、权限正确或长期可维护。

发布范围:后端方案文档,不涉及业务代码发布。

结论:本文已形成 Java ZIP 压缩包从输入收集、流式生成、完整性校验到 Spring Boot 下载和生命周期清理的可落地方案;正式接入项目时,优先确认规模上限、数据来源和交付模式。

相关推荐
Persistent的粽子!1 小时前
C++:类与对象(一)
开发语言·c++·经验分享·笔记
anxiao_m1 小时前
数据安全合规时代,企业跨网文件传输该如何落地
网络
weixin_440730501 小时前
使用pytest中方法控制执行步骤(test_begin.py、test_end.py,@pytest.mark.run(order=1))
开发语言·python·pytest
库玛西1 小时前
深入浅出Linux select网络模型:从底层位图原理到C++面向对象高级封装
linux·服务器·网络·c++·ubuntu
霸道流氓气质2 小时前
Spring AI 多模态开发指南:图片理解与语音合成
java·人工智能·spring
en.en..2 小时前
Linux mmap 内存映射深度解析:基于帧缓冲 /dev/fb0
java·服务器·前端
geovindu2 小时前
CSharp: Command Pattern
开发语言·后端·c#·.net·.netcore·命令模式·行为模式
洋不写bug2 小时前
链表补充练习,双链表的模拟实现
java·数据结构·链表·双链表·底层实现
后台模板学习2 小时前
用 IM 即时聊天项目一次讲清消息去重算法的踩坑与解决方案
java·数据库·spring