SpringBoot实现多种来源的Zip多层目录打包下载
目录
- 1. 核心思路
- 2. 代码实现
- 2.1 工具类:本地&HTTP两种方式写入Zip
- 2.2 Controller 示例:按本地目录结构批量导出
- 3. 常见问题及安全建议
- 4. 总结
需要将一批文件(可能分布在不同目录、不同来源)打包成Zip格式,按目录结构导出给用户下载。
1. 核心思路
支持将本地服务器上的文件(如/data/upload/xxx.jpg)打包进Zip,保持原有目录结构。
支持通过HTTP下载远程文件写入Zip。
所有写入Zip的目录名、文件名均需安全处理。
统一使用流式IO,适合大文件/大量文件导出,防止内存溢出。
目录下无文件时写入empty.txt标识。
2. 代码实现
2.1 工具类:本地&HTTP两种方式写入Zip
package com.example.xiaoshitou.utils; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.springframework.util.StreamUtils; import org.springframework.util.StringUtils; import Javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.*; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.LocalDate; /*** * @title * @author shijiangyong * @date 2025/4/28 16:34 **/ public class ZipDownloadUtils { private static final String SUFFIX_ZIP = ".zip"; private static final String UNNAMED = "未命名"; /** * 安全处理文件名/目录名 * @param name * @return */ public static String safeName(String name) { if (name == null) return "null"; return name.replaceAll("[\\\\/:*?\"<>|]", "_"); } /** * HTTP下载写入Zip * @param zipOut * @param fileUrl * @param zipEntryName * @throws IOException */ public static void writeHttpFileToZip(ZipArchiveOutputStream zipOut, String fileUrl, String zipEntryName) throws IOException { ZipArchiveEntry entry = new ZipArchiveEntry(zipEntryName); zipOut.putArchiveEntry(entry); try (InputStream in = openHttpStream(fileUrl, 8000, 20000)) { byte[] buffer = new byte[4096]; int len; while ((len = in.read(buffer)) != -1) { zipOut.write(buffer, 0, len); } } catch (Exception e) { zipOut.write(("下载失败: " + fileUrl).getBytes(StandardCharsets.UTF_8)); } zipOut.closeArchiveEntry(); } /** * 本地文件写入Zip * @param zipOut * @param localFilePath * @param zipEntryName * @throws IOException */ public static void writeLocalFileToZip(ZipArchiveOutputStream zipOut, String localFilePath, String zipEntryName) throws IOException { File file = new File(localFilePath); if (!file.exists() || file.isDirectory()) { writeTextToZip(zipOut, zipEntryName + "_empty.txt", "文件不存在或是目录: " + localFilePath); return; } ZipArchiveEntry entry = new ZipArchiveEntry(zipEntryName); zipOut.putArchiveEntry(entry); try (InputStream fis = new FileInputStream(file)) { byte[] buffer = new byte[4096]; int len; while ((len = fis.read(buffer)) != -1) { zipOut.write(buffer, 0, len); http://www.devze.com } } zipOut.closeArchiveEntry(); } /** * 写入文本文件到Zip(如empty.txt) * @param zipOut * @param zipEntryName * @param content * @throws IOException */ public static void writeTextToZip(ZipArchiveOutputStream zipOut, String zipEntryName, String content) throws IOException { ZipArchiveEntry entry = new ZipArchiveEntry(zipEntryName); zipOut.putArchiveEntry(entry); zipOut.write(content.getBytes(StandardCharsets.UTF_8)); zipOut.closeArchiveEntry(); } /** * 打开HTTP文件流 * @param url * @param connectTimeout * @param readTimeout * @return * @throws IOException */ public static InputStream openHttpStream(String url, int connectTimeout, int readTimeout) throws IOException { URLConnection conn = new URL(url).openConnection(); conn.setConnectTimeout(connectTimeout); conn.setReadTimeout(readTimeout); return conn.getInputStream(); } /** * 从url获取文件名 * @param url * @return * @throws IOException */ public static String getFileName(String url) { return url.substring(url.lastIndexOf('/')+1); } /** * 设置response * @param request * @param response * @param fileName * @throws UnsupportedEncodingException */ public static void setResponse(HttpServletRequest request, HttpServletResponse response, String fileName) throws UnsupportedEncodingException { if (!StringUtils.hasText(fileName)) { fileName = LocalDate.now() + UNNAMED; } if (!fileName.endsWith(SUFFIX_ZIP)) { fileName = fileName + SUFFIX_ZIP; } response.setHeader("Connection", "close"); response.setHeader("Content-Type", "application/octet-stream;charset=UTF-8"); String filename = encodeFileName(request, fileName); response.setHeader("Content-Disposition", "attachment;filename=" + filename); } /** * 文件名在不同浏览器兼容处理 * @param request 请求信息 * @param fileName 文件名 * @return * @throws UnsupportedEncodingException */ public static String encodeFileName(HttpServletRequest request, String fileName) throws UnsupportedEncodingException { String userAgent = request.getHeader("USER-AGENT"); // 火狐浏览器 if (userAgent.contains("Firefox") || userAgent.contains("firefox")) { fileName = new String(fileName.getBytes(), "ISO8859-1"); } else { // 其他浏览器 fileName = URLEncoder.encode(fileName, "UTF-8"); } return fileName; } }
2.2 Controller 示例:按本地目录结构批量导出
假设有如下导出结构:
用户A/
身份证/ xxx.jpg (本地) xxx.png (本地) 头像/ xxx.jpg (HTTP)用户B/ empty.txt
模拟数据结构:
zipGroup:
import lombok.AllArgsConstructor; import lombok.Data; import java.util.List; /*** * @title * @author shijiangyong * @date 2025/4/28 16:36 **/ @Data @AllArgsConstructor public class ZipGroup { /** * 用户名、文件名 */ private String dirName; private List<ZipSubDir> subDirs; }
zipGroupDir:
importandroid lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /*** * @title * @author shijiangyong * @date 2025/4/28 16:37 **/ @Data @AllArgsConstructor @NoArgsConstructor public class ZipSubDir { /** * 子目录 */ private String subDirName; private List<ZipFileRef> fileRefs; }
ZipFileRef:
import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /*** * @title * @author shijiangyong * @date 2025/4/28 16:38 **/ @Data @AllArgsConstructor @NoArgsConstructor public class ZipFileRef { /** * 文件名 */ private String name; /** * 本地路径 */ private String localPath; /** * http路径 */ private String httpUrl; }
Controller通用代码:
package com.example.xiaoshitou.controller; import com.example.xiaoshitou.service.ZipService; import lombok.AllArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /*** * @title * @author shijiangyong * @date 2025/4/28 16:50 **/ @RestController @RequestMapping("/zip") @AllArgsConstructor public class ZipController { private final ZipService zipService; /** * 打包下载 * @param response */ @GetMapping("/download") public void downloadzip(HttpServletRequest request, HttpServletResponse response) { zipService.downloadZip(request,response); } }
Service 层代码:
package com.example.xiaoshitou.service.impl; import com.example.xiaoshitou.entity.ZipFileRef; import com.example.xiaoshitou.entity.ZipGroup; import com.example.xiaoshitou.entity.ZipSubDir; import com.example.xiaoshitou.service.ZipService; import com.example.xiaoshitou.utils.ZipDownloadUtils; import lombok.extern.slf4j.Slf4j; import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.springframework.stereotype.Service; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.BufferedOutputStream; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.zip.Deflater; /*** * @title * @author shijiangyong * @date 2025/4/28 16:43 **/ @Slf4j @Service public class ZipServiceImpl implements ZipService { @Override public void downloadZip(HttpServletRequest request, HttpServletResponse response) { // ==== 示例数据 ==== List<ZipGroup> data = Arrays.asList( new ZipGroup("小明", Arrays.asList( new ZipSubDir("身份证(本地)", Arrays.asList( new ZipFileRef("","E:/software/test/1.png",""), XdzXZFUV new ZipFileRef("","E:/software/test/2.png","") )), new ZipSubDir("头像(http)", Arrays.asList( // 百度随便找的 new ZipFileRef("","","https://pic4.zhimg.com/v2-4d9e9f936b9968f53be22b594aafa74f_r.jpg") )) )), new ZipGroup("小敏", Collections.emptyList()) ); try (BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream()); ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(bos)) { String fileName = "资料打包_" + System.currentTimeMillis() + ".zip"; ZipDownloadUtils.setResponse(request,response, fileName); // 快速压缩 zipOut.setLevel(Deflater.BEST_SPEED); for (ZipGroup group : data) { String groupDir = ZipDownloadUtils.safeName(group.getDirName()) + "/"; List<ZipSubDir> subDirs = group.getSubDirs(); if (subDirs == null || subDirs.isEmpty()) { groupDir = ZipDownloadUtils.safeName(group.getDirName()) + "(无资料)/"; ZipDownloadUtils.writeTextToZip(zipOut, groupDir + "empty.txt", "该目录无任何资料"); continue; } for (ZipSubDir subDir : subDirs) { String subDirPath = groupDir + ZipDownloadUtils.safeName(subDir.getSubDirName()) + "/"; List<ZipFileRef> fileRefs = subDir.getFileRefs(); if (fileRefs == null || fileRefs.isEmpty()) { subDirPath = groupDir + ZipDownloadUtils.safeName(subDir.getSubDirN编程客栈ame()) + "(empty)/"; ZipDownloadUtils.writeTextToZip(zipOut, subDirPath + "empty.txt", "该类型无资料"); continue; } for (ZipFileRef fileRef : fileRefs) { if (fileRef.getLocalPath() != null && !fileRef.getLocalPath().isEmpty()) { String name = ZipDownloadUtils.getFileName(fileRef.getLocalPath()); fileRef.setName(name); ZipDownloadUtils.writeLocalFileToZip(zipOut, fileRef.getLocalPath(), subDirPath + ZipDownloadUtils.safeName(fileRef.getName())); } else if (fileRef.getHttpUrl() != null && !fileRef.getHttpUrl().isEmpty()) { String name = ZipDownloadUtils.getFileName(fileRef.getHttpUrl()); fileRef.setName(name); ZipDownloadUtils.writeHttpFileToZip(zipOut, fileRef.getHttpUrl(), subDirPath + ZipDownloadUtils.safeName(fileRef.getName())); } } } } zipOut.finish(); zipOut.flush(); response.flushBuffer(); } catch (Exception e) { throw new RuntimeException("打包下载失败", e); http://www.devze.com } } }
3. 常见问题及安全建议
防路径穿越(Zip Slip):所有目录/文件名务必用safeName过滤特殊字符
大文件/大批量:建议分页、分批处理
空目录写入:统一写empty.txt标识空目录
本地文件不存在:Zip包内写入提示信息
HTTP下载失败:Zip包内写入“下载失败”提示
避免泄露服务器绝对路径:仅在日志中记录本地路径,Zip内不暴露
权限校验:实际生产需验证用户是否有权访问指定文件
4. 总结
这里介绍了如何从本地服务器路径和HTTP混合读取文件并Zip打包下载,目录结构灵活可控。可根据实际需求扩展更多来源类型(如数据库、对象存储等)。
到此这篇关于SpringBoot实现多种来源的Zip多层目录打包下载的文章就介绍到这了,更多相关SpringBoot多来源Zip打包内容请搜索编程客栈(www.devze.com)以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程客栈(www.devze.com)!
精彩评论