Spring Boot 多文件打包 ZIP 下载:完整生成、中文附件名与解压校验
- 发布时间:2026-10-30 07:00:08
- 本文热度:浏览 8 赞 0 评论 0
- 文章标签: Spring Boot ZIP 文件下载
- 全文共1字,阅读约需1分钟
报表页面勾选了“订单明细”和“汇总”,用户希望点一次就拿到两个文件。这个需求适合在服务端生成一个 ZIP:浏览器只下载一份附件,解压后得到各自独立的 CSV 与文本。
先把交付结果说清楚:本例只有两份受控的小报表,订单明细有两笔订单,金额共 8880 分;汇总文件写出相同的订单数与金额。客户端只传报表编号,不传服务器路径,也不指定 ZIP 内部文件名。接口先检查完整选择,再一次性生成 ZIP,失败时返回 JSON,而不是开始下载后才把错误文字塞进压缩数据。
下面是一个可直接启动的 Spring Boot 3.5.0 项目,使用 JDK 17、Maven 3.8.8,另用 Python 3 标准库做独立解压校验。报表数据固定在代码里,不需要数据库或第三方压缩库。这样可以先把打包与下载协议验证清楚,再接入真实报表生成逻辑。
项目与可选报表
新建 report-zip-lab 目录,按以下相对路径保存文件。
pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.0</version><relativePath/></parent>
<groupId>lab</groupId><artifactId>report-zip-lab</artifactId><version>1.0.0</version>
<properties><java.version>17</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency></dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
src/main/resources/application.properties:
server.address=127.0.0.1
server.port=31895
spring.main.banner-mode=off
src/main/java/lab/archive/ArchiveApplication.java:
package lab.archive;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ArchiveApplication {
public static void main(String[] args) { SpringApplication.run(ArchiveApplication.class, args); }
}
服务端维护编号到报表的映射。orders 对应 orders.csv,summary 对应 summary.txt。ZIP 条目名由这张目录控制,因此请求里的 ../pom.xml 只是一个未知编号,不会变成磁盘读取操作。
选择顺序会保留,重复编号则直接拒绝。这里不把重复项悄悄去重,是为了让调用方及时发现勾选参数组装错误;如果业务约定允许去重,也应该明确约定后再实现。一次最多两项,与当前目录的规模一致,不是声称所有导出都适合这个上限。
src/main/java/lab/archive/ReportArchive.java:
package lab.archive;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.springframework.stereotype.Service;
@Service
public class ReportArchive {
private record Report(String entryName, byte[] content) {}
private static final Map<String, Report> CATALOG = Map.of(
"orders", new Report("orders.csv", ("order_no,item,quantity,total_cents\n"
+ "A1001,笔记本,2,3980\nA1002,文件夹,1,4900\n").getBytes(StandardCharsets.UTF_8)),
"summary", new Report("summary.txt", "报表日期:2026-10-01\n订单数:2\n合计:8880 分\n".getBytes(StandardCharsets.UTF_8))
);
public byte[] create(String selection) throws IOException {
if (selection == null || selection.isBlank()) throw new IllegalArgumentException("SELECT_AT_LEAST_ONE");
String[] parts = selection.split(",", -1);
if (parts.length > 2) throw new IllegalArgumentException("TOO_MANY_REPORTS");
var ids = new LinkedHashSet<String>();
List<Report> reports = new ArrayList<>();
for (String raw : parts) {
String id = raw.strip();
if (id.isEmpty()) throw new IllegalArgumentException("EMPTY_REPORT_ID");
if (!ids.add(id)) throw new IllegalArgumentException("DUPLICATE_REPORT_ID");
Report report = CATALOG.get(id);
if (report == null) throw new IllegalArgumentException("UNKNOWN_REPORT_ID");
reports.add(report);
}
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
for (Report report : reports) {
zip.putNextEntry(new ZipEntry(report.entryName()));
zip.write(report.content());
zip.closeEntry();
}
}
return bytes.toByteArray();
}
}
这段写法有两个容易漏掉的收尾。每份文件先 putNextEntry,写完后 closeEntry,下一个条目才能按预期接上。所有条目写完后,还必须完成整个 ZIP,再取得输出字节:ZipOutputStream.close() 会写完中央目录并关闭流,ByteArrayOutputStream 关闭以后仍允许读取已缓冲的内容。
不要把 return bytes.toByteArray() 移到 try 块里面。Java 会先求出返回值,再执行自动关闭;那时复制出来的字节还没有最后的中央目录,即使某些工具能勉强修复,也不应作为正常下载产物。这里把返回放在 try 之后,独立解压器才可以正常识别完整归档。
循环之前已经把所有编号查完,因此 orders,missing 不会先写好订单文件、再忽然发现第二项不存在。真实报表如果涉及权限,也应在这一步确认每一份都允许当前用户导出。本例只演示固定的公开练习数据,没有用户登录与权限模型。
这里的 CSV 是两行已知内容,因此直接用固定字符串表达。接入真实业务字段时,名称里的逗号、双引号和换行需要按 CSV 规则转义,不能继续用字符串相加;ZIP 只负责归档字节,不会替你修复 CSV 结构。压缩条目名的 UTF-8 与文件正文的 UTF-8 也是两回事:前者决定解压出来叫什么名字,后者决定文件里的中文如何解码。检查脚本分别检查名称和正文,不能只验其中一项。
下载响应与错误响应分开
src/main/java/lab/archive/ArchiveController.java:
package lab.archive;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.springframework.http.CacheControl;
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.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestController
public class ArchiveController {
private final ReportArchive archive;
public ArchiveController(ReportArchive archive) { this.archive = archive; }
@GetMapping("/exports/reports.zip")
public ResponseEntity<byte[]> download(@RequestParam(required = false) String ids) throws IOException {
byte[] body = archive.create(ids);
return ResponseEntity.ok().contentType(MediaType.parseMediaType("application/zip"))
.contentLength(body.length).cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename("报表合集.zip", StandardCharsets.UTF_8).build().toString())
.body(body);
}
}
@RestControllerAdvice
class ArchiveErrors {
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<Map<String,String>> badSelection(IllegalArgumentException failure) {
return ResponseEntity.badRequest().body(Map.of("code", failure.getMessage()));
}
@ExceptionHandler(IOException.class)
ResponseEntity<Map<String,String>> archiveFailure() {
return ResponseEntity.internalServerError().body(Map.of("code", "ARCHIVE_FAILED"));
}
}
成功响应使用 application/zip,正文就是压缩字节,不加 JSON 包装,不做 Base64。Content-Disposition 的附件名使用带字符集的构建方法,浏览器可以保存为“报表合集.zip”;它与 ZIP 内部的 orders.csv 是两层不同的名字。
Content-Length 是最终 ZIP 的字节数,不是两个原文件大小相加。内容已经在内存中完整生成,这里可以准确设置长度。无效选择返回 400 与具体错误码,打包 I/O 失败返回 500、ARCHIVE_FAILED;这些响应没有附件头,调用方也就不该把它们当成 ZIP。
为了直接点击验收,加一个最小静态页面,不需要前端框架或额外构建。
src/main/resources/static/index.html:
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>报表打包下载</title>
<style>body{font:18px/1.7 system-ui;max-width:40rem;margin:2rem auto;padding:0 1rem}a{display:block;margin:1rem 0;overflow-wrap:anywhere}</style>
<h1>报表打包下载</h1>
<p>演示报表包含两笔订单,合计 8880 分。</p>
<a href="/exports/reports.zip?ids=orders,summary">下载订单与汇总 ZIP</a>
<a href="/exports/reports.zip?ids=orders">只打包订单报表</a>
</html>
在项目根目录启动:
mvn clean package
java -jar target/report-zip-lab-1.0.0.jar
浏览器打开 http://127.0.0.1:31895/,点击“下载订单与汇总 ZIP”。应保存为“报表合集.zip”,解压后有两份文件,中文品名“笔记本”和“文件夹”完整。另一个链接只生成订单文件,仍是 ZIP,而不是直接返回 CSV。
用另一种读取实现验收压缩包
只看扩展名和文件大小不足以确认归档正确。先通过命令行下载两个报表,保留响应头:
curl -fS -D download-headers.txt -o reports.zip 'http://127.0.0.1:31895/exports/reports.zip?ids=orders,summary'
download-headers.txt 中应有附件响应头和 application/zip。把下列脚本保存到项目根目录,运行 python3 check_zip.py reports.zip。它用 Python 的 ZIP 读取器验证条目、CRC、UTF-8 文本和业务金额,没有复用 Java 的打包过程。
check_zip.py:
import csv
import io
import sys
import zipfile
from pathlib import Path
archive = Path(sys.argv[1] if len(sys.argv) > 1 else "reports.zip")
with zipfile.ZipFile(archive) as z:
assert z.namelist() == ["orders.csv", "summary.txt"], z.namelist()
assert z.testzip() is None
rows = list(csv.DictReader(io.StringIO(z.read("orders.csv").decode("utf-8"))))
assert [row["order_no"] for row in rows] == ["A1001", "A1002"]
assert rows[0]["item"] == "笔记本"
assert sum(int(row["total_cents"]) for row in rows) == 8880
assert z.read("summary.txt").decode("utf-8") == "报表日期:2026-10-01\n订单数:2\n合计:8880 分\n"
print("ZIP_OK: 2 entries, 2 orders, 8880 cents")
成功时输出 ZIP_OK: 2 entries, 2 orders, 8880 cents。这份检查脚本针对上述固定顺序的两份报表;若改用 summary,orders 下载,条目顺序也会反过来,不能仍拿原顺序断言当成格式错误。
再验证几条无效选择:
curl -i http://127.0.0.1:31895/exports/reports.zip
curl -i 'http://127.0.0.1:31895/exports/reports.zip?ids=orders,orders'
curl -i 'http://127.0.0.1:31895/exports/reports.zip?ids=orders,missing'
curl -i 'http://127.0.0.1:31895/exports/reports.zip?ids=orders,'
依次应得到 SELECT_AT_LEAST_ONE、DUPLICATE_REPORT_ID、UNKNOWN_REPORT_ID、EMPTY_REPORT_ID,状态均为 400,正文为 JSON。若下载到的 .zip 打不开,先看状态与正文,有时保存下来的其实是错误 JSON;命令行下载使用 -f 可以让 HTTP 错误以非零退出码暴露出来。
这套内存生成方式的边界是“少量小报表”。压缩流缓冲区和返回的字节数组都会占用堆空间,压缩率高不代表原始数据生成时没有内存成本。几百兆导出适合先生成临时文件或异步导出产物,再提供下载;若直接向 HTTP 流写 ZIP,中途失败后通常已经无法改回一个干净的 JSON 错误响应。应先决定需要哪一种失败交付方式,再决定是否流式输出。完成练习后,在 Java 终端按 Ctrl+C 关闭服务。