Spring Boot 实现多种来源的 Zip 多层目录打包下载(本地文件&HTTP混合)

需要将一批文件(可能分布在不同目录、不同来源)打包成Zip格式,按目录结构导出给用户下载。


1. 核心思路

  • 支持将本地服务器上的文件(如/data/upload/xxx.jpg)打包进Zip,保持原有目录结构。
  • 支持通过HTTP下载远程文件写入Zip。
  • 所有写入Zip的目录名、文件名均需安全处理。
  • 统一使用流式IO,适合大文件/大量文件导出,防止内存溢出。
  • 目录下无文件时写入empty.txt标识。

2. 代码实现

2.1 工具类:本地&HTTP两种方式写入Zip

java 复制代码
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);
    }

    /**
     * 设置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 示例:按本地目录结构批量导出

假设有如下导出结构:

plain 复制代码
用户A/
    身份证/
        xxx.jpg (本地)
        xxx.png (本地)
    头像/
        xxx.jpg (HTTP)
用户B/
    empty.txt

模拟数据结构:

zipGroup:

java 复制代码
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:

java 复制代码
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:

java 复制代码
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通用代码:

java 复制代码
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 层代码:

java 复制代码
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",""),
                                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) :所有目录/文件名务必用safeName过滤特殊字符
  • 大文件/大批量:建议分页、分批处理
  • 空目录写入 :统一写empty.txt标识空目录
  • 本地文件不存在:Zip包内写入提示信息
  • HTTP下载失败:Zip包内写入"下载失败"提示
  • 避免泄露服务器绝对路径:仅在日志中记录本地路径,Zip内不暴露
  • 权限校验:实际生产需验证用户是否有权访问指定文件

4. 总结

这里介绍了如何从本地服务器路径HTTP混合读取文件并Zip打包下载,目录结构灵活可控。可根据实际需求扩展更多来源类型(如数据库、对象存储等)。

相关推荐
Dolphin_海豚2 分钟前
一文理清 node.js 模块查找策略
javascript·后端·前端工程化
cainiao08060513 分钟前
Java 大视界:基于 Java 的大数据可视化在智慧城市能源消耗动态监测与优化决策中的应用(2025 实战全景)
java
Q_Q51100828527 分钟前
python的婚纱影楼管理系统
开发语言·spring boot·python·django·flask·node.js·php
长风破浪会有时呀40 分钟前
记一次接口优化历程 CountDownLatch
java
EyeDropLyq1 小时前
线上事故处理记录
后端·架构
云朵大王1 小时前
SQL 视图与事务知识点详解及练习题
java·大数据·数据库
我爱Jack1 小时前
深入解析 LinkedList
java·开发语言
一线大码2 小时前
Gradle 高级篇之构建多模块项目的方法
spring boot·gradle·intellij idea
27669582923 小时前
tiktok 弹幕 逆向分析
java·python·tiktok·tiktok弹幕·tiktok弹幕逆向分析·a-bogus·x-gnarly
用户40315986396633 小时前
多窗口事件分发系统
java·算法