采用jpa方式
model类
ChunkInfo
package org.zz.platform.filemanagement.model;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
// 文件块model
@Data
public class ChunkInfo {
private Integer chunkNumber;
/**
* 每块大小
*/
private Long chunkSize;
/**
* 当前分块大小
*/
private Long currentChunkSize;
/**
* 总大小
*/
private Long totalSize;
/**
* 文件标识
*/
private String identifier;
/**
* 文件名
*/
private String filename;
/**
* 相对路径
*/
private String relativePath;
/**
* 总块数
*/
private Integer totalChunks;
/**
* 块内容 transient 表示upfile不是该对象序列化的一部分
*/
private transient MultipartFile upfile;
}
FileInfo
package org.zz.platform.filemanagement.model;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.GenericGenerator;
import javax.persistence.*;
import java.util.Date;
// 文件类
@Table(name = "t_file_info")
@Entity
@Getter
@Setter
public class FileInfo {
/**
* 附件编号
*/
@Id
@GeneratedValue(generator = "idGenerator")
@GenericGenerator(name = "idGenerator", strategy = "uuid")
@Column(name = "id", unique = true, nullable = false)
private String id;
/**
* 附件名称
*/
@Column(name = "filename")
private String filename;
/**
* 附件MD5标识
*/
@Column(name = "identifier")
private String identifier;
/**
* 附件总大小
*/
@Column(name = "total_size")
private Long totalSize;
@Transient
private String totalSizeName;
/**
* 附件类型
*/
@Column(name = "type")
private String type;
/**
* 附件存储地址
*/
@Column(name = "location")
private String location;
/**
* 上传人
*/
@Column(name = "upload_by")
private String uploadBy;
/**
* 上传时间
*/
@Column(name = "upload_time")
private Date uploadTime = new Date();
}
省略jpaservice层
控制层
package org.zz.platform.filemanagement.controller;
import com.querydsl.core.BooleanBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.zz.platform.common.utils.CommonUtils;
import org.zz.platform.common.utils.ResponseResult;
import org.zz.platform.filemanagement.model.ChunkInfo;
import org.zz.platform.filemanagement.model.FileInfo;
import org.zz.platform.filemanagement.model.QFileInfo;
import org.zz.platform.filemanagement.model.UploadResult;
import org.zz.platform.filemanagement.service.FileInfoService;
import org.zz.platform.filemanagement.utils.FileInfoUtils;
import org.zz.platform.filemanagement.vo.FileInfoVo;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
/**
* @Classname UpLoaderController
* @Description:
* @Author: WangHaodong
* @Date 2021/1/18 11:05
*/
@RestController
@RequestMapping("/rest/uploader")
@Slf4j
public class UpLoaderController {
@Value("${file.upload.path}")
private String uploadFolder;
private final FileInfoService fileInfoService;
private final RedisTemplate<String, Object> redisTemplate;
public UpLoaderController(FileInfoService fileInfoService,
RedisTemplate<String, Object> redisTemplate) {
this.fileInfoService = fileInfoService;
this.redisTemplate = redisTemplate;
}
/**
* 上传文件块
*
* @param chunk 文件块
* @Author: WangHaodong
* @Date 2021/1/19 14:27
* @return: org.springframework.http.ResponseEntity<?>
**/
@PostMapping("/chunk")
public ResponseEntity<?> uploadChunk(ChunkInfo chunk) {
String key = "chunk_info:" + chunk.getIdentifier();
MultipartFile file = chunk.getUpfile();
log.info("file originName: {}, chunkNumber: {}", file.getOriginalFilename(), chunk.getChunkNumber());
try {
byte[] bytes = file.getBytes();
Path path = Paths.get(FileInfoUtils.generatePath(uploadFolder + "/file/", chunk));
//文件写入指定路径
Files.write(path, bytes);
if (redisTemplate.hasKey(key)) {
redisTemplate.opsForValue().append(key, "," + chunk.getChunkNumber());
} else {
redisTemplate.opsForValue().set(key, chunk.getChunkNumber().toString());
}
} catch (IOException e) {
e.printStackTrace();
return ResponseResult.fail("上传失败");
}
return ResponseResult.success("成功");
}
@GetMapping("/chunk")
public UploadResult checkChunk(ChunkInfo chunk, HttpServletResponse response) {
UploadResult ur = new UploadResult();
//默认返回其他状态码,前端不进去checkChunkUploadedByResponse函数,正常走标准上传
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
String file = uploadFolder + "/file/" + chunk.getIdentifier() + "/" + chunk.getFilename();
FileInfo byIdentifier = fileInfoService.getRepository().findByIdentifier(chunk.getIdentifier());
String key = "chunk_info:" + chunk.getIdentifier();
//先判断整个文件是否已经上传过了,如果是,则告诉前端跳过上传,实现秒传
if (FileInfoUtils.fileExists(file) || byIdentifier != null) {
ur.setSkipUpload(true);
response.setStatus(HttpServletResponse.SC_OK);
ur.setMessage("上传完成");
return ur;
}
//如果完整文件不存在 去数据库判断当前哪些文件块已经上传过了 结果告诉前端 跳过这些文件块的上传 实现断点续传
// 判段redis中已上传的文件片 没有文件片走标准上传
if (redisTemplate.opsForValue().get(key) == null) {
return ur;
}
// 有文件片文件片取出接续上传
String temp = redisTemplate.opsForValue().get(key).toString();
String[] split = temp.split(",");
ArrayList<Integer> list = new ArrayList<>();
for (String s : split) {
list.add(Integer.parseInt(s));
}
if (list.size() > 0) {
ur.setSkipUpload(false);
ur.setUploadedChunks(list);
response.setStatus(HttpServletResponse.SC_OK);
ur.setMessage("继续上传");
return ur;
}
return ur;
}
@PostMapping("/mergeFile")
public ResponseEntity<?> mergeFile(@RequestBody FileInfoVo fileInfoVo) {
String key = "chunk_info:" + fileInfoVo.getUniqueIdentifier();
//前端组件参数转换为model对象
FileInfo fileInfo = new FileInfo();
fileInfo.setFilename(fileInfoVo.getName());
fileInfo.setIdentifier(fileInfoVo.getUniqueIdentifier());
fileInfo.setId(fileInfoVo.getId());
fileInfo.setTotalSize(fileInfoVo.getSize());
fileInfo.setType(fileInfoVo.getName().substring(fileInfoVo.getName().lastIndexOf(".")));
//进行文件的合并操作
String filename = fileInfo.getFilename();
String file = uploadFolder + "/file/" + fileInfo.getIdentifier() + "/" + filename;
String folder = uploadFolder + "/file/" + fileInfo.getIdentifier();
String fileSuccess = FileInfoUtils.merge(file, folder, filename);
fileInfo.setLocation(file);
//文件合并成功后,保存记录至数据库
if ("200".equals(fileSuccess)) {
fileInfoService.save(fileInfo);
// 删除文件片缓存
redisTemplate.delete(key);
}
return ResponseResult.success("成功");
}
@GetMapping("/selectFileList")
public ResponseEntity<?> selectFileList(@RequestParam(required = false) String fileName,
@PageableDefault(sort = {"uploadTime"}, direction = Sort.Direction.ASC) Pageable pageable,
HttpServletRequest request) {
QFileInfo qFileInfo = QFileInfo.fileInfo;
BooleanBuilder booleanBuilder = new BooleanBuilder();
if (!StringUtils.isEmpty(fileName)) {
booleanBuilder.and(qFileInfo.filename.like("%" + fileName + "%"));
}
String strPage = request.getParameter("page");
String strSize = request.getParameter("size");
if (org.apache.commons.lang.StringUtils.isEmpty(strPage) && org.apache.commons.lang.StringUtils.isEmpty(strSize)) {
List<FileInfo> list = fileInfoService.findAll(booleanBuilder, pageable.getSort());
return ResponseResult.success(list);
}
Page<FileInfo> pageList = fileInfoService.findAll(booleanBuilder, pageable);
for (FileInfo fileInfo : pageList) {
fileInfo.setTotalSizeName(CommonUtils.totalSize(fileInfo.getTotalSize()));
}
return ResponseResult.success(pageList);
}
@GetMapping("/download/{id}")
public ResponseEntity<?> download(@PathVariable String id) {
FileInfo fileInfo = fileInfoService.find(id);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).body(new FileSystemResource(fileInfo.getLocation()));
}
@DeleteMapping(value = "/deleteFile/{id}")
public ResponseEntity<?> deleteFile(@PathVariable String id) {
FileInfo fileInfo = fileInfoService.find(id);
if (fileInfo == null) {
return ResponseResult.fail("请检查数据!");
}
fileInfoService.delete(id);
FileInfoUtils.deleteDirectory(fileInfo.getLocation());
return ResponseResult.success("删除成功");
}
}
工具类
package org.zz.platform.filemanagement.utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zz.platform.filemanagement.model.ChunkInfo;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
public class FileInfoUtils {
private final static Logger logger = LoggerFactory.getLogger(FileInfoUtils.class);
public static String generatePath(String uploadFolder, ChunkInfo chunk) {
StringBuilder sb = new StringBuilder();
sb.append(uploadFolder).append("/").append(chunk.getIdentifier());
if (!Files.isWritable(Paths.get(sb.toString()))) {
try {
Files.createDirectories(Paths.get(sb.toString()));
} catch (IOException e) {
logger.error(e.getMessage(), e);
}
}
return sb.append("/")
.append(chunk.getFilename())
.append("-")
.append(chunk.getChunkNumber()).toString();
}
/**
* 文件合并
*
* @param file 文件全路径
* @param folder 文件夹
* @param filename 文件名称
* @Author: WangHaodong
* @Date 2021/1/19 14:22
* @return: java.lang.String
**/
public static String merge(String file, String folder, String filename) {
try {
//先判断文件是否存在
if (fileExists(file)) {
//文件已存在
return "300";
} else {
//不存在的话,进行合并
Files.createFile(Paths.get(file));
Files.list(Paths.get(folder))
.filter(path -> !path.getFileName().toString().equals(filename))
.sorted((o1, o2) -> {
String p1 = o1.getFileName().toString();
String p2 = o2.getFileName().toString();
int i1 = p1.lastIndexOf("-");
int i2 = p2.lastIndexOf("-");
return Integer.valueOf(p2.substring(i2)).compareTo(Integer.valueOf(p1.substring(i1)));
})
.forEach(path -> {
try {
//以追加的形式写入文件
Files.write(Paths.get(file), Files.readAllBytes(path), StandardOpenOption.APPEND);
//合并后删除该块
Files.delete(path);
} catch (IOException e) {
logger.error(e.getMessage(), e);
}
});
return "200";
}
} catch (IOException e) {
logger.error(e.getMessage(), e);
//合并失败
return "400";
}
}
/**
* 根据文件的全路径名判断文件是否存在
*
* @param file 文件全路径
* @Author: WangHaodong
* @Date 2021/1/19 14:24
* @return: boolean
**/
public static boolean fileExists(String file) {
boolean fileExists;
Path path = Paths.get(file);
fileExists = Files.exists(path, LinkOption.NOFOLLOW_LINKS);
return fileExists;
}
/**
* 删除目录(文件夹)以及目录下的文件
*
* @param path 路径
* @Author: WangHaodong
* @return: boolean
*/
public static boolean deleteDirectory(String path) {
path = path.substring(0,path.lastIndexOf(File.separator));
File dirFile = new File(path);
if (!dirFile.exists() || !dirFile.isDirectory()) {
return false;
}
boolean flag = true;
// 删除文件夹下的所有文件(包括子目录)
File[] files = dirFile.listFiles();
for (File file : files) {
if (file.isFile()) {
flag = deleteFile(file.getAbsolutePath());
} else {
flag = deleteDirectory(file.getAbsolutePath());
}
if (!flag) {
break;
}
}
if (!flag) {
return false;
}
return dirFile.delete();
}
/**
* 删除单个文件
*
* @param path
* @Author: WangHaodong
* @return: boolean
*/
public static boolean deleteFile(String path) {
boolean flag = false;
File file = new File(path);
// 路径为文件且不为空则进行删除
if (file.isFile() && file.exists()) {
file.delete();
flag = true;
}
return flag;
}
}
vue端
Upload.vue
<template>
<el-dialog
width="500px"
:visible.sync="showDialog"
:title="title"
:close-on-click-modal="false"
append-to-body>
<!-- 上传器 -->
<uploader
ref="uploader"
:options="options"
:autoStart=false
:file-status-text="fileStatusText"
@file-added="onFileAdded"
@file-success="onFileSuccess"
@file-error="onFileError"
class="uploader-ui">
<uploader-unsupport></uploader-unsupport>
<uploader-drop>
<div>
<uploader-btn id="global-uploader-btn" :attrs="attrs" ref="uploadBtn">选择文件<i
class="el-icon-upload el-icon--right"></i></uploader-btn>
</div>
</uploader-drop>
<uploader-list></uploader-list>
</uploader>
</el-dialog>
</template>
<script>
import SparkMD5 from 'spark-md5'
import {mergeFile} from '@/api/common_services/file-upload-service'
export default {
data() {
return {
title: 'ssss',
showDialog: false,
options: {
// 目标上传 URL,默认POST
target: `${baseUrl}/filemanagement/rest/uploader/chunk`,
// 分块大小(单位:字节)
chunkSize: '2048000',
// 上传文件时文件内容的参数名,对应chunk里的Multipart对象名,默认对象名为file
fileParameterName: 'upfile',
// 失败后最多自动重试上传次数
maxChunkRetries: 3,
// 是否开启服务器分片校验,对应GET类型同名的target URL
testChunks: true,
checkChunkUploadedByResponse(chunk, message) {
debugger
const objMessage = JSON.parse(message)
if (objMessage.skipUpload) {
return true
}
return (objMessage.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0
},
headers: {
Authorization: serverConfig.authorization
}
},
attrs: {},
fileStatusText: {
success: '上传成功',
error: '上传失败',
uploading: '上传中',
paused: '暂停',
waiting: '等待上传'
}
}
},
methods: {
onFileAdded(file) {
this.computeMD5(file)
},
/*
第一个参数 rootFile 就是成功上传的文件所属的根 Uploader.File 对象,它应该包含或者等于成功上传文件;
第二个参数 file 就是当前成功的 Uploader.File 对象本身;
第三个参数就是 message 就是服务端响应内容,永远都是字符串;
第四个参数 chunk 就是 Uploader.Chunk 实例,它就是该文件的最后一个块实例,如果你想得到请求响应码的话,chunk.xhr.status就是
*/
onFileSuccess(rootFile, file, response, chunk) {
mergeFile(file).then(responseData => {
if (responseData.data.code === 415) {
console.log('合并操作未成功,结果码:' + responseData.data.code)
}
}).catch(error => {
console.log('合并后捕获的未知异常:' + error)
})
},
onFileError(rootFile, file, response, chunk) {
console.log('上传完成后异常信息:' + response)
},
/**
* 计算md5,实现断点续传及秒传
* @param file
*/
computeMD5(file) {
file.pause()
// 单个文件的大小限制2G
const fileSizeLimit = 2 * 1024 * 1024 * 1024
console.log('文件大小:' + file.size)
console.log('限制大小:' + fileSizeLimit)
if (file.size > fileSizeLimit) {
this.$message({
showClose: true,
message: '文件大小不能超过2G'
})
file.cancel()
}
const fileReader = new FileReader()
const time = new Date().getTime()
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
let currentChunk = 0
const chunkSize = 10 * 1024 * 1000
const chunks = Math.ceil(file.size / chunkSize)
const spark = new SparkMD5.ArrayBuffer()
// 由于计算整个文件的Md5太慢,因此采用只计算第1块文件的md5的方式
const chunkNumberMD5 = 1
loadNext()
fileReader.onload = e => {
spark.append(e.target.result)
if (currentChunk < chunkNumberMD5) {
loadNext()
} else {
const md5 = spark.end()
file.uniqueIdentifier = md5
file.resume()
console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`)
}
}
fileReader.onerror = function () {
this.error(`文件${file.name}读取出错,请检查该文件`)
file.cancel()
}
function loadNext() {
const start = currentChunk * chunkSize
const end = (start + chunkSize) >= file.size ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
currentChunk++
console.log('计算第' + currentChunk + '块')
}
},
close() {
this.uploader.cancel()
},
error(msg) {
this.$notify({
title: '错误',
message: msg,
type: 'error',
duration: 2000
})
}
}
}
</script>
<style>
.uploader-ui {
padding: 15px;
margin: 40px auto 0;
font-size: 12px;
font-family: Microsoft YaHei;
box-shadow: 0 0 10px rgba(0, 0, 0, .4);
}
.uploader-ui .uploader-btn {
margin-right: 4px;
font-size: 12px;
border-radius: 3px;
color: #FFF;
background-color: #409EFF;
border-color: #409EFF;
display: inline-block;
line-height: 1;
white-space: nowrap;
}
.uploader-ui .uploader-list {
max-height: 440px;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
}
</style>
UpLoadFileList.vue
<template>
<div class="content">
<el-form :inline="true" class="query-form">
<el-row :gutter="20">
<el-col :span="6">
<el-form-item>
<el-button type="primary" icon="el-icon-upload" @click="uploadFiles()">文件上传</el-button>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="文件名">
<el-input v-model="query.nameSearch" size="mini" placeholder="输入文件名"></el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
<!-- table主要区域 begin -->
<el-table
:data="tableData"
stripe
border
style="width: 100%;"
height="300"
tooltip-effect="light"
ref="multipleTable">
<el-table-column prop="filename" align="center" label="文件名" show-overflow-tooltip></el-table-column>
<el-table-column prop="totalSizeName" align="center" width="" label="文件大小"></el-table-column>
<el-table-column align="center" label="上传时间">
<template slot-scope="scope">
{{formatDateTime(scope.row.uploadTime)}}
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<template slot-scope="scope">
<el-button
type="text"
icon="el-icon-download"
class="blue"
@click="handleDownload(scope.row)"
>下载
</el-button>
<el-button
type="text"
icon="el-icon-delete"
class="red"
@click="handleDelete(scope.row)"
>删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- table主要区域 end -->
<el-pagination
layout="total, prev, pager, next, jumper"
:total="this.page.total"
:current-page="this.page.currentPage"
:page-size="this.page.size"
:page-sizes="this.page.page_sizes"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
>
</el-pagination>
<upload ref="uploadForm"></upload>
</div>
</template>
<script>
import {selectFileList, deleteFile, downloadFile} from '@/api/common_services/file-upload-service'
import Upload from './Upload'
import FileUtil from '@/utils/FileUtil'
export default {
components: {Upload},
data() {
return {
query: {
nameSearch: ''
},
idShow: false,
tableData: [],
multipleSelection: [],
uploadVisible: false,
pageTotal: 0,
form: {},
id: -1,
page: {
total: 0,
size: 10,
page_sizes: [10, 20, 50, 100],
currentPage: 1
}
}
},
created() {
this.getFileList()
},
methods: {
getFileList() {
this.loading = true
selectFileList({
page: this.page.currentPage - 1,
size: this.page.size,
fileName: this.query.nameSearch
}).then(res => {
this.tableData = res.data.content
this.page.total = res.data.total
this.page.totalPages = 0
this.loading = false
})
},
handlerClose() {
this.getFileList()
},
uploadFiles() {
this.$refs.uploadForm.showDialog = true
},
handleSearch() {
this.getFileList()
},
/**
* 当选择项发生变化时会触发该事件
*/
handleSelectionChange(val) { // 选中的行
this.selectedRowsData = val
console.log(this.selectedRowsData)
},
/**
* 改变页数
* @param currentPage
*/
handleCurrentChange(currentPage) {
this.page.currentPage = currentPage
this.getFileList()
},
/**
* 改变页码
* @param size
*/
handleSizeChange(size) {
this.page.size = size
this.getFileList()
},
handleDelete(row) {
this.$confirm('确定删除文件 [' + row.filename + ']吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteFile(row.id).then(res => {
this.$message({
type: 'success',
message: '删除成功!'
})
this.getFileList()
})
}).catch(() => {
this.$message({
type: 'info',
message: '取消删除'
})
})
},
// 下载
handleDownload(row) {
this.loadingOverLay = this.$loading({
lock: true,
text: '文件生成中',
spinner: 'el-icon-loading',
background: 'rgba(0,0,0,0.7)'
})
downloadFile(row.id).then(res => {
const blob = new Blob([res.data], {
type: 'application/octet-stream'
})
FileUtil.downloadFromFile(blob, row.filename)
}).catch(err => {
this.$message.error('下载出错' + err)
})
this.loadingOverLay.close()
}
}
}
</script>
<style scoped>
/deep/ .el-table__empty-text {
line-height: 30px;
width: 50%;
color: #909399;
}
</style>
界面效果
来源:oschina
链接:https://my.oschina.net/u/3784129/blog/4923754