Java(Spring Boot)+ Vue 实现图片下载与压缩包导出(全流程深度优化版)
)
在实际业务中,“批量下载网络图片并打包为ZIP”的需求广泛存在(如电商商品图导出、运营素材汇总等),但基础实现往往面临内存溢出、安全风险、用户体验差等问题。本文基于 Spring Boot 后端与 Vue 前端,从流式处理、安全加固、异常细化、交互优化四个核心维度,提供生产级别的完整解决方案,并扩展图片处理、异步任务等实用功能。
一、方案整体架构
整个流程分为“前端交互层”“后端处理层”“资源交互层”三部分,核心逻辑是:
- 前端收集用户输入的图片URL,进行基础校验后提交给后端;
- 后端验证URL合法性,批量下载图片(流式处理避免内存占用),打包为ZIP并流式返回;
- 前端接收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);
};
五、生产环境注意事项
- 资源清理:临时文件需定期清理(如使用定时任务删除24小时前的ZIP文件);
- 分布式存储:避免使用本地临时文件,改用MinIO、S3等分布式存储;
- 身份验证:添加用户登录(如JWT),避免匿名用户滥用服务;
- 监控告警:通过Prometheus+Grafana监控下载成功率、超时率,异常时告警;
- 限流熔断:使用Sentinel或Resilience4j限制单用户请求频率,避免服务器过载。
六、总结
本文提供的方案从“基础功能”到“生产优化”,覆盖了图片下载与压缩的全流程,核心亮点包括:
- 后端:流式处理防OOM、三重安全校验防SSRF、异常细化与资源管理;
- 前端:即时校验、进度展示、错误反馈,提升用户体验;
- 扩展:图片水印、异步任务、分布式存储,适配复杂业务场景。
可根据实际需求(如图片格式转换、尺寸压缩)进一步扩展,满足不同业务场景的需求。
更多推荐

所有评论(0)