SpringBoot實現(xiàn)多種來源的Zip多層目錄打包下載
需要將一批文件(可能分布在不同目錄、不同來源)打包成Zip格式,按目錄結(jié)構(gòu)導(dǎo)出給用戶下載。
1. 核心思路
支持將本地服務(wù)器上的文件(如/data/upload/xxx.jpg)打包進Zip,保持原有目錄結(jié)構(gòu)。
支持通過HTTP下載遠程文件寫入Zip。
所有寫入Zip的目錄名、文件名均需安全處理。
統(tǒng)一使用流式IO,適合大文件/大量文件導(dǎo)出,防止內(nèi)存溢出。
目錄下無文件時寫入empty.txt標(biāo)識。
2. 代碼實現(xiàn)
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); } } 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); } /** * 設(shè)置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 示例:按本地目錄結(jié)構(gòu)批量導(dǎo)出
假設(shè)有如下導(dǎo)出結(jié)構(gòu):
用戶A/
身份證/
xxx.jpg (本地)
xxx.png (本地)
頭像/
xxx.jpg (HTTP)
用戶B/
empty.txt
模擬數(shù)據(jù)結(jié)構(gòu):
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:
import 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) { // ==== 示例數(shù)據(jù) ==== List<ZipGroup> data = Arrays.asList( new ZipGroup("小明", Arrays.asList( new ZipSubDir("身份證(本地)", Arrays.asList( new ZipFileRef("","E:/software/test/1.png",""), 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.getSubDirName()) + "(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); } } }
3. 常見問題及安全建議
防路徑穿越(Zip Slip):所有目錄/文件名務(wù)必用safeName過濾特殊字符
大文件/大批量:建議分頁、分批處理
空目錄寫入:統(tǒng)一寫empty.txt標(biāo)識空目錄
本地文件不存在:Zip包內(nèi)寫入提示信息
HTTP下載失?。篫ip包內(nèi)寫入“下載失敗”提示
避免泄露服務(wù)器絕對路徑:僅在日志中記錄本地路徑,Zip內(nèi)不暴露
權(quán)限校驗:實際生產(chǎn)需驗證用戶是否有權(quán)訪問指定文件
4. 總結(jié)
這里介紹了如何從本地服務(wù)器路徑和HTTP混合讀取文件并Zip打包下載,目錄結(jié)構(gòu)靈活可控??筛鶕?jù)實際需求擴展更多來源類型(如數(shù)據(jù)庫、對象存儲等)。
到此這篇關(guān)于SpringBoot實現(xiàn)多種來源的Zip多層目錄打包下載的文章就介紹到這了,更多相關(guān)SpringBoot多來源Zip打包內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
淺析Spring IOC bean為什么默認(rèn)是單例
單例的意思就是說在 Spring IoC 容器中只會存在一個 bean 的實例,無論一次調(diào)用還是多次調(diào)用,始終指向的都是同一個 bean 對象,本文小編將和大家一起分析Spring IOC bean為什么默認(rèn)是單例,需要的朋友可以參考下2023-12-12SpringSecurity授權(quán)機制的實現(xiàn)(AccessDecisionManager與投票決策)
本文主要介紹了SpringSecurity授權(quán)機制的實現(xiàn),其核心是AccessDecisionManager和投票系統(tǒng),下面就來介紹一下,感興趣的可以了解一下2025-04-04JAVA的LIST接口的REMOVE重載方法調(diào)用原理解析
這篇文章主要介紹了JAVA的LIST接口的REMOVE重載方法調(diào)用原理解析,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-10-10深入探究TimSort對歸并排序算法的優(yōu)化及Java實現(xiàn)
這篇文章主要介紹了TimSort歸并排序的優(yōu)化及Java實現(xiàn),TimSort 是一個歸并排序做了大量優(yōu)化的版本,需要的朋友可以參考下2016-05-05Ubuntu安裝JDK與IntelliJ?IDEA的詳細過程
APT是Linux系統(tǒng)上的包管理工具,能自動解決軟件包依賴關(guān)系并從遠程存儲庫中獲取安裝軟件包,這篇文章主要介紹了Ubuntu安裝JDK與IntelliJ?IDEA的過程,需要的朋友可以參考下2023-08-08java和javascript中過濾掉img形式的字符串不顯示圖片的方法
這篇文章主要介紹了java和javascript中過濾掉img形式的字符串不顯示圖片的方法,以實例形式分別講述了采用java和javascript實現(xiàn)過濾掉img形式字符串的技巧,需要的朋友可以參考下2015-02-02詳解SpringBoot結(jié)合策略模式實戰(zhàn)套路
這篇文章主要介紹了詳解SpringBoot結(jié)合策略模式實戰(zhàn)套路,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-10-10