doc_wei/erp-pro:文件上传下载功能深度解析与实践指南
概述
在企业级应用开发中,文件上传下载是必不可少的基础功能。doc_wei/erp-pro项目基于Spring Boot + UNI-APP + Ant Design Vue技术栈,提供了一套完整、高效、安全的文件上传下载解决方案。本文将深入解析该项目的文件处理机制,并提供详细的实践指南。
核心架构设计
文件上传下载模块架构
核心组件说明
| 组件名称 | 职责描述 | 关键技术 |
|---|---|---|
| UploadServiceImpl | 文件上传下载核心服务 | Spring MVC, MultipartFile |
| FileService | 文件元数据管理 | JPA, 数据库操作 |
| FileConstants | 文件路径常量定义 | 枚举类型, 配置管理 |
| FileUtil | 文件操作工具类 | IO操作, 路径处理 |
文件上传功能详解
1. 普通文件上传
@Override
public void uploadFile(InputObject inputObject, OutputObject outputObject) {
Map<String, Object> map = inputObject.getParams();
int type = Integer.parseInt(map.get("type").toString());
String basePath = tPath + FileConstants.FileUploadPath.getSavePath(type);
// 检查上传文件
MultipartFile file = checkUploadFile();
String fileName = file.getOriginalFilename();
String fileExtName = fileName.substring(fileName.lastIndexOf(".") + 1);
String newFileName = String.format(Locale.ROOT, "%s.%s", System.currentTimeMillis(), fileExtName);
String path = basePath + "/" + newFileName;
FileUtil.createDirs(basePath);
// 上传文件
try {
file.transferTo(new File(path));
} catch (IOException ex) {
throw new CustomException(ex);
}
newFileName = FileConstants.FileUploadPath.getVisitPath(type) + newFileName;
saveFile(file, newFileName, type);
Map<String, Object> bean = new HashMap<>();
bean.put("picUrl", newFileName);
bean.put("type", type);
bean.put("fileName", fileName);
outputObject.setBean(bean);
}
2. Base64格式上传
支持Base64编码的图片上传,特别适合移动端和小程序场景:
@Override
public void uploadFileBase64(InputObject inputObject, OutputObject outputObject) {
Map<String, Object> map = inputObject.getParams();
int type = Integer.parseInt(map.get("type").toString());
String imgStr = map.get("images").toString();
String[] d = imgStr.split("base64,");
if (d != null && d.length == 2) {
String dataPrix = d[0];
String data = d[1];
if (FileUtil.checkBase64IsImage(dataPrix)) {
byte[] bytes = Base64.decodeBase64(data.getBytes());
String basePath = tPath + FileConstants.FileUploadPath.getSavePath(type);
FileUtil.createDirs(basePath);
String trueFileName = System.currentTimeMillis() + "." +
FileUtil.getBase64FileTypeByPrix(dataPrix);
FileUtil.writeByteToPointPath(bytes, basePath + "/" + trueFileName);
Map<String, Object> bean = new HashMap<>();
bean.put("picUrl", FileConstants.FileUploadPath.getVisitPath(type) + trueFileName);
bean.put("type", type);
outputObject.setBean(bean);
}
}
}
3. 分片上传与断点续传
对于大文件上传,项目支持分片上传和断点续传功能:
@Override
public void uploadFileChunks(InputObject inputObject, OutputObject outputObject) {
UploadChunks uploadChunks = inputObject.getParams(UploadChunks.class);
String cacheKey = getCacheKey(uploadChunks.getMd5());
List<Upload> beans = redisCache.getList(cacheKey, key -> new ArrayList<>(),
RedisConstants.ONE_DAY_SECONDS, Upload.class);
List<File> fileList = new ArrayList<>();
for (Upload bean : beans) {
File f = new File(tPath.replace("images", "") + bean.getFileAddress());
fileList.add(f);
}
String userId = inputObject.getLogParams().get("id").toString();
String fileName = uploadChunks.getName();
String fileExtName = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
String newFileName = String.format(Locale.ROOT, "%s.%s", System.currentTimeMillis(), fileExtName);
String path = tPath + FileConstants.FileUploadPath.getSavePath(uploadChunks.getType(), userId)
+ CommonCharConstants.SLASH_MARK + newFileName;
// 合并文件分片
FileChannel outChnnel = null;
try {
File outputFile = new File(path);
outputFile.createNewFile();
outChnnel = new FileOutputStream(outputFile).getChannel();
FileChannel inChannel;
for (File file : fileList) {
inChannel = new FileInputStream(file).getChannel();
inChannel.transferTo(0, inChannel.size(), outChnnel);
inChannel.close();
file.delete(); // 删除分片
}
} catch (Exception e) {
throw new CustomException(e);
} finally {
FileUtil.close(outChnnel);
}
jedisClient.del(cacheKey);
newFileName = FileConstants.FileUploadPath.getVisitPath(uploadChunks.getType(), userId) + newFileName;
uploadChunks.setFileAddress(newFileName);
outputObject.setBean(uploadChunks);
}
文件下载功能
1. 普通文件下载
@Override
public void getFileContent(HttpServletRequest request, HttpServletResponse response, String configId) {
String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false);
if (StrUtil.isEmpty(path)) {
throw new IllegalArgumentException("结尾的 path 路径必须传递");
}
path = URLUtil.decode(path); // 解码中文路径
FileClient client = fileConfigService.getFileClient(configId);
Assert.notNull(client, "客户端({}) 不能为空", configId);
try {
byte[] content = client.getContent(path);
if (content == null) {
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
FileUtil.writeAttachment(response, path, content);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
2. 预签名URL下载
支持生成临时访问链接,提高安全性:
@Override
public void getFilePresignedUrl(InputObject inputObject, OutputObject outputObject) {
String path = inputObject.getParams().get("path").toString();
FileClient fileClient = fileConfigService.getMasterFileClient();
try {
FilePresignedUrlRespDTO presignedObjectUrl = fileClient.getPresignedObjectUrl(path);
presignedObjectUrl.setConfigId(fileClient.getId());
outputObject.setBean(presignedObjectUrl);
outputObject.settotal(CommonNumConstants.NUM_ONE);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
文件管理功能
文件删除
@Override
public void deleteFileByPath(InputObject inputObject, OutputObject outputObject) {
String path = inputObject.getParams().get("path").toString();
com.skyeye.upload.entity.File file = fileService.queryByPath(path);
if (file == null) {
throw new CustomException("文件不存在");
}
// 从文件存储器中删除
FileClient client = fileConfigService.getFileClient(file.getConfigId());
Assert.notNull(client, "客户端({}) 不能为空", file.getConfigId());
try {
client.delete(file.getPath());
} catch (Exception e) {
throw new RuntimeException(e);
}
// 删除记录
fileService.deleteById(file.getId());
}
高级功能:Markdown压缩包解析
项目支持上传包含图片的Markdown压缩包,并自动解析图片链接:
@Override
public void markdownZipUploadAndParse(InputObject inputObject, OutputObject outputObject) {
Map<String, Object> map = inputObject.getParams();
MultipartFile zipFilePart = checkUploadFile();
String originalZipName = zipFilePart.getOriginalFilename();
if (!originalZipName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
throw new CustomException("仅支持zip格式");
}
int type = Integer.parseInt(map.get("type").toString());
String tempZipPath = null;
String unzipDir = null;
try {
String basePath = tPath + FileConstants.FileUploadPath.getSavePath(type);
FileUtil.createDirs(basePath);
tempZipPath = basePath + CommonCharConstants.SLASH_MARK + UUID.randomUUID() + ".zip";
zipFilePart.transferTo(new File(tempZipPath));
unzipDir = basePath + CommonCharConstants.SLASH_MARK + UUID.randomUUID();
FileUtil.createDirs(unzipDir);
unzip(tempZipPath, unzipDir);
List<Map<String, Object>> resultList = processMarkdownDirectory(unzipDir, type);
outputObject.setBeans(resultList);
outputObject.settotal(resultList.size());
} catch (IOException e) {
throw new CustomException(e);
} finally {
// 清理临时文件
if (StrUtil.isNotEmpty(tempZipPath)) {
FileUtil.deleteFile(tempZipPath);
}
if (StrUtil.isNotEmpty(unzipDir)) {
try {
FileUtils.forceDelete(new File(unzipDir));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
安全特性
1. 文件类型验证
private MultipartFile checkUploadFile() {
CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver(
PutObject.getRequest().getSession().getServletContext());
if (!multipartResolver.isMultipart(PutObject.getRequest())) {
throw new CustomException("请求必须为multipart/form-data");
}
MultipartHttpServletRequest multiRequest = (MultipartHttpServletRequest) PutObject.getRequest();
Iterator iter = multiRequest.getFileNames();
if (!iter.hasNext()) {
throw new CustomException("未选择文件");
}
MultipartFile zipFilePart = multiRequest.getFile(iter.next().toString());
if (zipFilePart == null || zipFilePart.isEmpty()) {
throw new CustomException("上传文件为空");
}
return zipFilePart;
}
2. 路径安全处理
private Path resolveZipEntry(Path destDir, String entryName) throws IOException {
String safeEntryName = sanitizeFileName(entryName);
Path normalizedPath = destDir.resolve(safeEntryName).normalize();
if (!normalizedPath.startsWith(destDir)) {
throw new IOException("不安全的zip条目: " + safeEntryName);
}
return normalizedPath;
}
private String sanitizeFileName(String fileName) {
if (fileName == null || fileName.trim().isEmpty()) {
return "unnamed_file";
}
return fileName
.replaceAll("[<>:\"/\\\\|?*]", CommonCharConstants.SLASH_MARK)
.replaceAll("\\s+", CommonCharConstants.SLASH_MARK)
.trim();
}
性能优化策略
1. 内存管理
使用FileChannel进行大文件操作,避免内存溢出:
FileChannel outChnnel = null;
try {
File outputFile = new File(path);
outputFile.createNewFile();
outChnnel = new FileOutputStream(outputFile).getChannel();
FileChannel inChannel;
for (File file : fileList) {
inChannel = new FileInputStream(file).getChannel();
inChannel.transferTo(0, inChannel.size(), outChnnel);
inChannel.close();
file.delete();
}
} finally {
FileUtil.close(outChnnel);
}
2. 缓存策略
利用Redis缓存分片上传信息,支持断点续传:
private String getCacheKey(String md5) {
return String.format(Locale.ROOT, "upload:file:chunks:%s", md5);
}
// 存储分片信息
String cacheKey = getCacheKey(upload.getMd5());
List<Upload> beans = redisCache.getList(cacheKey, key -> new ArrayList<>(),
RedisConstants.ONE_DAY_SECONDS, Upload.class);
beans.add(upload);
jedisClient.set(cacheKey, JSONUtil.toJsonStr(beans));
实践指南
1. 前端调用示例
// 普通文件上传
const formData = new FormData();
formData.append('file', file);
formData.append('type', 1); // 文件类型
const response = await axios.post('/api/upload/file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
// 分片上传
const chunkSize = 2 * 1024 * 1024; // 2MB
const totalChunks = Math.ceil(file.size / chunkSize);
for (let chunk = 0; chunk < totalChunks; chunk++) {
const start = chunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunkData = file.slice(start, end);
const chunkFormData = new FormData();
chunkFormData.append('file', chunkData);
chunkFormData.append('chunk', chunk);
chunkFormData.append('totalChunks', totalChunks);
chunkFormData.append('md5', fileMd5);
await axios.post('/api/upload/chunk', chunkFormData);
}
2. 配置说明
在application.yml中配置文件存储路径:
# 文件存储配置
IMAGES_PATH: /data/uploads/images
# Redis配置(用于分片上传)
spring:
redis:
host: localhost
port: 6379
database: 0
3. 错误处理最佳实践
try {
// 文件操作代码
file.transferTo(new File(path));
} catch (IOException ex) {
LOGGER.error("文件上传失败: {}", ex.getMessage(), ex);
throw new CustomException("文件上传失败,请重试");
} finally {
// 资源清理
FileUtil.close(outChnnel);
}
总结
doc_wei/erp-pro项目的文件上传下载功能具有以下特点:
- 功能全面:支持普通上传、Base64上传、分片上传、断点续传等多种方式
- 安全可靠:包含完整的文件类型验证、路径安全处理机制
- 高性能:采用FileChannel进行大文件操作,支持内存优化
- 扩展性强:支持多种存储后端,易于集成云存储服务
- 用户体验好:提供进度显示、错误重试等友好功能
通过本文的详细解析,开发者可以快速掌握该项目的文件处理机制,并在实际项目中灵活应用。无论是简单的图片上传还是复杂的大文件分片传输,都能找到合适的解决方案。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



