赞
踩
最近在工作中有涉及到文件上传功能,需求方要求文件最大上限为1G,如果直接将文件在前端做上传,会出现超长时间等待,如果服务端内存不够,会直接内存溢出,此时我们可以通过断点续传方式解决,前端我们通过WebUploader实现文件分割和上传,语言是React,后端我们通过SpringBoot实现文件接收和组装功能,下面我列出前后端主要功能代码。
一、前端代码
由于WebUploader依赖Jquery,所以在最开始我们应该引入Jquery,我们使用的前端脚手架是Ant Design,所以我在 src/pages/document.ejs 文件中引入,代码如下:
<script type="text/javascript" src="<%= context.config.manifest.basePath + 'jquery-1.11.1.min.js'%>"></script>
然后我们在前端代码中注册3个事件,分别是 before-send-file、before-send、after-send-file,这三个钩子分别是在发送文件前(上传文件之前执行,触发一次)、发送请求前(上传文件分块之前执行,触发多次)、文件上传后(分块全部上传完成之后执行,触发一次)
在 @/pages/device/index.jsx 文件中断点续传核心代码如下:
<Form.Item label="选择版本包" name="file" > <div name="file"> <label id="uploadWrapper" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', textAlign: 'center', border: '1px dashed #E5E5E5', cursor: inputDisabled ? 'default' : 'pointer', }} htmlFor={'softUpload'} onDrop={(e) => { document.getElementById('uploadWrapper').style.border = '1px dashed #E5E5E5'; document.getElementById('uploadOption').innerHTML = '点击或将文件拖拽到这里上传'; if (!inputDisabled) { inputOnChange(e); } }} onDragOver={(e) => { e.preventDefault(); document.getElementById('uploadWrapper').style.border = '1px dashed #1890FF'; document.getElementById('uploadOption').innerHTML = '释放鼠标'; }} > <input disabled={inputDisabled} type="file" title="" id={'softUpload'} multiple={false} name="file" style={{ opacity: 0 }} onChange={(e) => inputOnChange(e)} /> <label htmlFor={'softUpload'} style={{ cursor: inputDisabled ? 'default' : 'pointer' }} > <p style={{ marginBottom: '10px' }}> <span style={{ display: 'block', width: '102px', height: '30px', lineHeight: '30px', margin: '0 auto', color: '#1890FF', backgroundColor: '#E7F3FF', border: '1px solid #E7F3FF', }} > <UploadOutlined /> 上传 </span> </p> <div> <p id="uploadOption" className="ant-upload-text"> 点击或将文件拖拽到这里上传 </p> <p className="ant-upload-hint"> 支持扩展名: <span style={{ color: 'red' }}> {'版本包大小不超过1GB'} </span> </p> </div> </label> </label> <div style={{ maxWidth: '100%', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', lineHeight: 1, padding: '3px 0', marginTop: '13px', }} > <span>{task && task.file && task.file.name}</span> </div> <div style={{ padding: '0 24px 8px 0', width: '100%' }}> <Progress showInfo style={{ display: `${inputDisabled ? 'block' : 'none'}` }} strokeColor={{ from: '#108ee9', to: '#108ee9', }} percent={task.progress} size="small" /> </div> </div> </Form.Item>
在 @/pages/device/index.jsx 文件中 file input 点击事件核心代码如下:
const inputOnChange = async (e) => { e.preventDefault(); e.stopPropagation(); const files = e.target.files || e.dataTransfer.files; if (files && files[0]) { const isLt1G = files[0].size / 1024 / 1024 < 1024; if (!isLt1G) { message.error('版本包大小不能超过1GB!'); return; } addToTaskList(files[0]); } }; // 更新单个任务 const updateTask = (task, newProps) => { const newTask = Object.assign({}, task, newProps); setTask(newTask, function (data) { uploadNext(data); }); }; // 调用上传接口 const startUpload = (task) => { //初始化状态 const uploader = new Uploader({ file: task.file, onSuccess: (props) => { updateTask(task, { progress: 100, status: 'success' }); setCode(props.code); setMd5(props.md5); setInputDisabled(false); message.success(`${props.fileName} 文件上传成功`); }, onError: ({ msg }) => { setInputDisabled(false); updateTask(task, { progress: 0, status: 'error' }); message.error(msg); }, onProgress: ({ file, percentage }) => { const progress = (parseInt(percentage.toFixed(4) * 10000, 10) - 1) / 100; updateTask(task, { progress, status: 'uploading' }); }, }); updateTask(task, { progress: 0, status: 'uploading', uploader }); setInputDisabled(true); uploader.start(); }; // 开始下一个上传任务 const uploadNext = (task) => { // 单次仅允许一个上传任务 if (task.status === 'uploading') return; if (task.status === 'init') { startUpload(task); } }; // 添加任务 const addToTaskList = (file) => { setTask({ id: new Date().getTime(), file, progress: 0, status: 'init' }, function (data) { uploadNext(data); }); };
在 @/utils/upload.js 文件中封装了断点续传核心代码,代码如下:
import request from '@/utils/request'; import WebUploader from '../../public/webuploader.min'; import { TP_TOKE, BPR_BASE_URL } from '@/utils/constant'; /** * * 断点续传纯逻辑组件 * * 用法: * ``` * uploader = new Uploader({ * file: targetFile, * onSuccess: ({ fileName, resourceId, filePath }) => { * }, * onError: ({ msg }) => { * }, * onProgress: ({ data, percentage }) => { * }, * }); * * uploader.start(); * ``` * @class Uploader */ class Uploader { constructor({ file, onSuccess, onError, onProgress }) { // const files = e.target.files || e.dataTransfer.files; // 转化为WebUploader的内部file对象 this.file = new WebUploader.File(new WebUploader.Lib.File(WebUploader.guid('rt_'), file)); this.onSuccess = props => { this.clean(); if (onSuccess) onSuccess(props); }; this.onError = props => { this.clean(); if (onError) onError(props); }; this.onProgress = onProgress; this.uploader = null; } init = () => { WebUploader.Uploader.register({ name: 'webUploaderHookCommand', 'before-send-file': 'beforeSendFile', 'before-send': 'beforeSend', 'after-send-file': 'afterSendFile', }, { beforeSendFile: file => { const task = new WebUploader.Deferred(); this.fileName = file.name; this.fileSize = file.size; this.mimetype = file.type; this.fileExt = file.ext; (new WebUploader.Uploader()) .md5File(file, 0, 10 * 1024 * 1024 * 1024 * 1024).progress(percentage => { }) .then(val => { this.fileMd5 = val; const url = `${BPR_BASE_URL}/register`; const data = { fileMd5: this.fileMd5, fileName: file.name, fileSize: file.size, mimetype: file.type, fileExt: file.ext, }; request(url, { method: 'post', data, }).then(res => { console.log('register', res); if (res && res.status === 1) { task.resolve(); } else if (res && res.data && res.code === 103404) { // 文件已上传 this.onSuccess({ fileName: this.fileName, resourceId: res.data.resId, filePath: res.data.filePath, }); task.reject(); } else { file.statusText = res && res.message; task.reject(); } }); }); return task.promise(); }, beforeSend: block => { console.log('beforeSend'); const task = new WebUploader.Deferred(); const url = `${BPR_BASE_URL}/checkChunk`; const data = { fileMd5: this.fileMd5, chunk: block.chunk, chunkSize: block.end - block.start, }; request(url, { method: 'post', data, }).then(res => { console.log('checkChunk', res); if (res && res.data === true) { task.reject(); // 分片存在,则跳过上传 } else { task.resolve(); } }); this.uploader.options.formData.fileMd5 = this.fileMd5; this.uploader.options.formData.chunk = block.chunk; return task.promise(); }, afterSendFile: () => { console.log('start afterSendFile'); const task = new WebUploader.Deferred(); const url = `${BPR_BASE_URL}/mergeChunks`; const data = { fileMd5: this.fileMd5, fileName: this.fileName, fileSize: this.fileSize, mimetype: this.mimetype, fileExt: this.fileExt, }; request(url, { method: 'post', data, }).then(res => { console.log('mergeChunks', res); if (res && res.status === 1 && res.data && res.data.resId) { task.resolve(); this.onSuccess({ fileName: this.fileName, resourceId: res.data.resId, filePath: res.data.filePath, }); } else { task.reject(); this.onError({ msg: '合并文件失败' }); } }); }, }); } clean = () => { if (this.uploader) { WebUploader.Uploader.unRegister('webUploaderHookCommand'); } } start = () => { if (!this.uploader) { this.init(); } // 实例化 this.uploader = WebUploader.create({ server: BPR_BASE_URL, chunked: true, chunkSize: 1024 * 1024 * 5, chunkRetry: 1, threads: 3, duplicate: true, formData: { // 上传分片的http请求中一同携带的数据 appid: '1', token: localStorage.getItem(TP_TOKE), methodname: 'breakpointRenewal', }, }); // 一个分片上传成功后,调用该方法 this.uploader.on('uploadProgress', (data, percentage) => { console.log('uploadProgress'); this.onProgress({ data, percentage }); }); this.uploader.on('error', err => { this.onError({ msg: '上传出错,请重试' }); }); this.uploader.addFiles(this.file); this.uploader.upload(); } cancel = () => { console.log('call cancel'); this.uploader.stop(true); this.uploader.destroy(); console.log('getStats', this.uploader.getStats()); } } export default Uploader;
在 @/utils/constant 文件中定义了上述代码中所使用的常量,代码如下:
const constants = {
BPR_BASE_URL: '/v1.0/sys/admin/files/breakpointRenewal',
};
module.exports = constants;
到这里前端代码就写完了。
二、后端代码
1、maven依赖
<dependency> <groupId>com.aliyun.oss</groupId> <artifactId>aliyun-sdk-oss</artifactId> <version>3.11.0</version> </dependency> <dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.3.1</version> </dependency> <dependency> <groupId>commons-lang</groupId> <artifactId>commons-lang</artifactId> <version>2.6</version> <scope>compile</scope> </dependency>
2、ResponseEnum代码
/** * @Description: 返回码常量类 * @Author: henry * @Date: 2019/6/21 * code范围: * 成功:200 * 公共:0001-0999 * PaaS * FM 103400-103599 */ public enum ResponseEnum { /** * 公共模块码 */ RESPONSE_CODE_FAIL(100, "请求失败"), RESPONSE_CODE_SUCCESS(200, "请求成功"), RESPONSE_CODE_PARAM_ERR(400, "请求参数错误"), RESPONSE_CODE_PARAM_VALUE_ERR(401, "参数值错误"), RESPONSE_CODE_PARAM_EMPTY(402, "缺少参数"), RESPONSE_CODE_NOT_FOUND(404, "找不到指定的资源"), RESPONSE_CODE_METHOD_NOT_SUPPORT(405, "请求方法不支持"), RESPONSE_CODE_TYPE_NOT_ACCEPTABLE(406, "请求类型不接受"), RESPONSE_CODE_METHOD_NOT_EXIST(407, "请求方法不存在"), RESPONSE_CODE_PARAM_NOT_NULL(430, "参数为空"), RESPONSE_CODE_RECORD_ALREADY_EXISTS(431, "数据已存在"), RESPONSE_CODE_RECORD_NOT_EXISTS(432, "数据不存在"), RESPONSE_CODE_JSON_ERROR(433, "JSON格式不正确"), RESPONSE_CODE_PARAM_LENGTH_TOO_MIN(434, "参数长度过短"), RESPONSE_CODE_PARAM_LENGTH_TOO_MAX(435, "参数长度过长"), RESPONSE_CODE_NOTLANK_PARAM_NOT_EXISTS(436, "必填参数不存在"), RESPONSE_CODE_VERIFICATION_CODE_ERROR(442, "验证码无效"), RESPONSE_CODE_SYSTEM_BUSY(459, "系统忙或访问超时,请稍候重试"), RESPONSE_CODE_SYSTEM_ERROR(500, "系统错误,请稍后再试"), RESPONSE_CODE_PERMISSION_DENIED(502, "没有该操作的权限"), /** * FM(103400-103599) */ RESPONSE_CODE_FILE_UPLOAD_ERROR(103400, "文件上传失败"), RESPONSE_CODE_FILE_DOWNLOAD_ERROR(103401, "文件下载失败"), RESPONSE_CODE_FILE_DELETE_ERROR(103402, "文件删除失败"), RESPONSE_CODE_FILE_LIST_QUERY_ERROR(103403, "文件列表查询失败"), RESPONSE_CODE_BREAKPOINT_RENEVAL_REGISTRATION_ERROR(103404, "断点叙传注册:注册文件已存在"), RESPONSE_CODE_MERGE_FILE_ERROR(103405, "断点叙传合并:文件合并失败"), RESPONSE_CODE_FILE_BLOCK_DOES_NOT_EXIST_ERROR(103406, "断点叙传合并:文件分块不存在"), RESPONSE_CODE_VERIFY_FILE_ERROR(103407, "断点叙传校验:文件校验失败"), RESPONSE_CODE_PICTURE_SUFFIX_ERROR(103408, "图片格式不正确"); @Getter private int code; @Getter private String msg; ResponseEnum(int code, String msg) { this.code = code; this.msg = msg; } }
3、ResponseResult代码
/** * @Classname: com.openailab.oascloud.common.model.ResponseResult * @Description: 统一返回结果 * @Author: zxzhang * @Date: 2019/6/26 */ @Component public class ResponseResult implements Serializable { private static final long serialVersionUID = 5836869421731990598L; /** * 状态描述 */ @Getter private String message; /** * 返回数据 */ @Getter private Object data; /** * 响应码 */ @Getter private int code; /** * 状态(0:失败、1:成功) */ @Getter private int status; /** * 总条数 */ @Getter private Integer total; public ResponseResult() { } public ResponseResult(int status, Object data) { this.status = status; this.data = data; } public ResponseResult(int status, String message, Object data) { this.status = status; this.message = message; this.data = data; } public ResponseResult(String message, Object data, int code, int status) { this.message = message; this.data = data; this.code = code; this.status = status; } public ResponseResult(String message, Object data, int code, int status, Integer total) { this.message = message; this.data = data; this.code = code; this.status = status; this.total = total; } public static ResponseResult fail(String msg) { if (StringUtils.isEmpty(msg)) { return new ResponseResult(ResponseEnum.RESPONSE_CODE_FAIL.getMsg(), null, ResponseEnum.RESPONSE_CODE_FAIL.getCode(), CommonConst.RESPONSE_FAIL); } else { return new ResponseResult(msg, null, ResponseEnum.RESPONSE_CODE_FAIL.getCode(), CommonConst.RESPONSE_FAIL); } } public static ResponseResult fail(int code, String msg) { return new ResponseResult(msg, null, code, CommonConst.RESPONSE_FAIL); } public static ResponseResult fail(ResponseEnum responseEnum, Object obj) { return new ResponseResult(responseEnum.getMsg(), obj, responseEnum.getCode(), 0); } public static ResponseResult success(Object data) { return new ResponseResult(ResponseEnum.RESPONSE_CODE_SUCCESS.getMsg(), data, ResponseEnum.RESPONSE_CODE_SUCCESS.getCode(), CommonConst.RESPONSE_SUCCESS); } public static ResponseResult success(Object data, int code, String message) { return new ResponseResult(message, data, code, CommonConst.RESPONSE_SUCCESS); } public ResponseResult setMessage(String message) { this.message = message; return this; } public ResponseResult setData(Object data) { this.data = data; return this; } public ResponseResult setStatus(int status) { this.status = status; return this; } public ResponseResult setCode(int code) { this.code = code; return this; } public ResponseResult setTotal(Integer total) { this.total = total; return this; } }
4、FileController代码
/** * @description: 文件管理-Controller * @author: zhangzhixiang * @createDate: 2019/12/9 * @version: 1.0 */ @RestController @RequestMapping("/v1.0/sys/admin/files") public class FileController { private static Logger LOG = LoggerFactory.getLogger(FileController.class); @Autowired private IFileService fileService; @Autowired private IUserService userService; /** * 断点叙传 * * @param file * @param fileMd5 * @param chunk * @return com.openailab.oascloud.common.model.ResponseResult * @author zxzhang * @date 2020/1/13 */ @PostMapping(value = "/breakpointRenewal", produces = {MediaType.APPLICATION_JSON_UTF8_VALUE}, consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseResult breakpointRenewal(@RequestPart("file") MultipartFile file, @RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") Integer chunk) { try { return fileService.breakpointRenewal(file, fileMd5, chunk); } catch (Exception e) { LOG.error("********FileController->breakpointRenewal throw Exception.fileMd5:{},chunk:{}********", fileMd5, chunk, e); } return ResponseResult.fail(null); } /** * 断点叙传注册 * * @param fileMd5 * @param fileName * @param fileSize * @param mimetype * @param fileExt * @return com.openailab.oascloud.common.model.ResponseResult * @author zxzhang * @date 2020/1/13 */ @PostMapping(value = "/breakpointRenewal/register") public ResponseResult breakpointRegister(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName, @RequestParam("fileSize") Long fileSize, @RequestParam("mimetype") String mimetype, @RequestParam("fileExt") String fileExt) { try { return fileService.breakpointRegister(fileMd5, fileName, fileSize, mimetype, fileExt); } catch (Exception e) { LOG.error("********FileController->breakpointRegister throw Exception.fileMd5:{},fileName:{}********", fileMd5, fileName, e); } return ResponseResult.fail(null); } /** * 检查分块是否存在 * * @param fileMd5 * @param chunk * @param chunkSize * @return com.openailab.oascloud.common.model.ResponseResult * @author zxzhang * @date 2020/1/10 */ @PostMapping(value = "/breakpointRenewal/checkChunk") public ResponseResult checkChunk(@RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") Integer chunk, @RequestParam("chunkSize") Integer chunkSize) { try { return fileService.checkChunk(fileMd5, chunk, chunkSize); } catch (Exception e) { LOG.error("********FileController->breakpointRenewal throw Exception.fileMd5:{},chunk:{}********", fileMd5, chunk, e); } return ResponseResult.fail(null); } /** * 合并文件块 * * @param fileMd5 * @param fileName * @param fileSize * @param mimetype * @param fileExt * @return com.openailab.oascloud.common.model.ResponseResult * @author zxzhang * @date 2020/1/11 */ @PostMapping(value = "/breakpointRenewal/mergeChunks") public ResponseResult mergeChunks(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName, @RequestParam("fileSize") Long fileSize, @RequestParam("mimetype") String mimetype, @RequestParam("fileExt") String fileExt, @RequestParam("token") String token) { try { LoginUserInfo user = userService.getLoginUser(token); return fileService.mergeChunks(fileMd5, fileName, fileSize, mimetype, fileExt, user); } catch (Exception e) { LOG.error("********FileController->breakpointRenewal throw Exception.fileMd5:{},fileName:{}********", fileMd5, fileName, e); } return ResponseResult.fail(null); } }
5、IFileService代码
/** * @description: 文件管理-Interface * @author: zhangzhixiang * @createDate: 2019/12/9 * @version: 1.0 */ public interface IFileService { /** * 断点叙传注册 * * @param fileMd5 * @param fileName * @param fileSize * @param mimetype * @param fileExt * @return com.openailab.oascloud.common.model.ResponseResult * @author zxzhang * @date 2020/1/10 */ ResponseResult breakpointRegister(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt); /** * 断点叙传 * * @param file * @return com.openailab.oascloud.common.model.ResponseResult * @author zxzhang * @date 2019/12/9 */ ResponseResult breakpointRenewal(MultipartFile file, String fileMd5, Integer chunk); /** * 检查分块是否存在 * * @param fileMd5 * @param chunk * @param chunkSize * @return com.openailab.oascloud.common.model.ResponseResult * @author zxzhang * @date 2020/1/10 */ ResponseResult checkChunk(String fileMd5, Integer chunk, Integer chunkSize); /** * 合并文件块 * * @param fileMd5 * @param fileName * @param fileSize * @param mimetype * @param fileExt * @return com.openailab.oascloud.common.model.ResponseResult * @author zxzhang * @date 2020/1/11 */ ResponseResult mergeChunks(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt, LoginUserInfo user); }
6、FileServiceImpl代码
/** * @description: 文件管理-service * @author: zhangzhixiang * @createDate: 2019/12/9 * @version: 1.0 */ @Service public class FileServiceImpl implements IFileService { private final static Logger LOG = LoggerFactory.getLogger(FileServiceImpl.class); private static final SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd"); @Autowired private FileDao fileDao; @Autowired private BootstrapConfig bootstrapConfig; @Autowired private FileManagementHelper fileManagementHelper; @Autowired private PageObjUtils pageObjUtils; @Autowired private RedisDao redisDao; private String getUploadPath() { return bootstrapConfig.getFileRoot() + bootstrapConfig.getUploadDir() + "/"; } private String getFileFolderPath(String fileMd5) { return getUploadPath() + fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/"; } private String getFilePath(String fileMd5, String fileExt) { return getFileFolderPath(fileMd5) + fileMd5 + "." + fileExt; } private String getFileRelativePath(String fileMd5, String fileExt) { return bootstrapConfig.getUploadDir() + "/" + fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "." + fileExt; } private String getChunkFileFolderPath(String fileMd5) { return bootstrapConfig.getFileRoot() + bootstrapConfig.getBreakpointDir() + "/" + fileMd5 + "/"; } @Override public ResponseResult breakpointRegister(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) { Map<String, String> ret = Maps.newHashMap(); // 检查文件是否存在于磁盘 String fileFolderPath = this.getFileFolderPath(fileMd5); String filePath = this.getFilePath(fileMd5, fileExt); File file = new File(filePath); boolean exists = file.exists(); // 检查文件是否存在于PostgreSQL中 (文件唯一标识为 fileMd5) ResourceBO resourceBO = new ResourceBO(); resourceBO.setFileMd5(fileMd5); resourceBO.setIsDelete(0); List<ResourceBO> resourceBOList = fileDao.selectResourceByCondition(resourceBO); if (exists && resourceBOList.size() > 0) { // 既存在于磁盘又存在于数据库说明该文件存在,直接返回resId、filePath resourceBO = resourceBOList.get(0); ret.put("filePath", resourceBO.getFilePath()); ret.put("resId", String.valueOf(resourceBO.getResourceId())); return ResponseResult.fail(ResponseEnum.RESPONSE_CODE_BREAKPOINT_RENEVAL_REGISTRATION_ERROR, ret); } //若磁盘中存在,但数据库中不存在,则生成resource记录并存入redis中 if (resourceBOList.size() == 0) { // 首次断点叙传的文件需要创建resource新记录并返回redId,并存入redis中 resourceBO.setType(fileManagementHelper.judgeDocumentType(fileExt)); resourceBO.setStatus(TranscodingStateEnum.UPLOAD_NOT_COMPLETED.getCode()); resourceBO.setFileSize(fileSize); resourceBO.setFileMd5(fileMd5); resourceBO.setFileName(fileName); resourceBO.setCreateDate(new Date()); resourceBO.setIsDelete(0); final Integer resourceId = fileDao.addResource(resourceBO); resourceBO.setResourceId(resourceId); redisDao.set(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5, JSONObject.toJSONString(resourceBO), RedisPrefixConst.EXPIRE); } //如果redis中不存在,但数据库中存在,则存入redis中 String breakpoint = redisDao.get(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5); if (StringUtils.isEmpty(breakpoint) && resourceBOList.size() > 0) { resourceBO = resourceBOList.get(0); redisDao.set(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5, JSONObject.toJSONString(resourceBO), RedisPrefixConst.EXPIRE); } // 若文件不存在则检查文件所在目录是否存在 File fileFolder = new File(fileFolderPath); if (!fileFolder.exists()) { // 不存在创建该目录 (目录就是根据前端传来的MD5值创建的) fileFolder.mkdirs(); } return ResponseResult.success(null); } @Override public ResponseResult breakpointRenewal(MultipartFile file, String fileMd5, Integer chunk) { Map<String, String> ret = Maps.newHashMap(); // 检查分块目录是否存在 String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5); File chunkFileFolder = new File(chunkFileFolderPath); if (!chunkFileFolder.exists()) { chunkFileFolder.mkdirs(); } // 上传文件输入流 File chunkFile = new File(chunkFileFolderPath + chunk); try (InputStream inputStream = file.getInputStream(); FileOutputStream outputStream = new FileOutputStream(chunkFile)) { IOUtils.copy(inputStream, outputStream); // redis中查找是否有fileMd5的分块记录(resId) String breakpoint = redisDao.get(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5); ResourceBO resourceBO = new ResourceBO(); if (!StringUtils.isEmpty(breakpoint)) { // 存在分块记录说明资源正在上传中,直接返回fileMd5对应的resId,且不再重复创建resource记录 resourceBO = JSONObject.parseObject(breakpoint, ResourceBO.class); ret.put("resId", String.valueOf(resourceBO.getResourceId())); } } catch (IOException e) { e.printStackTrace(); } return ResponseResult.success(ret); } @Override public ResponseResult checkChunk(String fileMd5, Integer chunk, Integer chunkSize) { // 检查分块文件是否存在 String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5); // 分块所在路径+分块的索引可定位具体分块 File chunkFile = new File(chunkFileFolderPath + chunk); if (chunkFile.exists() && chunkFile.length() == chunkSize) { return ResponseResult.success(true); } return ResponseResult.success(false); } @Override public ResponseResult mergeChunks(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt, LoginUserInfo user) { FileClient fileClient = ClientFactory.createClientByType(bootstrapConfig.getFileClientType()); String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5); File chunkFileFolder = new File(chunkFileFolderPath); File[] files = chunkFileFolder.listFiles(); final String filePath = this.getFilePath(fileMd5, fileExt); File mergeFile = new File(filePath); List<File> fileList = Arrays.asList(files); // 1. 合并分块 mergeFile = this.mergeFile(fileList, mergeFile); if (mergeFile == null) { return ResponseResult.fail(ResponseEnum.RESPONSE_CODE_MERGE_FILE_ERROR, null); } // 2、校验文件MD5是否与前端传入一致 boolean checkResult = this.checkFileMd5(mergeFile, fileMd5); if (!checkResult) { return ResponseResult.fail(ResponseEnum.RESPONSE_CODE_VERIFY_FILE_ERROR, null); } // 3、删除该文件所有分块 FileUtil.deleteDir(chunkFileFolderPath); // 4、在redis中获取文件分块记录 String breakpoint = redisDao.get(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5); if (StringUtils.isEmpty(breakpoint)) { return ResponseResult.fail("文件分块不存在"); } ResourceBO resourceBO = JSONObject.parseObject(breakpoint, ResourceBO.class); // 5、删除redis分块记录 redisDao.del(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5); // 6、组装返回结果 ret.put("filePath", getFileRelativePath(fileMd5, fileExt)); ret.put("resId", String.valueOf(resourceBO.getResourceId())); return ResponseResult.success(ret); } /** * 合并文件 * * @param chunkFileList * @param mergeFile * @return java.io.File * @author zxzhang * @date 2020/1/11 */ private File mergeFile(List<File> chunkFileList, File mergeFile) { try { // 有删 无创建 if (mergeFile.exists()) { mergeFile.delete(); } else { mergeFile.createNewFile(); } // 排序 Collections.sort(chunkFileList, (o1, o2) -> { if (Integer.parseInt(o1.getName()) > Integer.parseInt(o2.getName())) { return 1; } return -1; }); byte[] b = new byte[1024]; RandomAccessFile writeFile = new RandomAccessFile(mergeFile, "rw"); for (File chunkFile : chunkFileList) { RandomAccessFile readFile = new RandomAccessFile(chunkFile, "r"); int len = -1; while ((len = readFile.read(b)) != -1) { writeFile.write(b, 0, len); } readFile.close(); } writeFile.close(); return mergeFile; } catch (IOException e) { e.printStackTrace(); return null; } } /** * 校验文件MD5 * * @param mergeFile * @param md5 * @return boolean * @author zxzhang * @date 2020/1/11 */ private boolean checkFileMd5(File mergeFile, String md5) { try { // 得到文件MD5 FileInputStream inputStream = new FileInputStream(mergeFile); String md5Hex = DigestUtils.md5Hex(inputStream); if (StringUtils.equalsIgnoreCase(md5, md5Hex)) { return true; } } catch (Exception e) { e.printStackTrace(); } return false; } /** * 获取文件后缀 * * @param fileName * @return java.lang.String * @author zxzhang * @date 2019/12/10 */ public String getExt(String fileName) { return fileName.substring(fileName.lastIndexOf(".") + 1); } /** * 获取文件所在目录 * * @param filePath * @return java.lang.String * @author zxzhang * @date 2019/12/10 */ public String getFileDir(String filePath) { return filePath.substring(0, filePath.lastIndexOf(BootstrapConst.PATH_SEPARATOR)); } /** * 获取文件名 * * @param filePath * @return java.lang.String * @author zxzhang * @date 2019/12/10 */ public String getFileName(String filePath) { return filePath.substring(filePath.lastIndexOf(BootstrapConst.PATH_SEPARATOR) + 1, filePath.lastIndexOf(".")); } }
7、FileUtil代码
/** * @description: * @author: zhangzhixiang * @createDate: 2020/1/7 * @version: 1.0 */ public class FileUtil { private static final Logger LOG = LoggerFactory.getLogger(FileUtil.class); /** * 清空文件夹下所有文件 * * @param path * @return boolean * @author zxzhang * @date 2020/1/7 */ public static boolean deleteDir(String path) { File file = new File(path); if (!file.exists()) {//判断是否待删除目录是否存在 return false; } String[] content = file.list();//取得当前目录下所有文件和文件夹 for (String name : content) { File temp = new File(path, name); if (temp.isDirectory()) {//判断是否是目录 deleteDir(temp.getAbsolutePath());//递归调用,删除目录里的内容 temp.delete();//删除空目录 } else { if (!temp.delete()) {//直接删除文件 LOG.error("********文件删除失败,file:{}********" + name); } } } return true; } /** * 复制单个文件 * * @param oldPath String 原文件路径 如:c:/fqf * @param newPath String 复制后路径 如:f:/fqf/ff * @return boolean */ public static void copyFile(String oldPath, String newPath) { try { int bytesum = 0; int byteread = 0; File oldfile = new File(oldPath); if (oldfile.exists()) { //文件存在时 InputStream inStream = new FileInputStream(oldPath); //读入原文件 //newFilePath文件夹不存在则创建 File newParentFile = new File(newPath).getParentFile(); if (!newParentFile.exists()) { newParentFile.mkdirs(); } FileOutputStream fs = new FileOutputStream(newPath); byte[] buffer = new byte[1444]; int length; while ((byteread = inStream.read(buffer)) != -1) { bytesum += byteread; //字节数 文件大小 System.out.println(bytesum); fs.write(buffer, 0, byteread); } inStream.close(); } } catch (Exception e) { LOG.error("********复制单个文件操作出错********"); e.printStackTrace(); } } /** * 复制整个文件夹内容 * * @param oldPath String 原文件路径 如:c:/fqf * @param newPath String 复制后路径 如:f:/fqf/ff * @return boolean */ public static void copyFolder(String oldPath, String newPath) { try { //newFilePath文件夹不存在则创建 File newParentFile = new File(newPath).getParentFile(); if (!newParentFile.exists()) { newParentFile.mkdirs(); } File a = new File(oldPath); String[] file = a.list(); File temp = null; for (int i = 0; i < file.length; i++) { if (oldPath.endsWith(File.separator)) { temp = new File(oldPath + file[i]); } else { temp = new File(oldPath + File.separator + file[i]); } if (temp.isFile()) { FileInputStream input = new FileInputStream(temp); FileOutputStream output = new FileOutputStream(newPath + "/" + (temp.getName()).toString()); byte[] b = new byte[1024 * 5]; int len; while ((len = input.read(b)) != -1) { output.write(b, 0, len); } output.flush(); output.close(); input.close(); } if (temp.isDirectory()) {//如果是子文件夹 copyFolder(oldPath + "/" + file[i], newPath + "/" + file[i]); } } } catch (Exception e) { LOG.error("********复制整个文件夹内容操作出错********"); e.printStackTrace(); } } /** * 获取一个文件的md5值(可处理大文件) * * @param file * @return java.lang.String * @author zxzhang * @date 2020/3/23 */ public static String getMD5(MultipartFile file) { InputStream fileInputStream = null; try { MessageDigest MD5 = MessageDigest.getInstance("MD5"); fileInputStream = file.getInputStream(); byte[] buffer = new byte[8192]; int length; while ((length = fileInputStream.read(buffer)) != -1) { MD5.update(buffer, 0, length); } return new String(Hex.encodeHex(MD5.digest())); } catch (Exception e) { e.printStackTrace(); return null; } finally { try { if (fileInputStream != null) { fileInputStream.close(); } } catch (IOException e) { e.printStackTrace(); } } } /** * 获取一个文件的md5值(可处理大文件) * * @param file * @return java.lang.String * @author zxzhang * @date 2020/3/23 */ public static String getMD5(File file) { try(FileInputStream fileInputStream = new FileInputStream(file)) { MessageDigest MD5 = MessageDigest.getInstance("MD5"); byte[] buffer = new byte[8192]; int length; while ((length = fileInputStream.read(buffer)) != -1) { MD5.update(buffer, 0, length); } return new String(Hex.encodeHex(MD5.digest())); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 求一个字符串的md5值 * * @param target * @return java.lang.String * @author zxzhang * @date 2020/3/23 */ public static String MD5(String target) { return DigestUtils.md5Hex(target); } }
8、FileManagementHelper代码
/** * @description: * @author: zhangzhixiang * @createDate: 2019/12/11 * @version: 1.0 */ @Component public class FileManagementHelper { private static final Logger LOG = LoggerFactory.getLogger(FileManagementHelper.class); @Autowired private BootstrapConfig bootstrapConfig; /** * 根据文件后缀判断类型 * * @param ext * @return java.lang.Integer * @author zxzhang * @date 2019/12/10 */ public Integer judgeDocumentType(String ext) { //视频类 if (VedioEnum.containKey(ext) != null) { return ResourceTypeEnum.VIDEO.getCode(); } //图片类 if (ImageEnum.containKey(ext) != null) { return ResourceTypeEnum.IMAGE.getCode(); } //文档类 if (DocumentEnum.containKey(ext) != null) { return ResourceTypeEnum.FILE.getCode(); } //未知 return ResourceTypeEnum.OTHER.getCode(); } /** * 生成随机文件名称 * * @param ext * @return java.lang.String * @author zxzhang * @date 2019/12/10 */ public static String createFileName(String ext) { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMddHHmmss"); return simpleDateFormat.format(new Date()) + (int) (Math.random() * 900 + 100) + ext; } /** * 获取文件后缀 * * @param fileName * @return java.lang.String * @author zxzhang * @date 2019/12/10 */ public String getExt(String fileName) { return fileName.substring(fileName.lastIndexOf(".") + 1); } /** * 获取文件所在目录 * * @param filePath * @return java.lang.String * @author zxzhang * @date 2019/12/10 */ public String getFileDir(String filePath) { return filePath.substring(0, filePath.lastIndexOf(BootstrapConst.PATH_SEPARATOR)); } /** * 获取文件名 * * @param filePath * @return java.lang.String * @author zxzhang * @date 2019/12/10 */ public String getFileName(String filePath) { return filePath.substring(filePath.lastIndexOf(BootstrapConst.PATH_SEPARATOR) + 1, filePath.lastIndexOf(".")); } }
9、PageObjUtils代码
/** * @Classname: com.openailab.oascloud.um.util.PageObjUtils * @Description: 分页对象工具类 * @Author: ChenLiang * @Date: 2019/7/17 */ @Component public class PageObjUtils<T> { public PageVO getPageList(PageInfo<T> personPageInfo) { PageVO result = new PageVO(); if (personPageInfo != null) { if (!personPageInfo.getList().isEmpty()) { result.setPageNo(personPageInfo.getPageNum()); result.setPageSize(personPageInfo.getPageSize()); result.setTotal(Integer.valueOf(String.valueOf(personPageInfo.getTotal()))); result.setItems(personPageInfo.getList()); } } return result; } }
10、RedisDao代码
/** * @Classname: com.openailab.oascloud.datacenter.api.IRedisApi * @Description: Redis API * @Author: zxzhang * @Date: 2019/7/1 */ @FeignClient(ServiceNameConst.OPENAILAB_DATA_CENTER_SERVICE) public interface RedisDao { /** * @api {POST} /redis/set 普通缓存放入并设置过期时间 * @apiGroup Redis * @apiVersion 0.1.0 * @apiParam {String} key 键 * @apiParam {String} value 值 * @apiParam {long} expire 过期时间 */ @PostMapping("/redis/set") ResponseResult set(@RequestParam("key") String key, @RequestParam("value") String value, @RequestParam("expire") long expire); /** * @api {POST} /redis/get 普通缓存获取 * @apiGroup Redis * @apiVersion 0.1.0 * @apiParam {String} key 键 * @apiSuccess {String} value 值 */ @PostMapping("/redis/get") String get(@RequestParam("key") String key); /** * @api {POST} /redis/del 普通缓存删除 * @apiGroup Redis * @apiVersion 0.1.0 * @apiParam {String} key 键 */ @PostMapping("/redis/del") ResponseResult del(@RequestParam("key") String key); /** * @api {POST} /redis/hset 存入Hash值并设置过期时间 * @apiGroup Redis * @apiVersion 0.1.0 * @apiParam {String} key 键 * @apiParam {String} item 项 * @apiParam {String} value 值 * @apiParam {long} expire 过期时间 */ @PostMapping("/redis/hset") ResponseResult hset(@RequestParam("key") String key, @RequestParam("item") String item, @RequestParam("value") String value, @RequestParam("expire") long expire); /** * @api {POST} /redis/hget 获取Hash值 * @apiGroup Redis * @apiVersion 0.1.0 * @apiParam {String} key 键 * @apiParam {String} item 项 * @apiSuccess {String} value 值 * @apiSuccessExample {json} 成功示例 * {"name":"张三","age":30} */ @PostMapping("/redis/hget") Object hget(@RequestParam("key") String key, @RequestParam("item") String item); /** * @api {POST} /redis/hdel 删除Hash值SaasAppKeyDao * @apiGroup Redis * @apiVersion 0.1.0 * @apiParam {String} key 键 * @apiParam {String} item 项 */ @PostMapping("/redis/hdel") ResponseResult hdel(@RequestParam("key") String key, @RequestParam("item") String item); }
11、BootstrapConfig代码
/** * @Classname: com.openailab.oascloud.security.common.config.BootstrapConsts * @Description: 引导类 * @Author: zxzhang * @Date: 2019/10/8 */ @Data @Configuration public class BootstrapConfig { @Value("${file.client.type}") private String fileClientType; @Value("${file.root}") private String fileRoot; @Value("${file.biz.file.upload}") private String uploadDir; @Value("${file.biz.file.download}") private String downloadDir; @Value("${file.biz.file.backup}") private String backupDir; @Value("${file.biz.file.tmp}") private String tmpDir; @Value("${file.biz.file.breakpoint}") private String breakpointDir; }
12、application.properties
eureka.instance.instance-id=${spring.application.name}:${server.port} eureka.instance.prefer-ip-address=true eureka.client.serviceUrl.defaultZone=http://127.0.0.1:32001/eureka/ server.port=32018 spring.application.name=openailab-file-management #file file.client.type = ceph file.root = /usr/local/oas/file file.biz.file.upload = /upload file.biz.file.download = /download file.biz.file.backup = /backup file.biz.file.tmp = /tmp file.biz.file.breakpoint = /breakpoint #ribbon ribbon.ReadTimeout=600000 ribbon.ConnectTimeout=600000 #base info.description=文件管理服务 info.developer=andywebjava@163.com spring.servlet.multipart.enabled=true spring.servlet.multipart.max-file-size=5120MB spring.servlet.multipart.max-request-size=5120MB
13、表结构
字段名 注释 类型 长度 是否必填 是否主键
id 主键ID,sequence(course_resource_id_seq) int 32 是 是
type 资源类型,1:视频;2:文档;3:图片 int 2 是 否
fileName 文件名称 varchar 100 是 否
fileSize 文件大小 int 64 是 否
filePath 文件路径 varchar 200 否 否
status 0:无需转码 1:转码中 2:已转码 3:未上传完成 4:已上传完成 -1:转码失败 int 2 是 否
createDate 创建时间 timestamp 0 是 否
createUser 创建用户 varchar 50 是 否
isDelete 是否删除:0未删除,1已删除 int 2 是 否
userId 用户ID int 32 是 否
fileMd5 文件唯一标识(webupload文件md5唯一标识) varchar 100 是 否
文件断点续传到此就介绍完了
参考文章:http://blog.ncmem.com/wordpress/2023/10/25/springboot-%e5%ae%9e%e7%8e%b0%e5%a4%a7%e6%96%87%e4%bb%b6%e6%96%ad%e7%82%b9%e7%bb%ad%e4%bc%a0/
欢迎入群一起讨论
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。