doc_wei/erp-pro:文件上传下载功能深度解析与实践指南

doc_wei/erp-pro:文件上传下载功能深度解析与实践指南

【免费下载链接】erp-pro Skyeye云源代码,采用Springboot + UNI-APP + Ant Design Vue的框架的低代码快速开发平台。包含50多种电子流程,CRM、PM、ERP、MES、ADM、OA、EHR、AI、项目、商城、财务、多班次考勤、薪资、招聘、云售后、论坛、问卷、报表设计、工作流、Saas等功能。打造全网首套低代码、功能最全、价格最优惠的智能制造行业供应链一体化管理软件。 【免费下载链接】erp-pro 项目地址: https://gitcode.com/doc_wei/erp-pro

概述

在企业级应用开发中,文件上传下载是必不可少的基础功能。doc_wei/erp-pro项目基于Spring Boot + UNI-APP + Ant Design Vue技术栈,提供了一套完整、高效、安全的文件上传下载解决方案。本文将深入解析该项目的文件处理机制,并提供详细的实践指南。

核心架构设计

文件上传下载模块架构

mermaid

核心组件说明

组件名称职责描述关键技术
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项目的文件上传下载功能具有以下特点:

  1. 功能全面:支持普通上传、Base64上传、分片上传、断点续传等多种方式
  2. 安全可靠:包含完整的文件类型验证、路径安全处理机制
  3. 高性能:采用FileChannel进行大文件操作,支持内存优化
  4. 扩展性强:支持多种存储后端,易于集成云存储服务
  5. 用户体验好:提供进度显示、错误重试等友好功能

通过本文的详细解析,开发者可以快速掌握该项目的文件处理机制,并在实际项目中灵活应用。无论是简单的图片上传还是复杂的大文件分片传输,都能找到合适的解决方案。

【免费下载链接】erp-pro Skyeye云源代码,采用Springboot + UNI-APP + Ant Design Vue的框架的低代码快速开发平台。包含50多种电子流程,CRM、PM、ERP、MES、ADM、OA、EHR、AI、项目、商城、财务、多班次考勤、薪资、招聘、云售后、论坛、问卷、报表设计、工作流、Saas等功能。打造全网首套低代码、功能最全、价格最优惠的智能制造行业供应链一体化管理软件。 【免费下载链接】erp-pro 项目地址: https://gitcode.com/doc_wei/erp-pro

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值