在实际业务中,“批量下载网络图片并打包为ZIP”的需求广泛存在(如电商商品图导出、运营素材汇总等),但基础实现往往面临内存溢出、安全风险、用户体验差等问题。本文基于 Spring Boot 后端与 Vue 前端,从流式处理、安全加固、异常细化、交互优化四个核心维度,提供生产级别的完整解决方案,并扩展图片处理、异步任务等实用功能。

一、方案整体架构

整个流程分为“前端交互层”“后端处理层”“资源交互层”三部分,核心逻辑是:

  1. 前端收集用户输入的图片URL,进行基础校验后提交给后端;
  2. 后端验证URL合法性,批量下载图片(流式处理避免内存占用),打包为ZIP并流式返回;
  3. 前端接收ZIP流,触发浏览器下载,并展示处理进度与错误信息。

(实际场景可替换为真实流程图链接)

二、后端实现(Spring Boot):从基础到生产级优化

后端核心目标:安全、高效、稳定地处理图片下载与压缩,避免内存泄漏、SSRF攻击、资源耗尽等问题。

1. 项目初始化与依赖配置

(1)核心依赖(pom.xml)

除基础Web依赖外,补充 HttpClient 连接池、IO工具类、全局异常处理等依赖:

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- HttpClient(带连接池,优化性能) -->
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
        <version>4.5.14</version>
    </dependency>

    <!-- Apache IO工具类(简化流处理) -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-io</artifactId>
        <version>2.15.1</version>
    </dependency>

    <!-- Spring Boot Validation(参数校验) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Lombok(简化代码) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>
(2)HttpClient 连接池配置(性能优化)

默认 HttpClient 无连接池,频繁创建连接会导致性能损耗。通过配置类实现连接池复用:

import org.apache.http.client.config.RequestConfig;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class HttpClientConfig {

    // 最大连接数
    private static final int MAX_TOTAL_CONNECTIONS = 200;
    // 每个路由的最大连接数(路由=协议+域名+端口)
    private static final int MAX_CONNECTIONS_PER_ROUTE = 20;
    // 连接超时时间(毫秒)
    private static final int CONNECT_TIMEOUT = 5000;
    // 读取超时时间(毫秒)
    private static final int SOCKET_TIMEOUT = 10000;

    @Bean
    public CloseableHttpClient httpClient() {
        // 1. 配置连接池
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        connectionManager.setMaxTotal(MAX_TOTAL_CONNECTIONS);
        connectionManager.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE);

        // 2. 配置请求超时
        RequestConfig requestConfig = RequestConfig.custom()
                .setConnectTimeout(CONNECT_TIMEOUT)
                .setSocketTimeout(SOCKET_TIMEOUT)
                .setConnectionRequestTimeout(CONNECT_TIMEOUT) // 从连接池获取连接的超时时间
                .build();

        // 3. 构建HttpClient
        return HttpClientBuilder.create()
                .setConnectionManager(connectionManager)
                .setDefaultRequestConfig(requestConfig)
                .build();
    }
}

2. 核心业务逻辑实现(安全+流式处理)

(1)自定义异常与DTO(结构化错误返回)

为了让前端准确识别错误类型(如URL非法、下载超时),定义自定义异常和错误响应DTO:

// 1. 自定义异常:图片下载相关异常
@Data
@AllArgsConstructor
public class ImageDownloadException extends RuntimeException {
    private String url; // 出错的图片URL
    private String errorMsg; // 错误信息
    private int errorCode; // 错误码(1001=URL非法,1002=下载超时,1003=HTTP错误)
}

// 2. 错误响应DTO
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
    private int code;
    private String message;
    private List<String> failedUrls; // 失败的URL列表(方便前端展示)
}
(2)URL合法性校验工具(防SSRF+白名单)

SSRF(服务器端请求伪造)是核心安全风险,攻击者可能通过输入http://localhost/secret等URL访问服务器内部资源。需通过协议限制、域名白名单、IP黑名单三重校验:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;

@Component
public class UrlValidator {
    // 从配置文件读取可信域名白名单(如:trusted.domains=example.com,test.com)
    @Value("${image.download.trusted.domains:example.com}")
    private String trustedDomains;

    // 禁止访问的内部IP正则(匹配10.0.0.0/8、192.168.0.0/16等内网段)
    private static final Pattern INTERNAL_IP_PATTERN = Pattern.compile(
            "^(10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|" +
            "172\\.(1[6-9]|2\\d|3[0-1])\\.\\d{1,3}\\.\\d{1,3}|" +
            "192\\.168\\.\\d{1,3}\\.\\d{1,3}|" +
            "127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})$"
    );

    // 校验URL合法性
    public void validate(String url) {
        try {
            URL parsedUrl = new URL(url);

            // 1. 限制协议只能是HTTP/HTTPS
            if (!"http".equals(parsedUrl.getProtocol()) && !"https".equals(parsedUrl.getProtocol())) {
                throw new ImageDownloadException(url, "URL协议必须是HTTP/HTTPS", 1001);
            }

            // 2. 校验域名是否在白名单内
            String host = parsedUrl.getHost();
            List<String> trustedList = Arrays.asList(trustedDomains.split(","));
            boolean isTrusted = trustedList.stream()
                    .anyMatch(trustedDomain -> host.endsWith(trustedDomain.trim()));
            if (!isTrusted) {
                throw new ImageDownloadException(url, "URL域名不在可信白名单内", 1001);
            }

            // 3. 禁止访问内部IP(防SSRF)
            if (INTERNAL_IP_PATTERN.matcher(host).matches()) {
                throw new ImageDownloadException(url, "禁止访问内部IP地址", 1001);
            }

        } catch (MalformedURLException e) {
            throw new ImageDownloadException(url, "URL格式非法", 1001);
        }
    }
}
(3)图片下载与压缩服务(流式处理+重复文件名处理)

核心优化点:

  • 流式读写:不将图片完整加载到内存,而是边下载边写入ZIP,避免OOM;
  • 重复文件名处理:同一ZIP内若有重复文件名(如多个URL都指向image.jpg),自动添加序号(image_1.jpg);
  • 错误隔离:单个图片下载失败不影响整体流程,记录失败URL后继续处理其他图片。
import lombok.RequiredArgsConstructor;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.impl.client.CloseableHttpClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

@Service
@RequiredArgsConstructor
public class ImageZipService {
    private final CloseableHttpClient httpClient;
    private final UrlValidator urlValidator;

    // 限制单次最大下载图片数量(避免服务器压力过大)
    @Value("${image.download.max.count:20}")
    private int maxImageCount;

    // 限制单个图片最大大小(单位:MB)
    @Value("${image.download.max.size:10}")
    private long maxImageSizeMb;
    private final long maxImageSize = maxImageSizeMb * 1024 * 1024; // 转换为字节

    /**
     * 批量下载图片并写入ZIP流
     * @param imageUrls 图片URL列表
     * @param zipOut ZIP输出流(与响应流绑定,避免临时文件)
     * @return 失败的URL列表
     */
    public List<String> downloadAndZipImages(List<String> imageUrls, ZipOutputStream zipOut) throws IOException {
        // 1. 校验图片数量
        if (imageUrls.size() > maxImageCount) {
            throw new RuntimeException("单次下载图片数量不能超过" + maxImageCount + "张");
        }

        // 2. 记录重复文件名(key:文件名,value:序号)
        Map<String, Integer> fileNameCounter = new HashMap<>();
        // 3. 记录失败的URL
        List<String> failedUrls = new java.util.ArrayList<>();

        for (String url : imageUrls) {
            try {
                // 步骤1:校验URL合法性
                urlValidator.validate(url);

                // 步骤2:获取合法的ZIP条目名称(处理重复文件名)
                String originalFileName = getFileNameFromUrl(url);
                String zipEntryName = getUniqueFileName(originalFileName, fileNameCounter);

                // 步骤3:下载图片并写入ZIP(流式处理)
                HttpGet httpGet = new HttpGet(url);
                // 绑定上下文,便于后续跟踪
                HttpClientContext context = HttpClientContext.create();

                try (InputStream imageIn = httpClient.execute(httpGet, context).getEntity().getContent()) {
                    // 校验图片大小(防止超大文件)
                    long imageSize = httpClient.execute(httpGet, context).getEntity().getContentLength();
                    if (imageSize > 0 && imageSize > maxImageSize) {
                        throw new ImageDownloadException(url, "图片大小超过" + maxImageSizeMb + "MB限制", 1004);
                    }

                    // 写入ZIP条目
                    ZipEntry zipEntry = new ZipEntry(zipEntryName);
                    zipOut.putNextEntry(zipEntry);

                    // 流式读写:每次读取1KB,避免内存占用
                    byte[] buffer = new byte[1024];
                    int len;
                    long writtenSize = 0;
                    while ((len = imageIn.read(buffer)) != -1) {
                        zipOut.write(buffer, 0, len);
                        writtenSize += len;
                        // 二次校验大小(应对Content-Length为-1的情况)
                        if (writtenSize > maxImageSize) {
                            throw new ImageDownloadException(url, "图片大小超过" + maxImageSizeMb + "MB限制", 1004);
                        }
                    }

                    // 关闭当前ZIP条目
                    zipOut.closeEntry();
                }

            } catch (Exception e) {
                // 记录失败URL,继续处理其他图片
                failedUrls.add(url);
                System.err.println("下载图片失败(URL:" + url + "):" + e.getMessage());
            }
        }

        // 步骤4:添加元数据文件(记录下载信息)
        addMetadataToZip(zipOut, imageUrls.size(), failedUrls.size(), failedUrls);

        return failedUrls;
    }

    /**
     * 从URL中提取文件名(如https://a.com/b.jpg → b.jpg)
     */
    private String getFileNameFromUrl(String url) {
        try {
            URL parsedUrl = new URL(url);
            String path = parsedUrl.getPath();
            return path.substring(path.lastIndexOf('/') + 1);
        } catch (MalformedURLException e) {
            return "unknown_image_" + System.currentTimeMillis() + ".jpg";
        }
    }

    /**
     * 处理重复文件名(如image.jpg → image_1.jpg)
     */
    private String getUniqueFileName(String originalName, Map<String, Integer> counter) {
        if (!counter.containsKey(originalName)) {
            counter.put(originalName, 1);
            return originalName;
        } else {
            int index = counter.get(originalName)++;
            // 分割文件名与后缀(如image.jpg → image_1.jpg)
            int dotIndex = originalName.lastIndexOf('.');
            if (dotIndex == -1) {
                return originalName + "_" + index;
            } else {
                String prefix = originalName.substring(0, dotIndex);
                String suffix = originalName.substring(dotIndex);
                return prefix + "_" + index + suffix;
            }
        }
    }

    /**
     * 向ZIP中添加元数据文件(README.txt),记录下载信息
     */
    private void addMetadataToZip(ZipOutputStream zipOut, int totalCount, int failedCount, List<String> failedUrls) throws IOException {
        ZipEntry metadataEntry = new ZipEntry("README.txt");
        zipOut.putNextEntry(metadataEntry);

        String metadata = String.format(
                "图片下载压缩包元数据%n" +
                "总图片数量:%d%n" +
                "成功数量:%d%n" +
                "失败数量:%d%n" +
                (failedCount > 0 ? "失败URL列表:%n" + String.join("%n", failedUrls) : "")
        );

        zipOut.write(metadata.getBytes());
        zipOut.closeEntry();
    }
}
(4)控制器实现(StreamingResponseBody 流式响应)

使用 Spring 的 StreamingResponseBody 替代传统的 HttpServletResponse 输出流,可实现响应数据边生成边发送,避免将整个ZIP加载到内存,尤其适合大文件场景:

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.io.IOException;
import java.util.List;
import java.util.zip.ZipOutputStream;

@RestController
@RequestMapping("/api/image")
@RequiredArgsConstructor
@CrossOrigin(origins = "${frontend.origin:http://localhost:8081}") // 生产环境配置前端域名
public class ImageZipController {
    private final ImageZipService imageZipService;

    /**
     * 批量下载图片并导出ZIP
     * @param imageUrls 图片URL列表(需非空且每个URL合法)
     */
    @PostMapping("/download-zip")
    public ResponseEntity<StreamingResponseBody> downloadImageZip(
            @Valid @RequestBody @NotEmpty(message = "图片URL列表不能为空") List<String> imageUrls) {

        // 1. 构建流式响应体
        StreamingResponseBody responseBody = outputStream -> {
            try (ZipOutputStream zipOut = new ZipOutputStream(outputStream)) {
                // 2. 调用服务层处理:下载图片并写入ZIP
                imageZipService.downloadAndZipImages(imageUrls, zipOut);
            } catch (IOException e) {
                throw new RuntimeException("ZIP压缩失败:" + e.getMessage());
            }
        };

        // 3. 设置响应头:告知浏览器下载附件
        return ResponseEntity.ok()
                .header("Content-Type", "application/zip")
                .header("Content-Disposition", "attachment; filename=\"images_" + System.currentTimeMillis() + ".zip\"")
                .header("Cache-Control", "no-store") // 禁止缓存,避免旧文件干扰
                .body(responseBody);
    }

    /**
     * 全局异常处理器:统一返回结构化错误信息
     */
    @ExceptionHandler({ImageDownloadException.class, RuntimeException.class})
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        ErrorResponse error = new ErrorResponse();
        if (e instanceof ImageDownloadException) {
            ImageDownloadException ide = (ImageDownloadException) e;
            error.setCode(ide.getErrorCode());
            error.setMessage(ide.getErrorMsg());
            error.setFailedUrls(List.of(ide.getUrl()));
            return ResponseEntity.badRequest().body(error);
        } else {
            error.setCode(500);
            error.setMessage(e.getMessage());
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
        }
    }
}

3. 配置文件(application.yml)

将可变参数(如白名单、数量限制)配置在文件中,便于环境切换:

# 服务器端口
server:
  port: 8080

# 图片下载配置
image:
  download:
    trusted.domains: example.com,test.com,your-domain.com # 可信域名白名单
    max.count: 20 # 单次最大下载数量
    max.size: 10 # 单个图片最大大小(MB)

# 前端域名(用于CORS)
frontend:
  origin: http://localhost:8081 # 开发环境前端地址,生产环境替换为真实域名

三、前端实现(Vue 3 + Vite):交互优化与错误处理

前端核心目标:提升用户体验,包括URL校验、进度展示、错误反馈、批量操作等功能,避免用户“盲等”或“下载到错误文件”。

1. 项目初始化与依赖

# 创建Vue项目
npm create vite@latest image-downloader -- --template vue
cd image-downloader
npm install axios vue-router element-plus # 安装axios、路由、UI组件库

2. 核心组件(ImageDownloader.vue)

整合 URL动态增减、即时校验、下载进度、错误列表展示 功能,使用 Element Plus 提升UI体验:

<template>
  <div class="container">
    <h2>图片批量下载工具</h2>

    <!-- URL输入区域 -->
    <div class="url-list">
      <div v-for="(item, index) in urlList" :key="index" class="url-item">
        <!-- URL输入框 + 即时校验 -->
        <el-input
          v-model="item.url"
          placeholder="请输入HTTP/HTTPS图片URL"
          :class="{ 'invalid-url': item.invalid && item.url }"
          @input="validateUrl(index)"
        />
        <!-- 错误提示 -->
        <el-tooltip
          v-if="item.invalid && item.url"
          content="URL格式非法(需以http/https开头)"
          placement="bottom"
          effect="error"
        >
          <el-icon class="error-icon"><WarningFilled /></el-icon>
        </el-tooltip>
        <!-- 删除按钮 -->
        <el-button 
          type="text" 
          icon="Delete" 
          @click="removeUrl(index)"
          :disabled="urlList.length === 1"
        />
      </div>
    </div>

    <!-- 操作按钮 -->
    <div class="btn-group">
      <el-button type="primary" @click="addUrl">+ 添加URL</el-button>
      <el-button 
        type="success" 
        @click="startDownload"
        :disabled="isDownloading || hasInvalidUrl || urlList.length === 0"
      >
        <el-icon v-if="isDownloading"><Loading /></el-icon>
        {{ isDownloading ? '下载中...' : '打包下载' }}
      </el-button>
    </div>

    <!-- 下载进度条(仅在下载时显示) -->
    <el-progress
      v-if="isDownloading"
      :percentage="downloadProgress"
      status="success"
      :text-inside="true"
      :stroke-width="10"
      class="progress-bar"
    />

    <!-- 错误信息展示(下载失败后显示) -->
    <el-card v-if="failedUrls.length > 0" class="error-card">
      <template #header>
        <div class="card-header">
          <el-icon><WarningFilled /></el-icon>
          <span>部分图片下载失败</span>
        </div>
      </template>
      <div class="failed-urls">
        <p>失败URL列表:</p>
        <el-tag 
          v-for="(url, idx) in failedUrls" 
          :key="idx" 
          type="danger" 
          closable
          @close="removeFailedUrl(idx)"
        >
          {{ url }}
        </el-tag>
      </div>
    </el-card>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import axios from 'axios';
import { 
  ElInput, ElButton, ElIcon, ElTooltip, ElProgress, 
  ElCard, ElTag, ElMessage 
} from 'element-plus';
import { Delete, Loading, WarningFilled } from '@element-plus/icons-vue';

// 1. 状态管理
const urlList = ref([{ url: '', invalid: false }]); // URL列表(含校验状态)
const isDownloading = ref(false); // 是否正在下载
const downloadProgress = ref(0); // 下载进度(0-100)
const failedUrls = ref([]); // 失败的URL列表

// 2. URL动态操作
const addUrl = () => {
  urlList.value.push({ url: '', invalid: false });
};

const removeUrl = (index) => {
  urlList.value.splice(index, 1);
};

// 3. URL即时校验(正则匹配HTTP/HTTPS)
const validateUrl = (index) => {
  const url = urlList.value[index].url.trim();
  const urlRegex = /^https?:\/\/.+\.(jpg|jpeg|png|gif|bmp)$/i; // 限制图片格式
  urlList.value[index].invalid = url && !urlRegex.test(url);
};

// 4. 检查是否有非法URL(用于禁用下载按钮)
const hasInvalidUrl = computed(() => {
  return urlList.value.some(item => item.invalid && item.url.trim());
});

// 5. 下载逻辑(带进度监听)
const startDownload = async () => {
  // 步骤1:收集合法URL(过滤空值)
  const validUrls = urlList.value
    .map(item => item.url.trim())
    .filter(url => url);

  // 步骤2:初始化状态
  isDownloading.value = true;
  downloadProgress.value = 0;
  failedUrls.value = [];

  try {
    // 步骤3:发送请求(responseType: 'blob' 处理二进制流)
    const response = await axios.post(
      '/api/image/download-zip',
      validUrls,
      {
        responseType: 'blob',
        // 监听下载进度
        onDownloadProgress: (progressEvent) => {
          if (progressEvent.total) {
            // 计算进度(仅为估算,因ZIP大小动态生成)
            downloadProgress.value = Math.round((progressEvent.loaded / progressEvent.total) * 100);
          }
        }
      }
    );

    // 步骤4:处理ZIP响应(触发浏览器下载)
    const blob = new Blob([response.data], { type: 'application/zip' });
    const url = URL.createObjectURL(blob);
    const aTag = document.createElement('a');
    
    // 文件名:images_时间戳.zip
    const fileName = `images_${Date.now()}.zip`;
    aTag.download = fileName;
    aTag.href = url;
    aTag.click();

    // 步骤5:释放URL资源
    URL.revokeObjectURL(url);
    ElMessage.success(`下载成功!共${validUrls.length}张图片`);

  } catch (error) {
    // 步骤6:错误处理(区分blob错误和JSON错误)
    if (error.response) {
      const { data, headers } = error.response;
      // 若响应是JSON(错误信息),解析失败URL
      if (headers['content-type']?.includes('application/json')) {
        const errorData = await data.json(); // 解析JSON错误
        failedUrls.value = errorData.failedUrls || [];
        ElMessage.error(`下载失败:${errorData.message}`);
      } else {
        // 若响应是blob(错误的ZIP),提示通用错误
        ElMessage.error('下载失败,请检查URL或联系管理员');
      }
    } else {
      ElMessage.error('网络异常,请重试');
    }
  } finally {
    // 步骤7:重置状态
    isDownloading.value = false;
    downloadProgress.value = 0;
  }
};

// 6. 移除失败URL
const removeFailedUrl = (index) => {
  failedUrls.value.splice(index, 1);
};
</script>

<style scoped>
.container {
  max-width: 800px;
  margin: 20px auto;
  padding: 0 20px;
}

.url-list {
  margin: 20px 0;
}

.url-item {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 10px;
}

.invalid-url {
  border-color: #f56c6c;
}

.error-icon {
  color: #f56c6c;
  cursor: help;
}

.btn-group {
  margin-bottom: 20px;
  display: flex;
  gap: 10px;
}

.progress-bar {
  margin-bottom: 20px;
}

.error-card {
  margin-top: 20px;
}

.card-header {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #f56c6c;
}

.failed-urls {
  margin-top: 10px;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
</style>

3. 跨域配置(vite.config.js)

开发环境下,前端(8081端口)与后端(8080端口)存在跨域,需配置Vite代理:

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 8081, // 前端端口
    proxy: {
      '/api': {
        target: 'http://localhost:8080', // 后端地址
        changeOrigin: true, // 允许跨域
        rewrite: (path) => path.replace(/^\/api/, '') // 去掉/api前缀
      }
    }
  }
});

四、扩展功能:从基础到增值

1. 后端扩展:图片处理(格式转换+水印)

(1)添加图片处理依赖
<!-- 图片处理:Thumbnails -->
<dependency>
    <groupId>net.coobird</groupId>
    <artifactId>thumbnailator</artifactId>
    <version>0.4.20</version>
</dependency>
(2)扩展服务类:添加水印功能
import net.coobird.thumbnailator.Thumbnails;
import net.coobird.thumbnailator.geometry.Positions;
import java.awt.Font;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import javax.imageio.ImageIO;

// 在ImageZipService中添加水印方法
public InputStream addWatermark(InputStream imageIn, String watermarkText) throws IOException {
    // 1. 读取图片
    BufferedImage image = ImageIO.read(imageIn);

    // 2. 配置水印(文字、字体、颜色、透明度)
    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    Thumbnails.of(image)
            .size(image.getWidth(), image.getHeight())
            .watermark(
                    Positions.BOTTOM_RIGHT, // 水印位置:右下角
                    watermarkText, // 水印文字(如"内部使用")
                    0.5f // 透明度(0.0-1.0)
            )
            .watermarkFont(new Font("宋体", Font.BOLD, 20)) // 字体
            .toOutputStream(outputStream);

    // 3. 转换为InputStream返回
    return new ByteArrayInputStream(outputStream.toByteArray());
}
(3)在下载逻辑中调用水印功能
// 在downloadAndZipImages方法中,下载图片后添加水印
try (InputStream imageIn = httpClient.execute(httpGet, context).getEntity().getContent()) {
    // 添加水印(可从配置或请求参数获取水印文字)
    InputStream watermarkedImageIn = addWatermark(imageIn, "内部素材");
    
    // 写入ZIP
    ZipEntry zipEntry = new ZipEntry(zipEntryName);
    zipOut.putNextEntry(zipEntry);
    IOUtils.copy(watermarkedImageIn, zipOut); // 使用IOUtils简化流拷贝
    zipOut.closeEntry();
}

2. 异步处理:大文件/多图片场景优化

当图片数量多(>50张)或图片大(>10MB)时,同步请求可能超时。可通过“任务提交+轮询”实现异步处理:

(1)后端:任务管理与异步接口
// 1. 任务状态枚举
public enum TaskStatus {
    PENDING, PROCESSING, COMPLETED, FAILED
}

// 2. 任务DTO
@Data
public class TaskDTO {
    private String taskId; // 任务ID(UUID生成)
    private TaskStatus status;
    private int progress; // 处理进度(0-100)
    private String zipUrl; // 完成后ZIP文件的临时URL
    private List<String> failedUrls;
}

// 3. 异步任务服务
@Service
public class AsyncImageZipService {
    private final Map<String, TaskDTO> taskMap = new ConcurrentHashMap<>(); // 任务缓存
    private final ExecutorService executorService = Executors.newFixedThreadPool(5); // 线程池
    private final ImageZipService imageZipService;

    // 提交任务
    public TaskDTO submitTask(List<String> imageUrls) {
        String taskId = UUID.randomUUID().toString();
        TaskDTO task = new TaskDTO();
        task.setTaskId(taskId);
        task.setStatus(TaskStatus.PENDING);
        task.setProgress(0);
        taskMap.put(taskId, task);

        // 提交异步任务
        executorService.submit(() -> {
            try {
                task.setStatus(TaskStatus.PROCESSING);
                // 1. 临时文件存储ZIP(实际可使用分布式存储如MinIO)
                File tempZip = File.createTempFile("images_", ".zip");
                try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(tempZip))) {
                    // 2. 处理图片并更新进度(简化版,实际需分阶段更新)
                    List<String> failedUrls = imageZipService.downloadAndZipImages(imageUrls, zipOut);
                    task.setFailedUrls(failedUrls);
                    task.setProgress(100);
                    task.setStatus(TaskStatus.COMPLETED);
                    task.setZipUrl("/api/image/temp/" + taskId + ".zip"); // 临时下载URL
                }
            } catch (Exception e) {
                task.setStatus(TaskStatus.FAILED);
                task.setFailedUrls(imageUrls);
            }
        });

        return task;
    }

    // 查询任务状态
    public TaskDTO getTaskStatus(String taskId) {
        return taskMap.getOrDefault(taskId, new TaskDTO());
    }
}
(2)前端:任务提交与轮询
// 前端添加任务提交与轮询逻辑
const submitTask = async () => {
  const validUrls = urlList.value.map(item => item.url.trim()).filter(url => url);
  const task = await axios.post('/api/image/submit-task', validUrls);
  const taskId = task.data.taskId;
  
  // 轮询任务状态(每2秒一次)
  const pollInterval = setInterval(async () => {
    const taskStatus = await axios.get(`/api/image/task/${taskId}`);
    if (taskStatus.data.status === 'COMPLETED') {
      clearInterval(pollInterval);
      // 下载ZIP
      window.open(taskStatus.data.zipUrl);
    } else if (taskStatus.data.status === 'FAILED') {
      clearInterval(pollInterval);
      ElMessage.error('任务失败,请重试');
    } else {
      // 更新进度
      downloadProgress.value = taskStatus.data.progress;
    }
  }, 2000);
};

五、生产环境注意事项

  1. 资源清理:临时文件需定期清理(如使用定时任务删除24小时前的ZIP文件);
  2. 分布式存储:避免使用本地临时文件,改用MinIO、S3等分布式存储;
  3. 身份验证:添加用户登录(如JWT),避免匿名用户滥用服务;
  4. 监控告警:通过Prometheus+Grafana监控下载成功率、超时率,异常时告警;
  5. 限流熔断:使用Sentinel或Resilience4j限制单用户请求频率,避免服务器过载。

六、总结

本文提供的方案从“基础功能”到“生产优化”,覆盖了图片下载与压缩的全流程,核心亮点包括:

  • 后端:流式处理防OOM、三重安全校验防SSRF、异常细化与资源管理;
  • 前端:即时校验、进度展示、错误反馈,提升用户体验;
  • 扩展:图片水印、异步任务、分布式存储,适配复杂业务场景。

可根据实际需求(如图片格式转换、尺寸压缩)进一步扩展,满足不同业务场景的需求。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐