java大文件分片异步上传完整实施方案
一、方案概述
1.1 方案背景
在Web应用中,大文件(如超过100MB的文档、压缩包、图片集)直接上传时,易出现请求超时、内存溢出、网络中断导致上传失败等问题;同时,传统同步上传会阻塞页面,影响用户体验,且IE等老旧浏览器对大文件上传支持较差。为解决上述问题,设计并实现一套兼容多浏览器、支持分片异步上传、进度实时展示、具备安全校验的大文件上传方案。
1.2 核心目标
-
兼容适配:支持现代浏览器(Chrome、Firefox、Edge等)与IE浏览器(含低版本),解决浏览器兼容性问题。
-
分片上传:将大文件切割为固定大小分片,并行上传,避免单次上传超时和内存溢出。
-
异步交互:上传过程不阻塞页面,实时展示上传进度,支持上传取消、文件删除操作。
-
安全可靠:实现登录校验、请求来源校验、文件类型/大小/数量限制,防止恶意上传和路径遍历攻击。
-
可扩展性:支持业务灵活配置,适配不同业务场景下的文件上传需求,便于后续功能扩展(如断点续传、MD5校验)。
1.3 适用场景
本方案适用于各类Web应用的大文件上传场景,尤其适合:
-
业务系统中需要上传大体积文档(如合同、报表、设计图)的场景。
-
需要兼容IE浏览器(政府、企业内网等老旧系统环境)的上传需求。
-
对上传体验要求较高,需要实时进度展示、断点续传的场景。
二、需求分析
2.1 功能需求
| 需求类别 | 具体需求 | 实现说明 |
|---|---|---|
| 文件选择 | 支持单文件选择,显示文件名称、大小 | 前端页面提供上传区域,兼容IE低版本文件选择逻辑 |
| 分片处理 | 大文件自动分片,支持并行上传,可配置分片大小和并行数量 | 前端切割文件,服务端接收并保存分片,支持最多3个分片并行上传 |
| 进度展示 | 实时显示上传进度,支持百分比展示 | 现代浏览器用原生进度监听,IE浏览器用轮询查询进度 |
| 上传控制 | 支持上传确认、取消上传、已上传文件删除 | 前端提供操作按钮,服务端对应处理取消、删除逻辑,清理临时文件 |
| 文件合并 | 所有分片上传完成后,自动合并为完整文件 | 服务端校验分片完整性,按顺序合并,校验文件大小一致性 |
| 业务对接 | 上传完成后回调业务逻辑,保存文件信息 | 提供前端回调函数,服务端将文件信息存入Session,支持业务扩展 |
2.2 非功能需求
-
性能:分片上传响应时间≤500ms,合并文件时间≤1000ms(单文件≤200MB),支持同时3个分片并行上传。
-
兼容性:支持Chrome 80+、Firefox 75+、Edge 80+、IE 10+,IE低版本(8-9)可基础使用(需开启ActiveX控件)。
-
安全性:登录校验、请求来源校验、文件类型白名单、文件大小限制、防路径遍历攻击,避免恶意文件上传。
-
可靠性:上传中断后可取消,临时文件自动清理,合并文件后校验完整性,防止文件损坏。
-
可维护性:代码模块化设计,配置可灵活调整,便于后续扩展和问题排查。
2.3 约束条件
-
文件类型:仅支持图片(jpeg、png、gif、bmp)、文档(doc、docx、xls、xlsx、pdf)、压缩包(zip)。
-
文件大小:默认单文件≤10MB,可按业务编码配置最大≤200MB,单用户单次上传文件数量≤5个。
-
分片配置:默认分片大小20MB,最大分片数量≤30个,最大并行上传数≤3个。
-
环境要求:服务端需支持Java Servlet 3.0+,前端需引入jQuery(用于DOM操作)。
三、架构设计
3.1 整体架构
本方案采用"前端分片上传+服务端接收处理+Session存储状态"的架构模式,分为前端层、服务层、存储层三个层级,各层级职责清晰,协同工作完成大文件上传全流程。
3.1.1 架构分层
-
前端层:负责文件选择、分片切割、并行上传、进度展示、操作控制(确认、取消、删除),兼容多浏览器。
-
服务层:由两个Servlet组成,负责接收分片、合并文件、查询进度、处理取消/删除请求,实现安全校验和业务逻辑对接。
-
存储层:分为临时存储(分片文件)和最终存储(完整文件),采用本地文件系统存储,支持目录自动创建和清理。
3.1.2 核心交互流程
-
前端初始化:传入业务编码,绑定上传区域点击事件、文件选择事件、操作按钮事件。
-
文件选择:用户选择文件,前端校验文件名合法性,显示文件信息,生成唯一uploadId(用于跟踪上传状态)。
-
分片上传:前端切割文件为固定大小分片,并行上传分片,实时更新进度(现代浏览器监听上传进度,IE轮询查询进度)。
-
服务端接收:AsyncUploadServlet接收分片,校验参数和文件合法性,保存分片到临时目录,更新上传进度和已上传分片记录。
-
文件合并:所有分片上传完成后,前端发送合并请求,服务端校验分片完整性,合并分片为完整文件,校验文件大小,保存到最终目录。
-
业务回调:合并完成后,前端触发回调函数,服务端将文件信息存入Session,供业务系统调用。
-
操作控制:用户可取消上传(服务端清理临时分片和进度记录)、删除已上传文件(服务端删除文件和Session记录)。
3.2 核心组件设计
3.2.1 前端组件(asyncUpload.jsp)
前端核心组件负责用户交互和分片处理,包含页面样式、DOM元素、JavaScript逻辑三部分,关键功能如下:
-
页面样式:提供上传区域、文件信息展示、进度条、操作按钮,适配不同浏览器样式。
-
DOM元素:上传区域、文件输入框、文件信息显示区、进度条、确认/取消按钮,支持IE条件注释提示。
-
JavaScript逻辑:初始化参数、文件选择处理、分片切割、并行上传、进度更新、上传控制(确认、取消、删除)、浏览器兼容处理。
3.2.2 服务端组件
-
AsyncUploadServlet:核心Servlet,负责处理分片上传、文件合并、取消上传、删除文件请求,实现安全校验和业务对接。
-
初始化:配置上传目录、允许的文件类型、文件大小限制,创建目录(若不存在)。
-
分块上传处理:接收分片参数和数据,校验参数合法性,保存分片到临时目录,更新上传进度和已上传分片记录。
-
文件合并处理:校验所有分片完整性,按顺序合并分片,校验文件大小,保存到最终目录,更新Session中的文件信息。
-
取消上传处理:删除临时分片目录,清理上传进度和已上传分片记录。
-
删除文件处理:删除已上传的完整文件,清理Session中的文件信息。
-
-
AsyncUploadProgressServlet:辅助Servlet,专为IE浏览器提供上传进度查询功能,接收uploadId,返回当前上传进度。
3.2.3 存储设计
-
临时存储目录:tmp/upload/asyncUploadTemp,用于保存分片文件,每个上传任务对应一个子目录(以uploadId命名),分片文件以"分片索引.part"命名。
-
最终存储目录:tmp/upload/asyncUpload,用于保存合并后的完整文件,文件以UUID重命名(避免文件名重复和路径遍历攻击),保留原文件扩展名。
-
目录管理:服务端初始化时自动创建临时目录和最终目录,文件合并完成后删除对应临时目录,取消上传时删除临时目录,支持后续添加定时清理临时文件功能。
四、核心实现(完整可运行代码)
4.1 环境依赖
服务端需引入以下依赖包(Maven配置),确保Servlet 3.0+环境:
xml
<!-- 文件上传依赖 -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version>
</dependency>
<!-- JSON序列化依赖 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.7</version>
</dependency>
<!-- 日志依赖 -->
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>
4.2 web.xml配置(修复异步支持)
xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<!-- 异步上传文件Servlet -->
<servlet>
<servlet-name>asyncUploadServlet</servlet-name>
<servlet-class>com.web.AsyncUploadServlet</servlet-class>
<!-- 开启异步支持,解决大文件上传阻塞问题 -->
<async-supported>true</async-supported>
</servlet>
<servlet-mapping>
<servlet-name>asyncUploadServlet</servlet-name>
<url-pattern>/asyncUpload</url-pattern>
</servlet-mapping>
<!-- 异步上传进度查询Servlet(适配IE浏览器) -->
<servlet>
<servlet-name>asyncUploadProgressServlet</servlet-name>
<servlet-class>com.web.AsyncUploadProgressServlet</servlet-class>
<async-supported>true</async-supported>
</servlet>
<servlet-mapping>
<servlet-name>asyncUploadProgressServlet</servlet-name>
<url-pattern>/asyncUploadProgress</url-pattern>
</servlet-mapping>
</web-app>
4.3 服务端代码
4.3.1 AsyncUploadServlet.java(完整版)
java
package com.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* @description: 大文件分片异步上传核心Servlet
* @author: liuyongheng
* @date: 2025-09-18 18:13:08
*/
public class AsyncUploadServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
protected final static Log log = LogFactory.getLog(AsyncUploadServlet.class);
// 上传临时目录(分片存储)
private String uploadTempDir;
// 上传最终目录(完整文件存储)
private String uploadDir;
// 允许上传的文件类型(白名单)
private Set<String> allowedFileTypes;
// 允许上传的文件大小(key:业务编码,value:大小限制,单位:字节)
public static Map<String, Long> alloweFileSize;
// 上传进度跟踪 (uploadId -> 进度百分比),静态并发安全
public static Map<String, Float> uploadProgress = new ConcurrentHashMap<>();
// 已上传的分块 (uploadId -> Set<chunkIndex>),静态并发安全,解决多用户混乱问题
public static Map<String, Set<Integer>> uploadedChunks = new ConcurrentHashMap<>();
// 最大上传文件数量(单用户单次)
private static final int MAX_FILE_COUNT = 5;
// 最大上传文件分片数量
private static final int MAX_CHUNK_COUNT = 30;
@Override
public void init() throws ServletException {
// 初始化上传目录(可后续改为配置文件读取)
uploadDir = "tmp/upload/asyncUpload";
uploadTempDir = "tmp/upload/asyncUploadTemp";
// 创建目录(如果不存在)
File dir = new File(uploadDir);
if (!dir.exists()) {
dir.mkdirs();
}
dir = new File(uploadTempDir);
if (!dir.exists()) {
dir.mkdirs();
}
// 初始化允许的文件类型(白名单)
allowedFileTypes = new HashSet<>();
allowedFileTypes.add("image/jpeg");
allowedFileTypes.add("image/png");
allowedFileTypes.add("image/gif");
allowedFileTypes.add("image/bmp");
allowedFileTypes.add("application/zip");
allowedFileTypes.add("application/pdf");
allowedFileTypes.add("application/msword");
allowedFileTypes.add("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
allowedFileTypes.add("application/vnd.ms-excel");
allowedFileTypes.add("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
// 初始化允许的文件大小(默认10MB,业务编码可自定义,如200MB)
alloweFileSize = new HashMap<>();
alloweFileSize.put("default", 10485760L); // 10MB = 10*1024*1024
// 此处根据业务需求灵活修改,key为业务唯一标识,value为对应大小限制(单位:字节)
alloweFileSize.put("trans001", 209715200L); // 200MB = 200*1024*1024
}
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
log.info("AsyncUploadServlet Start");
long startTime = System.currentTimeMillis();
// 设置响应内容类型,避免中文乱码
response.setContentType("text/plain;charset=UTF-8");
PrintWriter out = response.getWriter();
HttpSession session = request.getSession();
// 1. 登录校验:必须登录才能上传(根据业务调整session属性)
if (session == null || session.getAttribute("session_login_model") == null) {
sendError(out, "未登录,请先登录后再上传");
return;
}
// 2. 安全校验:验证请求来源,防止跨域恶意请求
String referer = request.getHeader("Referer");
String serverName = request.getServerName();
if (referer == null || !referer.contains(serverName)) {
sendError(out, "不允许的请求来源,禁止跨域上传");
return;
}
// 3. 处理不同操作类型(分片上传、合并、取消、删除)
String action = request.getParameter("action");
if ("complete".equals(action)) {
// 处理文件合并(所有分片上传完成后调用)
handleComplete(request, out);
} else if ("cancel".equals(action)) {
// 处理取消上传
handleCancel(request, out);
} else if ("delete".equals(action)) {
// 处理删除已上传文件
handleDelete(request, out);
} else {
// 默认处理分块上传
handleChunkUpload(request, out);
}
log.info("AsyncUploadServlet End, 耗时: " + (System.currentTimeMillis() - startTime) + "ms");
}
/**
* 处理分块上传
* @param request 请求对象
* @param out 响应输出流
*/
private void handleChunkUpload(HttpServletRequest request, PrintWriter out) {
// 初始化参数
String uploadId = null; // 上传唯一标识,用于跟踪分片和进度
String fileName = null; // 原始文件名
String fileSizeStr = null; // 文件总大小(字符串)
String chunkIndexStr = null; // 当前分片索引(从0开始)
String totalChunksStr = null; // 总分片数量
String transCode = null; // 业务编码(用于区分不同业务的大小限制)
byte[] chunkData = null; // 当前分片数据
// 校验请求是否为multipart/form-data类型(文件上传请求)
if (ServletFileUpload.isMultipartContent(request)) {
try {
// 解析请求,获取表单字段和文件分片数据
List<FileItem> items = new ServletFileUpload(new DiskFileItemFactory()).parseRequest(request);
for (FileItem item : items) {
if (!item.isFormField()) {
// 非表单字段:获取分片数据
chunkData = item.get();
} else {
// 表单字段:获取上传相关参数
String fieldName = item.getFieldName();
String fieldValue = item.getString();
if ("uploadId".equals(fieldName)) {
uploadId = URLDecoder.decode(fieldValue, "UTF-8");
} else if ("fileName".equals(fieldName)) {
fileName = URLDecoder.decode(fieldValue, "UTF-8");
} else if ("fileSize".equals(fieldName)) {
fileSizeStr = fieldValue;
} else if ("chunkIndex".equals(fieldName)) {
chunkIndexStr = fieldValue;
} else if ("totalChunks".equals(fieldName)) {
totalChunksStr = fieldValue;
} else if ("transCode".equals(fieldName)) {
transCode = fieldValue;
}
}
}
} catch (Exception e) {
log.error("获取上传参数失败:", e);
sendError(out, "获取上传参数失败,请重试");
return;
}
}
log.info("handleChunkUpload: chunkIndex=" + chunkIndexStr + ", totalChunks=" + totalChunksStr +
", uploadId=" + uploadId + ", fileName=" + fileName + ", fileSize=" + fileSizeStr + ", transCode=" + transCode);
// 4. 参数合法性校验
if (uploadId == null || fileName == null || chunkIndexStr == null || totalChunksStr == null) {
sendError(out, "缺少必要的上传参数(uploadId、fileName、chunkIndex、totalChunks)");
uploadProgress.remove(uploadId);
return;
}
// 解析参数(字符串转对应类型)
int chunkIndex;
int totalChunks;
long fileSize;
try {
chunkIndex = Integer.parseInt(chunkIndexStr);
totalChunks = Integer.parseInt(totalChunksStr);
fileSize = Long.parseLong(fileSizeStr);
} catch (NumberFormatException e) {
sendError(out, "参数格式错误(chunkIndex、totalChunks、fileSize需为数字)");
uploadProgress.remove(uploadId);
return;
}
// 5. 文件类型校验(白名单校验)
String fileType = getFileType(fileName);
if (!allowedFileTypes.contains(fileType)) {
sendError(out, "不允许上传的文件类型: " + fileType + ",仅支持图片、文档、压缩包");
uploadProgress.remove(uploadId);
return;
}
// 6. 文件大小校验(根据业务编码获取对应限制,无业务编码则用默认值)
long maxFileSize = alloweFileSize.getOrDefault(transCode, alloweFileSize.get("default"));
if (fileSize > maxFileSize) {
sendError(out, "文件大小超出限制,最大允许上传" + (maxFileSize / 1024 / 1024) + "MB");
uploadProgress.remove(uploadId);
return;
}
// 7. 分片数量校验(防止分片过多导致异常)
if (chunkIndex > MAX_CHUNK_COUNT) {
sendError(out, "文件分片过多,最大允许" + MAX_CHUNK_COUNT + "个分片");
uploadProgress.remove(uploadId);
return;
}
// 8. 创建临时目录(按uploadId区分,存储当前上传任务的所有分片)
String tempDirPath = uploadTempDir + File.separator + uploadId;
File tempDir = new File(tempDirPath);
if (!tempDir.exists()) {
tempDir.mkdirs();
}
// 9. 保存当前分片到临时目录
String chunkFileName = chunkIndex + ".part"; // 分片文件名(索引.part)
String chunkFilePath = tempDirPath + File.separator + chunkFileName;
File chunkFile = new File(chunkFilePath);
try (InputStream in = new ByteArrayInputStream(chunkData);
OutputStream outStream = new FileOutputStream(chunkFile)) {
byte[] buffer = new byte[1024 * 1024]; // 1MB缓冲区,提升写入效率
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
outStream.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
sendError(out, "保存分片失败: " + e.getMessage());
uploadProgress.remove(uploadId);
return;
}
// 10. 记录已上传的分片(并发安全)
uploadedChunks.computeIfAbsent(uploadId, k -> Collections.newSetFromMap(new ConcurrentHashMap<>()));
uploadedChunks.get(uploadId).add(chunkIndex);
// 11. 更新上传进度(百分比)
int uploadedCount = uploadedChunks.get(uploadId).size();
float progress = (float) uploadedCount / totalChunks * 100;
uploadProgress.put(uploadId, progress);
// 12. 返回分片上传成功响应
sendSuccess(out, "分块 " + (chunkIndex + 1) + "/" + totalChunks + " 上传成功", "");
}
/**
* 处理文件合并(所有分片上传完成后调用)
* @param request 请求对象
* @param out 响应输出流
*/
private void handleComplete(HttpServletRequest request, PrintWriter out) {
String uploadId = null;
String fileName = null;
try {
// 解码参数,避免中文乱码
uploadId = URLDecoder.decode(String.valueOf(request.getParameter("uploadId")), "UTF-8");
fileName = URLDecoder.decode(String.valueOf(request.getParameter("fileName")), "UTF-8");
} catch (UnsupportedEncodingException e) {
sendError(out, "缺少必要的参数(uploadId、fileName)");
uploadProgress.remove(uploadId);
return;
}
String fileSizeStr = request.getParameter("fileSize");
String transCode = request.getParameter("transCode");
String asyncUploadState = request.getParameter("asyncUploadState"); // 上传状态(是否为续传)
log.info("handleComplete: uploadId=" + uploadId + ", fileName=" + fileName + ", fileSize=" + fileSizeStr + ", transCode=" + transCode);
// 参数校验
if (uploadId == null || fileName == null) {
sendError(out, "缺少必要的参数(uploadId、fileName)");
uploadProgress.remove(uploadId);
return;
}
// 解析文件大小
long fileSize;
try {
fileSize = Long.parseLong(fileSizeStr);
} catch (NumberFormatException e) {
sendError(out, "文件大小参数错误,请重试");
uploadProgress.remove(uploadId);
return;
}
// 校验已上传分片是否存在
Set<Integer> chunks = uploadedChunks.get(uploadId);
if (chunks == null || chunks.isEmpty()) {
sendError(out, "未找到上传的分块数据,请重新上传");
uploadProgress.remove(uploadId);
return;
}
// 13. 创建安全的最终文件名(UUID重命名,防止路径遍历攻击和文件名重复)
String safeFileName = createSafeFileName(fileName);
String filePath = uploadDir + File.separator + safeFileName;
// 14. 合并分片(按索引顺序合并)
String tempDirPath = uploadTempDir + File.separator + uploadId;
File tempDir = new File(tempDirPath);
if (!tempDir.exists() || !tempDir.isDirectory()) {
sendError(out, "临时分块目录不存在,请重新上传");
uploadProgress.remove(uploadId);
return;
}
try {
OutputStream outStream = new FileOutputStream(filePath);
// 按分片索引顺序合并(从0开始)
for (int i = 0; i < chunks.size(); i++) {
if (!chunks.contains(i)) {
// 缺少分片,删除已创建的文件和临时目录,返回错误
Files.deleteIfExists(Paths.get(filePath));
deleteDirectory(tempDir);
sendError(out, "缺少分块 " + (i + 1) + ",上传失败,请重新上传");
return;
}
// 读取当前分片并写入最终文件
String chunkFilePath = tempDirPath + File.separator + i + ".part";
File chunkFile = new File(chunkFilePath);
try (InputStream in = new FileInputStream(chunkFile)) {
byte[] buffer = new byte[1024 * 1024];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
outStream.write(buffer, 0, bytesRead);
}
}
// 合并完成后删除当前分片,释放磁盘空间
chunkFile.delete();
}
// 15. 校验合并后的文件大小是否与原始大小一致(防止文件损坏)
File finalFile = new File(filePath);
if (finalFile.length() != fileSize) {
finalFile.delete();
sendError(out, "文件上传不完整,大小不匹配,请重新上传");
uploadProgress.remove(uploadId);
return;
}
// 16. 再次校验文件大小(防止合并过程中异常)
long maxFileSize = alloweFileSize.getOrDefault(transCode, alloweFileSize.get("default"));
if (finalFile.length() > maxFileSize) {
finalFile.delete();
sendError(out, "文件大小超出限制,最大允许上传" + (maxFileSize / 1024 / 1024) + "MB");
return;
}
// 17. 删除临时目录(合并完成,临时分片已删除)
tempDir.delete();
// 18. 清理进度记录和分片记录
uploadProgress.remove(uploadId);
uploadedChunks.remove(uploadId);
// 19. 保存文件信息到Session(供业务系统调用)
HttpSession session = request.getSession();
String operSign = (String) ((Map) session.getAttribute("session_model")).get("opersign");
// 构建Session key(区分业务编码和操作用户)
StringBuilder sessionKey = new StringBuilder();
sessionKey.append("asyncUploadList").append(transCode).append(operSign);
// 非续传场景,清除历史上传记录
if (!"01".equals(asyncUploadState)) {
session.removeAttribute(sessionKey.toString());
}
// 校验上传文件数量是否超出限制
List<?> fileList = (List<?>) session.getAttribute(sessionKey.toString());
if (fileList == null) {
fileList = new ArrayList<>();
}
if (fileList.size() >= MAX_FILE_COUNT) {
sendError(out, "文件上传个数超出限制,最多允许上传" + MAX_FILE_COUNT + "个文件");
return;
}
// 校验总文件大小是否超出限制
long totalFileSize = fileSize;
if (fileList.size() > 0) {
for (Object obj : fileList) {
Map<?, ?> fileMap = (Map<?, ?>) obj;
totalFileSize += Long.parseLong(fileMap.get("fileSize").toString());
if (totalFileSize > maxFileSize) {
sendError(out, "文件总大小超出限制,最大允许上传" + (maxFileSize / 1024 / 1024) + "MB");
return;
}
}
}
// 保存当前文件信息到Session
HashMap<String, String> fileMap = new HashMap<>();
fileMap.put("filePath", filePath);
fileMap.put("fileSize", String.valueOf(finalFile.length()));
fileMap.put("originalFileName", fileName);
fileMap.put("safeFileName", safeFileName);
((List) fileList).add(fileMap);
session.setAttribute(sessionKey.toString(), fileList);
// 20. 返回合并成功响应(携带安全文件名,供前端回调使用)
sendSuccess(out, "文件异步上传完成", safeFileName);
log.info("文件异步上传完成,文件名:" + safeFileName + ",路径:" + filePath);
} catch (IOException e) {
uploadProgress.remove(uploadId);
log.error("合并文件失败:", e);
sendError(out, "合并文件失败,请重新上传");
}
}
/**
* 处理取消上传
* @param request 请求对象
* @param out 响应输出流
*/
private void handleCancel(HttpServletRequest request, PrintWriter out) {
String uploadId = null;
try {
uploadId = URLDecoder.decode(String.valueOf(request.getParameter("uploadId")), "UTF-8");
log.info("handleCancel: uploadId=" + uploadId);
} catch (UnsupportedEncodingException e) {
sendError(out, "缺少uploadId参数,无法取消上传");
return;
}
if (uploadId == null) {
sendError(out, "缺少uploadId参数,无法取消上传");
return;
}
// 删除临时分片目录
String tempDirPath = uploadTempDir + File.separator + uploadId;
File tempDir = new File(tempDirPath);
deleteDirectory(tempDir);
// 清理进度记录和分片记录
uploadProgress.remove(uploadId);
uploadedChunks.remove(uploadId);
sendSuccess(out, "上传已取消", "");
}
/**
* 处理删除已上传文件
* @param request 请求对象
* @param out 响应输出流
*/
private void handleDelete(HttpServletRequest request, PrintWriter out) {
HttpSession session = request.getSession();
String transCode = request.getParameter("transCode");
String refileName = request.getParameter("refileName"); // 安全文件名(删除依据)
log.info("handleDelete: transCode=" + transCode + ", refileName=" + refileName);
// 校验参数
if (transCode == null || refileName == null) {
sendError(out, "缺少必要的参数(transCode、refileName)");
return;
}
// 获取操作用户标识,构建Session key
String operSign = (String) ((Map) session.getAttribute("session_model")).get("opersign");
StringBuilder sessionKey = new StringBuilder();
sessionKey.append("asyncUploadList").append(transCode).append(operSign);
// 从Session中获取已上传文件列表,删除对应文件
List<?> fileList = (List<?>) session.getAttribute(sessionKey.toString());
if (fileList != null && fileList.size() > 0) {
for (int i = 0; i < fileList.size(); i++) {
Map<?, ?> fileMap = (Map<?, ?>) fileList.get(i);
String filePath = (String) fileMap.get("filePath");
// 根据安全文件名匹配,删除对应文件
if (filePath.endsWith(refileName)) {
// 删除磁盘文件
File file = new File(filePath);
if (file.exists()) {
file.delete();
}
// 从Session列表中删除
((List) fileList).remove(i);
break;
}
}
// 更新Session中的文件列表
session.setAttribute(sessionKey.toString(), fileList);
}
sendSuccess(out, "文件删除成功", refileName);
}
/**
* 创建安全的文件名(UUID重命名,防止路径遍历攻击和文件名重复)
* @param originalFileName 原始文件名
* @return 安全文件名(UUID+扩展名)
*/
private String createSafeFileName(String originalFileName) {
// 获取文件扩展名(如.jpg、.pdf)
String extension = "";
int dotIndex = originalFileName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < originalFileName.length() - 1) {
extension = originalFileName.substring(dotIndex);
}
// 生成UUID,拼接扩展名,确保文件名唯一
return UUID.randomUUID().toString() + extension;
}
/**
* 根据文件名获取文件MIME类型(简单判断,可扩展为更精确的方式)
* @param fileName 文件名
* @return 文件MIME类型
*/
private String getFileType(String fileName) {
if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) {
return "image/jpeg";
} else if (fileName.endsWith(".png")) {
return "image/png";
} else if (fileName.endsWith(".gif")) {
return "image/gif";
} else if (fileName.endsWith(".bmp")) {
return "image/bmp";
} else if (fileName.endsWith(".pdf")) {
return "application/pdf";
} else if (fileName.endsWith(".doc")) {
return "application/msword";
} else if (fileName.endsWith(".docx")) {
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
} else if (fileName.endsWith(".xls")) {
return "application/vnd.ms-excel";
} else if (fileName.endsWith(".xlsx")) {
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
} else if (fileName.endsWith(".zip")) {
return "application/zip";
}
// 未知类型,返回原始文件名(后续可扩展校验)
return fileName;
}
/**
* 递归删除目录及其内容(包括子目录和文件)
* @param dir 要删除的目录
* @return 删除结果(true:成功,false:失败)
*/
private boolean deleteDirectory(File dir) {
if (dir.isDirectory()) {
File[] children = dir.listFiles();
if (children != null) {
for (File child : children) {
deleteDirectory(child);
}
}
}
// 删除目录(空目录或文件)
return dir.delete();
}
/**
* 发送成功响应(JSON格式)
* @param out 响应输出流
* @param message 成功消息
* @param reFileName 安全文件名(可选)
*/
private void sendSuccess(PrintWriter out, String message, String reFileName) {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", message);
result.put("reFileName", reFileName);
out.write(toJson(result));
log.info("上传成功:" + message);
}
/**
* 发送错误响应(JSON格式)
* @param out 响应输出流
* @param message 错误消息
*/
private void sendError(PrintWriter out, String message) {
Map<String, Object> result = new HashMap<>();
result.put("success", false);
result.put("message", message);
out.write(toJson(result));
log.error("上传错误:" + message);
}
/**
* 将Map转换为JSON字符串(静态方法,供进度查询Servlet调用)
* @param result 要转换的Map
* @return JSON字符串
*/
public static String toJson(Map<String, Object> result) {
try {
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
log.error("JSON序列化失败:", e);
}
// 序列化失败时返回默认错误响应
return "{\"success\":false,\"message\":\"JSON序列化失败\"}";
}
}
4.3.2 AsyncUploadProgressServlet.java(完整版)
java
package com.web;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @description: 为IE浏览器提供上传进度查询功能(IE不支持原生上传进度监听)
* @author: liuyongheng
* @date: 2025-09-19 08:44:30
*/
public class AsyncUploadProgressServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 设置响应格式,避免中文乱码
response.setContentType("text/plain;charset=UTF-8");
PrintWriter out = response.getWriter();
// 获取上传唯一标识(uploadId)
String uploadId = request.getParameter("uploadId");
if (uploadId == null) {
sendError(out, "缺少uploadId参数,无法查询进度");
return;
}
// 从AsyncUploadServlet中获取上传进度
Float progress = AsyncUploadServlet.uploadProgress.get(uploadId);
if (progress == null) {
sendError(out, "未找到上传进度信息,请确认uploadId是否正确");
} else {
// 返回进度信息(百分比)
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("progress", progress);
out.write(AsyncUploadServlet.toJson(result));
}
}
/**
* 发送错误响应(适配IE浏览器,返回进度为0)
* @param out 响应输出流
* @param message 错误消息
*/
private void sendError(PrintWriter out, String message) {
Map<String, Object> result = new HashMap<>();
result.put("success", false);
result.put("message", message);
result.put("progress", 0); // IE浏览器需要进度字段,避免解析异常
out.write(AsyncUploadServlet.toJson(result));
}
}
4.4 前端代码(asyncUpload.jsp,修复IE兼容BUG)
jsp
<%@ page language="java" pageEncoding="UTF-8" contentType="text/html;charset=utf-8" %>
<style>
.asyncUploadContainer { max-width: 800px; margin: 0 auto; }
.upload-area { border: 2px dashed #ccc; padding: 40px; text-align: center; margin-bottom: 20px; cursor: pointer; }
.upload-area:hover { border-color: #666; }
.progress-container { width: 100%; height: 20px; border: 1px solid #ccc; border-radius: 10px; overflow: hidden; margin: 10px 0; }
.asyncUploadProgress-bar { height: 100%; background-color: #ff5263; width: 0%; }
.file-info { margin: 10px 0; padding: 10px; border: 1px solid #eee;}
.status { color: #666; margin: 10px 0; word-break:break-all;}
.asyncBtn { padding: 10px 20px; background-color: #f44336; color: white; border: none; border-radius: 5px; cursor: pointer; }
.asyncbtn:hover { background-color: #45a049; }
.hidden { display: none; }
.upload-btn { width: 100%;text-align: center; }
.ie-notice { color: #f00; padding: 10px; background-color: #fff3cd; border: 1px solid #ffeeba; margin-bottom: 20px; }
</style>
<div class="asyncUploadContainer" style="display: none;" id="asyncUploadContainer">
<!-- IE浏览器提示 -->
<!--[if IE]>
<div class="ie-notice">
您正在使用IE浏览器,部分功能可能受限。建议使用现代浏览器以获得最佳体验。
</div>
<![endif]-->
<div class="upload-area" id="uploadArea">
<p>点击这里上传</p>
<input type="file" id="fileInput" class="hidden" />
</div>
<div id="fileInfo" class="file-info" style="display: none;">
<p>文件名: <span id="fileName"></span></p>
<p>大小: <span id="fileSize"></span></p>
<div class="progress-container">
<div class="asyncUploadProgress-bar" id="progressBar"></div>
</div>
<p class="status" id="uploadStatus">等待上传...</p>
<div class="upload-btn">
<button class="asyncBtn" id="confirmUpload">确认</button>
<button class="asyncBtn" id="cancelUpload" style="background-color: #f44336; margin-left: 10px;" disabled>取消</button>
</div>
</div>
</div>
<script>
// 全局变量
var file = null;
var uploadId = null;
var isUploading = false;
var progressInterval = null;
var chunkSize = 20 * 1024 * 1024; // 20MB每块,根据测试情况灵活修改
var totalChunks = 0;
var uploadedChunks = 0;
var pendingChunks = [];
var activeUploads = 0;
var MAX_PARALLEL_UPLOADS = 3; // 最大并行上传数
// DOM元素
var uploadArea = null;
var fileInput = null;
var fileInfo = null;
var fileName = null;
var fileSize = null;
var progressBar = null;
var uploadStatus = null;
var confirmUpload =null;
var cancelUploadBtn =null;
// 检测浏览器是否为IE
var isIE = null;
var transCode = null;
var uploadAborted = false;
//初始化参数
function asyncUploadInit (transcode){
uploadArea = $(".fy-alert-content #uploadArea").get(0);
fileInput = $(".fy-alert-content #fileInput").get(0);
fileInfo = $(".fy-alert-content #fileInfo").get(0);
fileName = $(".fy-alert-content #fileName").get(0);
fileSize = $(".fy-alert-content #fileSize").get(0);
progressBar = $(".fy-alert-content #progressBar").get(0);
uploadStatus = $(".fy-alert-content #uploadStatus").get(0);
confirmUpload = $(".fy-alert-content #confirmUpload").get(0);
cancelUploadBtn = $(".fy-alert-content #cancelUpload").get(0);
transCode = transcode;
isIE = /*@cc_on!@*/false || !!document.documentMode;
// 事件监听
if (uploadArea.addEventListener) {
uploadArea.addEventListener('click', function() {
console.log('Click event triggered');
fileInput.click();
});
} else if (uploadArea.attachEvent) {
uploadArea.attachEvent('onclick', function() {
console.log('Click event triggered');
fileInput.click();
});
}
if (fileInput.addEventListener) {
fileInput.addEventListener('change', handleFileSelect);
} else if (fileInput.attachEvent) {
fileInput.attachEvent('onchange', handleFileSelect);
}
if (confirmUpload.addEventListener) {
confirmUpload.addEventListener('click', closeWindow);
} else if (confirmUpload.attachEvent) {
confirmUpload.attachEvent('onclick', closeWindow);
}
if (cancelUploadBtn.addEventListener) {
cancelUploadBtn.addEventListener('click', cancelUpload);
} else if (cancelUploadBtn.attachEvent) {
cancelUploadBtn.attachEvent('onclick', cancelUpload);
}
}
// 处理文件选择
function handleFileSelect(e) {
if (e.target && e.target.files && e.target.files.length > 0) {
handleFile(e.target.files[0]);
} else if (e.srcElement && e.srcElement.value) {
// 处理IE低版本浏览器的文件选择
handleFileIE(e.srcElement.value);
}
cancelUploadBtn.disabled = false;
uploadAborted = false;
if(!validateFileName(e.target.files[0].name)){
if(typeof mesgAlert === 'function'){
mesgAlert("文件名称不合法");
} else {
alert("文件名称不合法");
}
return;
}
startUpload();
}
//处理IE低版本浏览器的文件选择
function handleFileIE(filePath) {
try {
// 这里可以添加处理 IE文件选择的逻辑,例如,使用 ActiveXObject 读取文件内容
var fileSystem = new ActiveXObject("Scripting.FileSystemObject");
var file = fileSystem.GetFile(filePath);
var fileContent = file.OpenAsTextStream(1, 0).ReadAll();
// 调用 handleFile 函数处理文件内容
handleFile(fileContent);
} catch (e) {
alert("无法读取文件,请检查浏览器的安全设置。",e);
}
}
// 处理文件信息
function handleFile(selectedFile) {
file = selectedFile;
// 显示文件信息
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
fileInfo.style.display="block";
// 生成上传ID
uploadId = generateUploadId(file);
}
// 生成上传ID (基于文件名、大小和当前时间)
function generateUploadId(file) {
var time = new Date().getTime();
// IE不支持File API的某些属性,做兼容处理
var name = file.name || 'unknown';
var size = file.size || 0;
return 'upload_' + name + '_' + size + '_' + time +'_' + transCode;
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
var k = 1024;
var sizes = ['Bytes', 'KB', 'MB', 'GB'];
var i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 开始上传
function startUpload() {
if (!file || isUploading) return;
isUploading = true;
cancelUploadBtn.disabled = false;
uploadStatus.textContent = '准备上传...';
//debugger;
// 计算分块数量
totalChunks = Math.ceil(file.size / chunkSize);
uploadedChunks = 0;
pendingChunks = [];
// 对于IE浏览器,使用轮询方式获取进度
if (isIE) {
progressInterval = setInterval(checkUploadProgress, 1000);
}
for (var i = 0; i < totalChunks; i++) {
pendingChunks.push(i);
}
// 开始上传分块
startChunkUploads();
}
function startChunkUploads(){
if(!uploadAborted) {
while (activeUploads < MAX_PARALLEL_UPLOADS && pendingChunks.length > 0) {
var chunkIndex = pendingChunks.shift();
uploadNextChunk(chunkIndex);
activeUploads++;
}
}
}
// 上传下一个分块
function uploadNextChunk(chunkIndex) {
var start = chunkIndex * chunkSize;
var end = Math.min(start + chunkSize, file.size);
var chunk = getFileChunk(file, start, end);
uploadStatus.textContent = '上传中: ' + (chunkIndex + 1) + '/' + totalChunks;
// 创建表单数据
var formData = createFormData(uploadId, file, chunk, chunkIndex, totalChunks, start, end);
// 上传分块
uploadChunk(formData);
}
// 获取文件分块 - 兼容IE
function getFileChunk(file, start, end) {
// 对于IE,使用FileReader或ActiveXObject
if (false) {//isIE && window.ActiveXObject
// IE的特殊处理
try {
var fileStream = new ActiveXObject("ADODB.Stream");
// var fileSystem = new ActiveXObject("Scripting.FileSystemObject");
if (!fileSystem.FileExists(file.path)) {
throw new Error("文件不存在");
}
fileStream.Type = 1; // Binary
fileStream.Open();
fileStream.LoadFromFile(file.path);
fileStream.Position = start;
var chunkData = fileStream.Read(end - start);
fileStream.Close();
return chunkData;
} catch (e) {
alert("IE浏览器上传需要启用ActiveX控件: " + e.message);
cancelUpload();
return null;
}
} else if (file.slice) {//IE11走这个
return file.slice(start, end);
} else if (file.mozSlice) {
return file.mozSlice(start, end);
} else if (file.webkitSlice) {
return file.webkitSlice(start, end);
}
return null;
}
// 创建表单数据
function createFormData(uploadId, file, chunk, chunkIndex, totalChunks, start, end) {
var formData = new FormData();
formData.append('uploadId', encodeURIComponent(uploadId));
formData.append('fileName', encodeURIComponent(file.name));
formData.append('fileSize', file.size);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
formData.append('start', start);
formData.append('end', end);
formData.append('chunkData', chunk);
formData.append('transCode', transCode);
return formData;
}
// 上传分块
function uploadChunk(formData) {
if (uploadAborted) return;
// 创建XMLHttpRequest,兼容IE
var xhr = createXMLHttpRequest();
// 非IE浏览器可以监听progress事件
if (!isIE) {
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
var chunkProgress = e.loaded / e.total;
var overallProgress = (uploadedChunks + chunkProgress) / totalChunks * 100;
updateProgress(overallProgress);
}
});
}
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
activeUploads--;
if (uploadAborted) return;
if (xhr.status === 200) {
try {
// 处理IE的JSON解析问题
var response = isIE ? eval('(' + xhr.responseText + ')') : JSON.parse(xhr.responseText);
if (response.success) {
uploadedChunks++;
// 更新进度(IE通过轮询更新,这里只更新非IE)
if (!isIE) {
var progress = (uploadedChunks / totalChunks) * 100;
updateProgress(progress);
}
//uploadNextChunk();
if(uploadedChunks === totalChunks ){
completeUpload();
} else {
startChunkUploads();
}
} else {
cancelUpload();
uploadStatus.textContent = '上传失败: ' + (response.message || '未知错误');
}
} catch (e) {
cancelUpload();
uploadStatus.textContent = '解析响应失败: ' + e.message;
}
} else {
cancelUpload();
uploadStatus.textContent = '上传失败,HTTP状态: ' + xhr.status;
}
}
};
xhr.open('POST', '/asyncUpload', true);
xhr.send(formData);
}
// 完成上传,通知服务器合并文件
function completeUpload() {
var xhr = createXMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
clearInterval(progressInterval);
if (xhr.status === 200) {
try {
var response = isIE ? eval('(' + xhr.responseText + ')') : JSON.parse(xhr.responseText);
if (response.success) {
response.fileName = file.name;
updateProgress(100);
uploadStatus.textContent = '上传完成!' ;
asyncUploadCallback(response);
} else {
uploadStatus.textContent = '合并文件失败: ' + (response.message || '未知错误');
}
} catch (e) {
uploadStatus.textContent = '解析响应失败: ' + e.message;
}
} else {
uploadStatus.textContent = '合并文件失败,HTTP状态: ' + xhr.status;
}
isUploading = false;
cancelUploadBtn.disabled = true;
}
};
xhr.open('POST', '/asyncUpload?action=complete', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.send('uploadId=' + encodeURIComponent(uploadId) +
'&fileName=' + encodeURIComponent(file.name) +
'&fileSize=' + file.size +
'&transCode=' + transCode +
'&asyncUploadState=' + getAsyncUploadState());
}
// 取消上传
function cancelUpload() {
isUploading = false;
uploadAborted = true;
clearInterval(progressInterval);
// 通知服务器取消上传
var xhr = createXMLHttpRequest();
xhr.open('POST', '/asyncUpload?action=cancel', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.send('uploadId=' + encodeURIComponent(uploadId));
uploadStatus.textContent = '上传已取消';
cancelUploadBtn.disabled = true;
}
// 删除上传
function deleteUpload(refileName) {
var xhr = createXMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
try {
var response = isIE ? eval('(' + xhr.responseText + ')') : JSON.parse(xhr.responseText);
if (response.success) {
deleteUploadCallBack(response)
} else {
uploadStatus.textContent = '删除文件失败: ' + (response.message || '未知错误');
}
} catch (e) {
uploadStatus.textContent = '解析响应失败: ' + e.message;
}
} else {
uploadStatus.textContent = '删除文件失败,HTTP状态: ' + xhr.status;
}
}
};
xhr.open('POST', '/asyncUpload?action=delete', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.send('transCode=' + transCode +'&refileName=' +refileName);
}
// 检查上传进度(IE专用)
function checkUploadProgress() {
if (!isUploading) return;
var xhr = createXMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
var response = isIE ? eval('(' + xhr.responseText + ')') : JSON.parse(xhr.responseText);
if (response.progress !== undefined) {
updateProgress(response.progress);
}
} catch (e) {
console.log('获取进度失败: ' + e.message);
}
}
};
xhr.open('GET', '/asyncUploadProgress?uploadId=' + encodeURIComponent(uploadId)+'&tmp='+Math.random(), true);
xhr.send();
}
// 更新进度条
function updateProgress(percent) {
percent = Math.min(100, Math.max(0, percent));
progressBar.style.width = percent + '%';
uploadStatus.textContent = '上传中: ' + Math.round(percent) + '%';
}
// 创建XMLHttpRequest对象,兼容IE
function createXMLHttpRequest() {
if (window.XMLHttpRequest) {
return new XMLHttpRequest();
} else {
return new ActiveXObject('Microsoft.XMLHTTP');;
}
}
function validateFileName(fileName) {
// 定义允许的文件名字符集(字母、数字、下划线、连字符、点、汉字)
var allowedPattern = /^[\u4e00-\u9fa5a-zA-Z0-9_.\-]+$/;
// 检查文件名是否符合允许的字符集
if (!allowedPattern.test(fileName)) {
return false; // 文件名含有特殊字符
}
return true; // 文件名合法
}
// 点击确定按钮关闭弹窗
function closeWindow(){
$(".fy-alert-shadow").hide();
$(".fy-alert-box").hide();
$(".fy-alert-box").remove();
}
</script>