使用Minio构建文件服务

1. Minio服务部署

Minio安装目录:/usr/local/minio

Minio数据存储目录:/usr/local/minio/data/minio

Minio服务启动脚本:

./minio server --address :9966 --console-address :9967 /usr/local/minio/data/minio
  • 9966为Minio服务api端口
  • 9967为Minio服务平台端口

Minio管理平台地址:http://172.40.240.162:9967/buckets,用户名和密码为:minio/minio123

2. 集成Minio服务说明

2.1 文件Md5值计算

每个文件都要生成一个Md5值,用于大文件分片后合并,文件对比。

/**
* 分块计算文件的md5值
* @param file 文件
* @param chunkSize 分片大小
* @returns Promise
*/
function calculateFileMd5(file, chunkSize) {
    
    
    return new Promise((resolve, reject) => {
    
    
        let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
        let chunks = Math.ceil(file.size / chunkSize);
        let currentChunk = 0;
        let spark = new SparkMD5.ArrayBuffer();
        let fileReader = new FileReader();

        fileReader.onload = function (e) {
    
    
            spark.append(e.target.result);
            currentChunk++;
            if (currentChunk < chunks) {
    
    
                loadNext();
            } else {
    
    
                let md5 = spark.end();
                console.log("...md5...",md5)
                resolve(md5);
            }
        };

        fileReader.onerror = function (e) {
    
    
            reject(e);
        };

        function loadNext() {
    
    
            let start = currentChunk * chunkSize;
            let end = start + chunkSize;
            if (end > file.size) {
    
    
                end = file.size;
            }
            fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
        }

        loadNext();
    });
}

SparkMD5.js文件私下给出

2.2 上传文件命名

  • 单文件上传,文件命名为:fileId + 文件后缀名。eg:/test/44fd7a29130511ed9fe5005056b2b395.jpg
  • 大文件上传,分片文件命名:文件md5值/分片索引。eg:/test/19d66e11606ef41bd6e447156f572969/1
  • 服务端大文件合并后,文件命名同单文件。

test为bucket名

单文件测试页面:http://localhost:19081/portal-osp/osp-file/client/file/home/upload

大文件测试页面:http://localhost:19081/portal-osp/osp-file/client/file/home/chunk/upload

2.3 缩略图

对于图片资源,自动生成一张0.25倍的缩略图,名称为:fileId_thumb.后缀名。eg:

  • 原文件名称为:44fd7a29130511ed9fe5005056b2b395.jpg
  • 缩略图名称为:44fd7a29130511ed9fe5005056b2b395_thumb.jpg

2.4 大文件分片

分片文件最小为5M,Minio规定如果需要进行合并文件操作,每个分片文件最小为5M。

本着谁分片谁合并原则,前端和后台都可以进行文件合并操作,如果在服务端进行大文件合并,影响服务资源,但提供文件合并接口。

前端文件分片代码:

/**
 * 执行分片上传
 * @param file 上传的文件
 * @param i 第几分片,从0开始
 * @param md5 文件的md5值
 */
function PostFile(file, i, md5) {
    
    
    resultDiv.innerHTML += '上传文件,当前分片为:' + i + '<br/>'
    let name = file.name,                           // 文件名
        size = file.size,                           // 总大小
        segSize = 4 * 1024 * 1024,                // 以5MB为一个分片,每个分片的大小
        segTotal = Math.ceil(size / segSize);   //总片数
    if (i >= segTotal) {
    
    
        return;
    }

    let start = i * segSize;
    let end = start + segSize;
    let packet = file.slice(start, end);  //将文件进行切片
    /*  构建form表单进行提交  */
    let form = new FormData();
    form.append("md5", md5);// 前端生成uuid作为标识符传个后台每个文件都是一个uuid防止文件串了
    form.append("file", packet); //slice方法用于切出文件的一部分
    form.append("name", name);
    form.append("fileSize", size);
    form.append("segTotal", segTotal); //总片数
    form.append("segCurrent", i + 1); //当前是第几片
    $.ajax({
    
    
        url: baseUrl + "/client/file/chunk/upload",
        type: "POST",
        data: form,
        //timeout:"10000",  //超时10秒
        async: true, //异步
        dataType: "json",
        processData: false, //很重要,告诉jquery不要对form进行处理
        contentType: false, //很重要,指定为false才能形成正确的Content-Type
        success: function (msg) {
    
    
            console.log(msg);
            /*  表示上一块文件上传成功,继续下一次  */
            if (msg.status === 20001) {
    
    
                form = '';
                i++;
                PostFile(file, i, md5);
            } else if (msg.status === 50000) {
    
    
                form = '';
                resultDiv.innerHTML += '请求状态码:' + msg.status + ',' + msg.message + '<br/>'
                // setInterval(function () {
    
    
                //     PostFile(file, i, md5)
                // }, 2000);
            } else if (msg.status === 20002) {
    
    
                // merge(segTotal, name, md5, getFileType(file.name), file.size)
                console.log("上传成功");
                resultDiv.innerHTML += '请求状态码:' + msg.status + ',' + msg.message + '<br/>'
                resultDiv.innerHTML += '<br/>---------------------------上传文件结束--------------------------------------------------------<br/>'
            } else {
    
    
                console.log('未知错误');
            }
        }
    })
}

前端文件合并代码:

 document.getElementById("mergedFile").addEventListener("change", function () {
    
    
     let file1 = this.files[0];
     let file2 = this.files[1];
     console.log('file1',file1);
     console.log('file2',file2);
     let arrayBlobs = [];
     let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
     arrayBlobs.push(blobSlice.call(file1, 0, file1.fileSize));
     arrayBlobs.push(blobSlice.call(file2, 0, file2.fileSize));
     let fileData = new Blob(arrayBlobs);
     downloadFileByBlob(fileData,"test.docx");
 });

3. 接口说明

minio依赖

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.4.0</version>
</dependency>

3.1 检查文件Md5接口

/client/file/check/{md5}

  • GET请求
/**
 * 每个文件都有一个md5值
 * 根据文件的md5校验文件是否存在
 * 实现秒传接口
 *
 * @param md5 文件的md5
 * @return 操作是否成功
 */
@GetMapping(value = "/check/{md5}")
public CommonResponse checkFileExists(@PathVariable("md5") String md5) {
    
    

    if (ObjectUtils.isEmpty(md5)) {
    
    
        return CommonResponse.ok(StatusCode.PARAM_ERROR_MD5.getCode())
            .message(StatusCode.PARAM_ERROR_MD5.getMessage());
    }
    // 从数据库中查询该MD5是否存在
    FileInfo fileInfo = fileInfoService.selectByMd5(md5);

    // 文件不存在
    if (fileInfo == null) {
    
    
        return CommonResponse.ok(StatusCode.NOT_FOUND.getCode())
            .message(StatusCode.NOT_FOUND.getMessage());
    }

    return CommonResponse.ok(StatusCode.EXIST_FILE_SUCCESS.getCode())
        .message(StatusCode.EXIST_FILE_SUCCESS.getMessage())
        .data("fileInfo",fileInfo);
}

3.2 单文件上传接口

/client/file/upload

  • POST请求
  • 参数:
    • md5:文件Md5值
    • file:上传文件
/**
 * 单个文件上传
 * @param requestParams
 * @param file
 * @return
 */
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public CommonResponse upload(
    @RequestParam Map<String, Object> requestParams,
    @RequestParam("file") MultipartFile file) {
    
    

    /**
         *  md5: 5ec5cec2b522c7a647e1fa4e7c6a08c7
         */
    FileRequest request = new JSONObject(requestParams).toJavaObject(FileRequest.class);
    log.info("上传文件信息:{}", request);
    if (ObjectUtils.isEmpty(request.getMd5())) {
    
    
        return CommonResponse.ok(StatusCode.PARAM_ERROR_MD5.getCode())
            .message(StatusCode.PARAM_ERROR_MD5.getMessage());
    }
    // 检查文件是否上传过
    FileInfo fileInfo = fileInfoService.selectByMd5(request.getMd5());
    if (fileInfo != null) {
    
    
        return CommonResponse.ok(StatusCode.EXIST_FILE_SUCCESS.getCode())
            .message(StatusCode.EXIST_FILE_SUCCESS.getMessage());
    }
    // 上传过程中出现异常,状态码设置为50000
    if (file == null) {
    
    
        return CommonResponse.error(StatusCode.RECEIVE_FILE_FAILURE.getCode())
            .message(StatusCode.RECEIVE_FILE_FAILURE.getMessage());
    }
    request.setFile(file);

    // 不需要分片的文件
    try {
    
    
        // 上传文件
        FileInfo result = minoFileService.putObject(bucketName,request);
        // 设置上传分片的状态
        return CommonResponse.ok(StatusCode.SUCCESS.getCode())
            .message(StatusCode.SUCCESS.getMessage())
            .data("fileInfo",result);
    } catch (Exception e) {
    
    
        e.printStackTrace();
        return CommonResponse.ok(StatusCode.FAILURE.getCode())
            .message(StatusCode.FAILURE.getMessage());
    }
}

3.3 大文件分片上传接口

/client/file/chunk/upload

  • POST请求
  • 参数:
    • md5:文件MD5值
    • file:文件一部分
    • name:文件名称。分片文件获取不到文件名
    • fileSize:文件总大小
    • segTotal:文件总分片数
    • segCurrent:文件当前分片
/**
 * 文件上传,适用大文件,分片上传
 * 上传文件:
 *      如果不需要分片,则用uuid生成文件名
 *      如果需要分片且不合并,则用md5值作为文件夹名,文件夹下为分片数据
 *      如果合并分片文件,则移除分片信息,并删除md5作为的文件夹名,合并成一个文件
 * @param requestParams
 * @param file
 * @return
 */
@PostMapping(value = "/chunk/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public CommonResponse chunkUpload(
    @RequestParam Map<String, Object> requestParams,
    @RequestParam("file") MultipartFile file) {
    
    

    /**
         * 分片文件上传,传的是blob,获取不到文件名
         *  md5: 5ec5cec2b522c7a647e1fa4e7c6a08c7
         *  name: 服务维护-2022-0712.docx
         *  fileSize: 7227540
         *  segTotal: 2
         *  segCurrent: 1
         */
    FileRequest request = new JSONObject(requestParams).toJavaObject(FileRequest.class);
    log.info("上传文件信息:{}", request);
    if (ObjectUtils.isEmpty(request.getMd5())) {
    
    
        return CommonResponse.error(StatusCode.PARAM_ERROR_MD5.getCode())
            .message(StatusCode.PARAM_ERROR_MD5.getMessage());
    }
    // 检查文件是否上传过
    FileInfo fileInfo = fileInfoService.selectByMd5(request.getMd5());
    if (fileInfo != null && fileInfo.getSegCurrent().intValue() == fileInfo.getSegTotal().intValue()) {
    
    
        return CommonResponse.ok(StatusCode.EXIST_FILE_SUCCESS.getCode())
            .message(StatusCode.EXIST_FILE_SUCCESS.getMessage());
    }

    // 上传过程中出现异常,状态码设置为50000
    if (file == null) {
    
    
        return CommonResponse.error(StatusCode.RECEIVE_FILE_FAILURE.getCode())
            .message(StatusCode.RECEIVE_FILE_FAILURE.getMessage());
    }

    request.setFile(file);
    String fileId = fileInfo == null ? UUIDUtil.timeUuid():fileInfo.getFileId();
    request.setFileId(fileId);

    FileInfo result = null;

    // 当不是最后一片时,上传返回的状态码为20001
    if (request.getSegCurrent() < request.getSegTotal()) {
    
    
        try {
    
    
            // 上传文件
            result = minoFileService.putChunkObject(request);
            log.info("segment file upload success {}", fileInfo);
            // 设置上传分片的状态
            return CommonResponse.ok(StatusCode.ALONE_CHUNK_UPLOAD_SUCCESS.getCode())
                .message(StatusCode.ALONE_CHUNK_UPLOAD_SUCCESS.getMessage())
                .data("fileInfo",result);
        } catch (Exception e) {
    
    
            e.printStackTrace();
            return CommonResponse.error(StatusCode.FAILURE.getCode())
                .message(StatusCode.FAILURE.getMessage());
        }
    } else {
    
    
        // 为分片文件的最后一片时状态码为20002
        try {
    
    
            // 上传文件
            result = minoFileService.putChunkObject(request);
            // 设置上传分片的状态
            return CommonResponse.ok(StatusCode.ALL_CHUNK_UPLOAD_SUCCESS.getCode())
                .message(StatusCode.ALL_CHUNK_UPLOAD_SUCCESS.getMessage())
                .data("fileInfo",result);
        } catch (Exception e) {
    
    
            e.printStackTrace();
            return CommonResponse.error(StatusCode.FAILURE.getCode())
                .message(StatusCode.FAILURE.getMessage());
        }
    }
}

3.4 分片文件合并接口

/client/file/merge

  • POST请求
  • 参数:(fileId、Md5二者只用传一个值)
    • fileId:文件Id
    • md5:文件Md5
/**
 * 文件合并
 * @return 分片合并的状态
 */
@PostMapping(value = "/merge")
public CommonResponse merge(
    @RequestParam Map<String, Object> requestParams) {
    
    

    /**
         * 参数:
         * fileId:
         * md5: 5ec5cec2b522c7a647e1fa4e7c6a08c7
         */
    FileRequest request = new JSONObject(requestParams).toJavaObject(FileRequest.class);
    FileInfo fileInfo = minoFileService.getFileInfo(request);
    // 检查文件是否存在
    if(fileInfo == null){
    
    
        return CommonResponse.ok(StatusCode.NOT_FOUND.getCode()).message(StatusCode.NOT_FOUND.getMessage());
    }

    if(fileInfo.getIsMerged() > 0 || fileInfo.getSegTotal() <= 1){
    
    
        // 文件不需要合并
        return CommonResponse.ok(StatusCode.NOT_SEGMENT_FILE_FAILURE.getCode()).message(StatusCode.NOT_SEGMENT_FILE_FAILURE.getMessage());
    }

    // path : /test/5ec5cec2b522c7a647e1fa4e7c6a08c7/1
    List<FileSegment> fileSegments = fileSegmentService.selectByMD5(fileInfo.getMd5());

    List<String> objectNameList = new ArrayList<>();
    for (FileSegment item : fileSegments) {
    
    
        String fileName = item.getPath().replace(bucketName,"").substring(1);
        objectNameList.add(fileName);
    }

    try {
    
    
        // 查询片数据
        if (fileInfo.getSegTotal() == fileSegments.size()) {
    
    
            // 开始合并请求
            String targetBucketName = bucketName;
            String filenameExtension = StringUtils.getFilenameExtension(fileInfo.getName());
            // 要合并成的文件名
            String objectName = fileSegments.get(0).getFileId() + "." + filenameExtension;
            // 返回合并后的新文件的路径
            String newPath = minoFileService.composeObject(objectNameList, bucketName, fileInfo.getMd5(), targetBucketName, objectName);

            log.info("桶:{} 中的分片文件,已经在桶:{},文件 {} 合并成功", fileInfo.getMd5(), targetBucketName, newPath);
            // 计算文件的md5
            String fileMd5 = null;
            try (InputStream inputStream = minoFileService.getObject(targetBucketName, objectName)) {
    
    
                fileMd5 = Md5Util.calculateMd5(inputStream);
            } catch (IOException e) {
    
    
                log.error("", e);
            }

            // 计算文件真实的类型
            List<String> typeList = new ArrayList<>();
            try (InputStream inputStreamCopy = minoFileService.getObject(targetBucketName, objectName)) {
    
    
                typeList.addAll(FileTypeUtil.getFileRealTypeList(inputStreamCopy, fileInfo.getName(), fileInfo.getFileSize()));
            } catch (IOException e) {
    
    
                log.error("", e);
            }

            // 并和前台的md5进行对比
            if (!ObjectUtils.isEmpty(fileMd5)
                && !ObjectUtils.isEmpty(typeList)
                && fileMd5.equalsIgnoreCase(fileInfo.getMd5())
                && typeList.contains(fileInfo.getFileType().toLowerCase(Locale.ENGLISH))) {
    
    
                // 表示是同一个文件, 且文件后缀名没有被修改过, 合并成功之后删除对应的分块文件
                minoFileService.removeObjects(bucketName,objectNameList);
                log.info("删除文件 {} 成功", fileInfo.getMd5());
                // 可以优化 todo
                FileInfo oldFileInfo = fileInfoService.selectByMd5(fileMd5);
                oldFileInfo.setPath(newPath);
                oldFileInfo.setIsMerged(StatusCode.FILE_MEGERD.getCode());
                fileInfo.setMtime(new Date());

                // 更新数据库的文件路径及合并状态
                fileInfoService.updateFileInfo(oldFileInfo);
                // 删除文件分块信息
                fileSegmentService.deleteFileByMD5(fileInfo.getMd5());
                // 成功,返回合并后的文件
                FileInfo result = fileInfoService.selectByMd5(oldFileInfo.getMd5());
                return CommonResponse.ok(StatusCode.SUCCESS.getCode())
                    .message(StatusCode.SUCCESS.getMessage())
                    .data("fileInfo",result);
            } else {
    
    
                log.info("非法的文件信息: 分片数量:{}, 文件名称:{}, 文件fileMd5:{}, 文件真实类型:{}, 文件大小:{}",fileInfo.getSegTotal(), fileInfo.getName(), fileMd5, typeList, fileInfo.getFileSize());

                // 文件md5对比失败,则要删除已经合并的文件
                minoFileService.removeObject(targetBucketName, objectName);
                return CommonResponse.ok(StatusCode.MERGED_FILE_FAILURE.getCode())
                    .message(StatusCode.MERGED_FILE_FAILURE.getMessage());
            }
        } else {
    
    
            // 失败,文件分片数不一致
            return CommonResponse.error(StatusCode.SEGMENT_COUNT_FILE_FAILURE.getCode())
                .message(StatusCode.SEGMENT_COUNT_FILE_FAILURE.getMessage());
        }
    } catch (Exception e) {
    
    
        log.error("", e);
        // 失败
        return CommonResponse.error(StatusCode.FAILURE.getCode())
            .message(StatusCode.FAILURE.getMessage());
    }
}

3.5 文件删除接口

/client/file/delete

  • POST请求
  • 参数:(fileId、Md5二者只用传一个值)
    • fileId:文件Id
    • md5:文件Md5
@PostMapping(value = "/delete")
public CommonResponse delete(
    @RequestParam Map<String, Object> requestParams) {
    
    

    /**
         * 参数:
         * md5: 5ec5cec2b522c7a647e1fa4e7c6a08c7
         * fileId :
         */
    FileRequest request = new JSONObject(requestParams).toJavaObject(FileRequest.class);
    FileInfo fileInfo = minoFileService.getFileInfo(request);
    minoFileService.removeObjectAll(bucketName,fileInfo);
    return CommonResponse.ok(StatusCode.SUCCESS.getCode()).message(StatusCode.SUCCESS.getMessage());
}

3.6 单文件下载接口

/client/file/download/{fileId}

  • POST请求
  • 参数:
    • fileId:文件id

3.7 分片文件下载接口

/client/file/download/{fileId}/{index}

  • POST请求
  • 参数:
    • fileId:文件id
    • inde:分片文件索引,第几段分片
@GetMapping(value = {
    
    "/download/{fileId}","/download/{fileId}/{index}"})
public void download(
    HttpServletResponse response,
    @PathVariable("fileId") String fileId,
    @PathVariable(value= "index",  required = false) String index,
    @RequestParam(value = "offset", required = false) Long offset,
    @RequestParam(value = "length", required = false) Long offLength) {
    
    
    /**
         * 参数:
         * fileId: 文件id
         * index: 分片索引
         * offset: 断点下载,指定位置
         * offLength: 读取指定长度
         * 备注:
         * path:
         *      非分块文件路径:/test/46951928120b11edadf4005056b2b395.txt
         *      分块文件路径: /bucketName/md5值/分片索引
         */

    InputStream inputStream = null;
    OutputStream outputStream = null;
    try{
    
    
        log.info("请求文件id为:{}",fileId);
        FileInfo fileInfo = fileInfoService.selectByFileId(fileId);
        String path = null;
        if(fileInfo.getIsMerged() == 0){
    
    
            // 分片文件下载路径
            path = "/" + fileInfo.getMd5() + "/" + index;
            response.setHeader("Content-Disposition", "attachment;filename="+ URLEncoder.encode(index,"UTF-8"));
        }else{
    
    
            path = fileInfo.getPath().replace(bucketName,"").substring(1);
            response.setHeader("Content-Disposition", "attachment;filename="+ URLEncoder.encode(fileInfo.getName(),"UTF-8"));
        }

        log.info("请求文件id为:{},下载路径为:{}",fileId,path);
        response.setHeader("content-type", "application/octet-stream");
        response.setContentType("application/octet-stream");
        response.setCharacterEncoding("UTF-8");

        if(!ObjectUtils.isEmpty(offset)  && !ObjectUtils.isEmpty(offLength)){
    
    
            inputStream = minoFileService.getObject(bucketName, path, offset, offLength);
        } else {
    
    
            inputStream = minoFileService.getObject(bucketName, path);
        }

        outputStream = response.getOutputStream();
        int length = 0;
        byte[] buffer = new byte[1024];
        while((length = inputStream.read(buffer)) != -1){
    
    
            outputStream.write(buffer,0,length);
        }
        inputStream.close();
        outputStream.flush();
        outputStream.close();
    }catch (Exception e){
    
    
        e.printStackTrace();
    }finally {
    
    
        try {
    
    
            if(inputStream != null){
    
    
                inputStream.close();
            }
            if (outputStream != null){
    
    
                outputStream.close();
            }
        } catch (IOException e) {
    
    
            e.printStackTrace();
        }
    }
}

3.8 查询文件信息接口

/client/file//info

  • POST请求
  • 参数:(fileId、Md5二者只用传一个值)
    • fileId:文件Id
    • md5:文件Md5
@PostMapping(value = "/info")
public CommonResponse info(
    @RequestParam Map<String, Object> requestParams) {
    
    

    /** 参数:
     * md5: 5ec5cec2b522c7a647e1fa4e7c6a08c7
     * fileId :
     */
    FileRequest request = new JSONObject(requestParams).toJavaObject(FileRequest.class);
    FileInfo fileInfo = minoFileService.getFileInfo(request);
    List<FileSegment> chunkInfo = null;
    if(fileInfo.getIsMerged() == 0){
    
    
        chunkInfo = fileSegmentService.selectByFileId(fileInfo.getFileId());
        return CommonResponse.ok(StatusCode.SUCCESS.getCode())
            .message(StatusCode.SUCCESS.getMessage())
            .data("fileInfo",fileInfo)
            .data("chunkInfo",chunkInfo);
    }
    return CommonResponse.ok(StatusCode.SUCCESS.getCode())
        .message(StatusCode.SUCCESS.getMessage())
        .data("fileInfo",fileInfo);
}

4. 服务说明

@Slf4j
@Service
public class MinioFileService {
    
    

    @Value("${oss.defaultBucket:default}")
    String bucketName;

    @Autowired
    MinioTemplate minioTemplate;

    @Autowired
    FileInfoService fileInfoService;

    @Autowired
    FileSegmentService fileSegmentService;

    /**
     * 查询所有Bucket
     * @return
     */
    @SneakyThrows
    public List<BucketDTO> listBuckets(){
    
    
        List<BucketDTO> result = new ArrayList<>();
        BucketDTO bucketDTO = null;
        List<Bucket> buckets = minioTemplate.listBuckets();
        for (Bucket bucket: buckets){
    
    
            bucketDTO = new BucketDTO();
            bucketDTO.setName(bucket.name());
            bucketDTO.setCreationDate(bucket.creationDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            result.add(bucketDTO);
        }
        return result;
    }

    /**
     * 获取bucket下的所有object
     * @param bucketName
     * @return
     */
    @SneakyThrows
    public List<ObjectDTO> listObjectsFromOSS(String bucketName){
    
    
        boolean found = minioTemplate.bucketExists(bucketName);
        List<ObjectDTO> list = new ArrayList<>();
        ObjectDTO objectDTO = null;
        if(found){
    
    
            // 获取bucket下的所有object
            Iterable<Result<Item>> result = minioTemplate.listObjects(bucketName,true);
            for(Result<Item> object: result){
    
    
                objectDTO = new ObjectDTO();
                Item item = object.get();
                BeanUtils.copyProperties(item,objectDTO);
                objectDTO.setLastModified(item.lastModified().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
                objectDTO.setDir(item.isDir());
                objectDTO.setOwnerId(item.owner().id());
                objectDTO.setOwnerName(item.owner().displayName());
                list.add(objectDTO);
            }
            return list;
        }
        log.info(bucketName + "does not exist.");
        return null;
    }

    /**
     * 查询桶中所有的对象名
     * @param bucketName 桶名
     * @return objectNames
     */
    @SneakyThrows
    public List<String> listObjectNames(String bucketName) {
    
    
        List<String> objectNameList = new ArrayList<>();
        if (minioTemplate.bucketExists(bucketName)) {
    
    
            Iterable<Result<Item>> results = minioTemplate.listObjects(bucketName, true);
            for (Result<Item> result : results) {
    
    
                String objectName = result.get().objectName();
                objectNameList.add(objectName);
            }
        }
        return objectNameList;
    }


    /**
     * 获取DB里bucket中的所有记录
     * @param bucketName
     * @return
     */
    public List<FileInfo> listObjectsFromDB(String bucketName) {
    
    
        return fileInfoService.selectByGroup(bucketName);
    }

    @SneakyThrows
    public FileInfo putObject(String bucketName, FileRequest request) {
    
    

        MultipartFile file = request.getFile();
        String fileId = UUIDUtil.timeUuid();
        String fileOriginalName = file.getOriginalFilename(); // cat.jpg
        String fileType = fileOriginalName.substring(fileOriginalName.lastIndexOf(".")+1);
        String fileName = fileId + "." +fileType;
        request.setFileId(fileId);
        request.setFileSize(file.getSize());
        request.setName(fileOriginalName);
        request.setFileExt(request.getFileExtName());
        request.setFileType(fileType);
        request.setGroup(bucketName);
        request.setIsMerged(StatusCode.FILE_MEGERD.getCode());
        request.setSegTotal(1);
        request.setSegCurrent(1);

        // 检查是否为为图片,为图片生成缩略图  todo

        InputStream stream = null;
        try {
    
    
            stream = file.getInputStream();
            minioTemplate.putObject(bucketName,fileName,stream,file.getContentType());
            // 此处stream用完会被关闭,所有要再次获取
            String fileRealMimeType = FileTypeUtil.getFileMimeType(file.getInputStream()); // image/jpeg
            if(fileRealMimeType != null && fileRealMimeType.contains("image")){
    
    
                // 图片
                generateThumb(bucketName,fileId,fileType,file);
            }
        } catch (Exception e) {
    
    
            e.printStackTrace();
            log.info("putObject exception:{}",e.getMessage());
        }finally {
    
    
            stream = null;
        }

        // 文件路径
        String path =  "/" + bucketName + "/" + fileName;
        request.setPath(path);
        // 将文件信息存入数据库中
        fileInfoService.insertOrUpdateFileInfo(request);
        return request.toFileInfo();
    }

    /**
     * 上传文件,分片
     * @param request  请求信息
     * @return FileInfo
     */
    @SneakyThrows
    public FileInfo putChunkObject(FileRequest request) {
    
    
        /**
         *  md5: 5ec5cec2b522c7a647e1fa4e7c6a08c7
         *  name: 移动采录-配网服务维护-2022-0712.docx
         *  fileSize: 7227540
         *  segTotal: 2
         *  segCurrent: 1
         */
        MultipartFile file = request.getFile();
        InputStream inputStream = file.getInputStream();
        try {
    
    
            String fileOriginalName = request.getName(); // cat.jpg
            String fileType = fileOriginalName.substring(fileOriginalName.lastIndexOf(".")+1);
            // 文件服务存储的目录为:/md5值/分片段名。eg:/721fc54dbdd35fa648c4f19e00a4c0ff/1
            String objectName  = "/" + request.getMd5() + "/" + request.getSegCurrent(); // 存入文件服务器中的文件名称
            String savePath = objectName;
            request.setFileType(fileType);
            request.setFileExt(request.getFileExtName());
            request.setGroup(bucketName);

            // 存入文件服务
            minioTemplate.putChunkObject(inputStream,bucketName,objectName);
            // 文件存储的完整路径要加上bucketName
            String path =  "/" + bucketName + savePath;
            request.setPath(path);
            // 将文件信息存入数据库中
            fileInfoService.insertOrUpdateFileInfo(request);
            // 分片信息入库
            FileSegment fileSegment = new FileSegment();
            fileSegment.setFileId(request.getFileId());
            fileSegment.setMd5(request.getMd5());
            fileSegment.setSegSize(file.getSize());
            fileSegment.setPath(path);
            fileSegment.setSegIndex(request.getSegCurrent());
            log.info("文件分片信息为:{}",fileSegment);
            fileSegmentService.insertFileSegment(fileSegment);
            return  fileInfoService.selectByFileId(request.getFileId());
        } catch (Exception e){
    
    
          e.printStackTrace();
        } finally {
    
    
            if (inputStream != null) {
    
    
                inputStream.close();
            }
        }
        return  null;
    }

    /**
     * 上传文件,分片
     * @param request  请求信息
     * @return FileInfo
     */
    @SneakyThrows
    public FileInfo putUnionObject(FileRequest request,FileInfo fileInfo) {
    
    
        /**
         *  md5: 5ec5cec2b522c7a647e1fa4e7c6a08c7
         *  name: 移动采录-配网服务维护-2022-0712.docx
         *  fileSize: 7227540
         *  segTotal: 2
         *  segCurrent: 1
         */
        MultipartFile file = request.getFile();
        InputStream inputStream = file.getInputStream();
        try {
    
    
            String fileOriginalName = request.getName(); // cat.jpg
            String fileType = fileOriginalName.substring(fileOriginalName.lastIndexOf("."));
            // 根据md5查询数据库信息
            String fileId = fileInfo == null ? UUIDUtil.timeUuid():fileInfo.getFileId();

            String objectName = null; // 存入文件服务器中的文件名称
            String savePath = null;

            // 如果文件分片,文件存储的目录结构要改变
            if(request.getSegCurrent() <= request.getSegTotal() && request.getSegTotal() > 1){
    
    
                // 文件服务存储的目录为:/md5值/分片段名。eg:/721fc54dbdd35fa648c4f19e00a4c0ff/1
                // 业务服务存储的是文件目录:/md5值
                savePath = "/" + request.getMd5();
                objectName = savePath + "/" + request.getSegCurrent();
            }else {
    
    
                // 如果文件不分段,则用uuid生成文件的文件名,但不包含文件后缀,需补充。eg:e287a46c0d4811eda62f005056b2b395.jpg
                objectName = fileId + fileType;
                savePath = objectName;
                // 不分片文件需要把分片标识设为已合并状态。分片文件的合并状态需要手动调用合并接口
                request.setIsMerged(StatusCode.FILE_MEGERD.getCode());
            }
            request.setFileId(fileId);
            request.setName(fileOriginalName);
            request.setFileType(fileType);
            request.setFileExt(request.getFileExtName());
            request.setGroup(bucketName);

            // 存入文件服务
            minioTemplate.putObject(bucketName,objectName,inputStream,fileType);
            // 文件存储的完整路径
            String path =  "/" + bucketName + savePath;
            request.setPath(path);
            // 将文件信息存入数据库中
            fileInfoService.insertOrUpdateFileInfo(request);
            // 如果是分片文件,将分片文件信息存到对应分片表中
            if(request.getSegTotal() > 1){
    
    
                FileSegment fileSegment = new FileSegment();
                BeanUtils.copyProperties(request,fileSegment);
                fileSegment.setSegSize(file.getSize());
                fileSegment.setPath(path + "/" +request.getSegCurrent());
                fileSegment.setSegIndex(request.getSegCurrent());
                log.info("文件分片信息为:{}",fileSegment);
                fileSegmentService.insertFileSegment(fileSegment);
            }
            return  fileInfoService.selectByFileId(fileId);
        } finally {
    
    
            if (inputStream != null) {
    
    
                inputStream.close();
            }
        }
    }


    /**
     * GetObject接口用于获取某个文件(Object),此操作需要对此Object具有读权限
     * @param bucketName 桶名
     * @param objectName 文件路径
     */
    @SneakyThrows
    public InputStream getObject(String bucketName, String objectName) {
    
    
        return minioTemplate.getObject(bucketName,objectName);
    }


    @SneakyThrows
    public InputStream getObject(String bucketName, String objectName, long offset, long length) {
    
    
        return minioTemplate.getObject(bucketName,objectName,offset,length);
    }

    /**
     * 文件删除
     * @param bucketName
     * @param fileId
     */
    @SneakyThrows
    public void removeObject(String bucketName, String fileId) {
    
    
        FileInfo fileInfo = fileInfoService.selectByFileId(fileId);
        String fileName = fileId + "." + fileInfo.getFileType();
        minioTemplate.removeObject(bucketName,fileName);
        fileInfoService.deleteFile(fileId);
    }

    /**
     * 文件删除,根据Md5或者fileId都可以
     * @param bucketName
     * @param fileInfo
     */
    @SneakyThrows
    @Transactional
    public void removeObjectAll(String bucketName, FileInfo fileInfo) {
    
    
        // 是否删除的分片文件
        if(fileInfo.getIsMerged() == 1){
    
    
            String objectName = fileInfo.getPath().replace(bucketName,"").substring(1);
            minioTemplate.removeObject(bucketName,objectName);
        } else {
    
    
            // 删除分片文件
            List<FileSegment> fileSegments = fileSegmentService.selectByMD5(fileInfo.getMd5());
            List<String> objectNameList = new ArrayList<>();
            for (FileSegment item : fileSegments) {
    
    
                String fileName = item.getPath().replace(bucketName,"").substring(1);
                objectNameList.add(fileName);
            }
            // 表示是同一个文件, 且文件后缀名没有被修改过, 合并成功之后删除对应的分块文件
            minioTemplate.removeObjects(bucketName,objectNameList);
            fileSegmentService.deleteFile(fileInfo.getFileId());
        }
        fileInfoService.deleteFile(fileInfo.getFileId());
    }


    /**
     * 批量文件删除
     * @param bucketName
     * @param objectNameList 文件名称集合
     */
    @SneakyThrows
    public void removeObjects(String bucketName, List<String> objectNameList) {
    
    
       minioTemplate.removeObjects(bucketName,objectNameList);
    }

    /**
     * 文件合并,将分块文件组成一个新的文件
     * @param objectNameList 分片名称
     * @param bucketName 分块文件所在的桶
     * @param targetBucketName 合并文件生成文件所在的桶
     * @param objectName       存储于桶中的对象名
     * @return OssFile
     *
     * 注意:minio规定,每个分片文件最小是5M,要不然合并文件会报错
     */
    @SneakyThrows
    public String composeObject(List<String> objectNameList, String bucketName, String md5, String targetBucketName, String objectName) {
    
    

        if (ObjectUtils.isEmpty(objectNameList)) {
    
    
            throw new IllegalArgumentException(bucketName + "桶中没有文件,请检查");
        }
        List<ComposeSource> composeSourceList = new ArrayList<>(objectNameList.size());
        // 在合并文件时,需要对分片的文件进行升序排序
        for (String object : objectNameList) {
    
    
            composeSourceList.add(ComposeSource.builder()
                    .bucket(bucketName)
                    .object(object)
                    .build());
        }
        return minioTemplate.composeObject(composeSourceList, targetBucketName, objectName);
    }

    public FileInfo getFileInfo(FileRequest request) {
    
    
        FileInfo fileInfo = null;
        if(!ObjectUtils.isEmpty(request.getMd5())){
    
    
            fileInfo = fileInfoService.selectByMd5(request.getMd5());
        } else if(!ObjectUtils.isEmpty(request.getFileId())){
    
    
            fileInfo = fileInfoService.selectByFileId(request.getFileId());
        }
        return fileInfo;
    }

    /**
     * 为图片生成缩略图
     * @param bucketName
     * @param fileId
     * @param fileType
     * @param file
     */
    private void generateThumb(String bucketName, String fileId, String fileType, MultipartFile file) {
    
    

        // 存放缩略图的临时目录
        String tempDir = "." + File.separator + bucketName + File.separator;
//        String tempDir = "e:" + File.separator + bucketName + File.separator;
        // 生成文件名
        String fileName = fileId + "_thumb" + "." +fileType;
        // 文件完整路径
        String path = tempDir + fileName;
        // 创建目录
        File dir = new File(tempDir);
        if(!dir.exists()){
    
    
            dir.setWritable(true,false);
            dir.setReadable(true,false);
            dir.setExecutable(true,false);
            dir.mkdir();
        }

        try {
    
    
            File toFile = new File(path);
            // 生成缩略图
            Thumbnails.of(file.getInputStream()).scale(0.25f).toFile(path);
            minioTemplate.uploadObject(bucketName,fileName,path);
            // 缩略图是否要入库  todo
        } catch (IOException e) {
    
    
            e.printStackTrace();
        } finally {
    
    
            if(dir != null){
    
    
                File[] files = dir.listFiles();
                for(File item : files){
    
    
                    item.delete();
                }
            }
        }
    }
}

配置文件

oss:
  enabled: true
  type: minio
  endPoint: http://172.40.240.162:9966
  accessKey: minio
  secretKey: minio123
  defaultBucket: test

数据库脚本

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for t_osp_file_segment
-- ----------------------------
DROP TABLE IF EXISTS `t_osp_file_segment`;
CREATE TABLE `t_osp_file_segment`  (
  `fileId` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '文件ID',
  `md5` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '文件md5值,唯一',
  `path` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '文件存储路径',
  `segSize` bigint(255) NULL DEFAULT NULL COMMENT '分片文件大小',
  `segIndex` int(255) NULL DEFAULT NULL COMMENT '分片的顺序'
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for t_osp_fileinfo
-- ----------------------------
DROP TABLE IF EXISTS `t_osp_fileinfo`;
CREATE TABLE `t_osp_fileinfo`  (
  `fileId` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT 'default' COMMENT '文件ID',
  `md5` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '文件md5值',
  `parentId` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '虚拟文件标识',
  `name` varchar(1024) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '文件名',
  `path` varchar(1024) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT 'FASTDFS路径',
  `fileType` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '文件类型',
  `fileSize` bigint(255) NULL DEFAULT NULL COMMENT '文件总大小',
  `segCurrent` int(255) NULL DEFAULT NULL COMMENT '已上传的分片',
  `segTotal` int(255) NULL DEFAULT NULL COMMENT '总分片数',
  `isMerged` bit(1) NULL DEFAULT b'0' COMMENT '是否合并,1表示合并,0表示未合并',
  `fileExt` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '扩展参数',
  `mtime` timestamp(6) NOT NULL ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '文件修改时间',
  `groupName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT 'default' COMMENT 'FASTDFS分组',
  PRIMARY KEY (`fileId`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

猜你喜欢

转载自blog.csdn.net/zxd1435513775/article/details/126674439
今日推荐