前言
1、文件目录不存在会自动创建。
2、限制了上传的文件类型,可参考 MDN MIME
3、文件路径采用 随机字符串+时间戳 的方式命名。
4、兼容了单文件上传和多文件上传,返回存储路径的字符串数组。
5、大文件切片上传需要前端配合,且只能采取单文件上传的方式,不可多文件上传。
一、配置上传文件大小等
/app/index.ts中配置app.use
app
.use(koaBody({
multipart: true,
formidable: {
maxFileSize: 200 * 1024 * 1024, //设置上传文件大小最大限制,默认 200M
// uploadDir: path.join(__dirname, '..', 'static/upload'), // 上传目录
// keepExtensions: true,// 保留文件扩展名
}
}))
二、文件上传、多文件上传,删除文件
import { Context } from "koa"
import response from "../../utils/response"
import { randomStr, dateFormart, isJSON } from "../../utils/index"
import fs from 'fs'
import path from 'path'
import md5 from 'md5'
const maxFileSize = 100 * 1024 * 1024; // 单文件最大值
/**
* 上传文件
*/
class UploadController {
index(ctx: Context) {
response.success(ctx)
}
// 上传多个文件
uploadfiles(ctx: Context) {
const files = ctx.request.files?.file // 获取上传文件
// console.log(files)
// console.log(Array.isArray(files))
// @ts-ignore
if (files) {
let filePathArr: string[] = []
if (!Array.isArray(files)) { // 是否是数组
if (!validateFileType(files)) {
return response.error(ctx, '', '非法文件上传')
}
if (files.size > maxFileSize) {
return response.error(ctx, '', `文件大小超过${maxFileSize / 1024 / 1024}M`)
}
const filePath = saveFileThis(files)
filePathArr.push(filePath)
} else {
let errArr = [];
for (let file of files) {
if (!validateFileType(file)) {
errArr.push('非法文件上传')
continue;
}
if (file.size > maxFileSize) {
errArr.push(`文件大小超过${maxFileSize / 1024 / 1024}M`)
continue;
}
}
if (errArr.length > 0) {
return response.error(ctx, errArr)
}
for (let file of files) {
const filePath = saveFileThis(file)
filePathArr.push(filePath)
}
}
response.success(ctx, filePathArr)
} else {
response.error(ctx, [], '文件不能为空')
}
}
deletefiles(ctx: Context) {
const queryPath = ctx.request.body.path || ''
if (!queryPath) {
return response.error(ctx, '', '路径不能为空')
}
// console.log(Array.isArray(queryPath),isJSON(queryPath))
const pathArr = isJSON(queryPath) ? JSON.parse(queryPath) : []
if (!Array.isArray(pathArr) || pathArr.length === 0) {
return response.error(ctx, '', '路径不正确')
}
let dataArr = []
for (let val of pathArr) {
if (!(/^\/upload\//.test(val))) {
dataArr.push('路径不正确')
continue;
}
const pathTarget = path.join(__dirname, '../..', `static`, val)
// console.log(pathTarget, fs.existsSync(pathTarget))
if (fs.existsSync(pathTarget)) {
if (fs.statSync(pathTarget).isDirectory()) {
dataArr.push('文件路径有误')
continue;
} else {
fs.unlinkSync(pathTarget);
dataArr.push('删除成功')
continue;
}
} else {
dataArr.push('文件不存在')
continue;
}
}
return response.success(ctx, dataArr)
}
}
/**
* @description 判断文件夹是否存在 如果不存在则创建文件夹
*/
function checkDirExist(p: string) {
if (!fs.existsSync(p)) {
fs.mkdirSync(p, { recursive: true }); // 递归创建子文件夹
}
}
/**
* @description 抽离公共方法 校验单文件类型
*/
function validateFileType(file: any) {
// @ts-ignore
// console.log(file.originalFilename, file.filepath, file.mimetype)
// @ts-ignore
const fileType = file.mimetype
const typeSet = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp',
'video/mp4', 'video/webm', 'video/x-msvideo', 'audio/mpeg', 'audio/ogg',
'text/markdown', 'application/json',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/msword',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.ms-powerpoint',
'application/pdf', 'application/zip', 'application/x-zip-compressed',
])
if (!typeSet.has(fileType)) {
return false
}
return true
}
/**
* @description 抽离公共方法 存储单文件
*/
function saveFileThis(file: any) {
// @ts-ignore
const reader = fs.createReadStream(file.filepath) // 创建可读流
// @ts-ignore
const ext = path.extname(file.originalFilename)
// 最终要保存到的文件夹目录
const yyyyMMdd = dateFormart('yyyyMMdd') // 目录: 年月日
const lastDir = path.join(__dirname, '../..', `static/upload/${yyyyMMdd}`);
checkDirExist(lastDir); // 检查文件夹是否存在如果不存在则新建文件夹
const filePath = `/upload/${yyyyMMdd}/` + randomStr() + ext;
const writer = fs.createWriteStream('static' + filePath) // 创建可写流
reader.pipe(writer) // 可读流通过管道写入可写流
return filePath
}
/**
* @description 判断文件、文件夹是否存在及删除的方法
* @param {string} path 必传参数可以是文件夹可以是文件
* @param {string} reservePath 保存path目录 path值与reservePath值一样就保存
*/
async function delFile(path: string, reservePath: string = '') {
if (fs.existsSync(path)) {
if (fs.statSync(path).isDirectory()) {
let files = fs.readdirSync(path);
files.forEach((file, index) => {
let currentPath = path + "/" + file;
if (fs.statSync(currentPath).isDirectory()) {
delFile(currentPath, reservePath);
} else {
fs.unlinkSync(currentPath);
}
});
if (path != reservePath) {
try {
let fileList = await fs.readdirSync(path)
// 清空文件夹内容之后之后删除文件夹
if (fileList.length > 0) {
setTimeout(() => {
fs.rmdirSync(path);
}, 100);
} else {
fs.rmdirSync(path);
}
} catch (error) {
console.log('删除文件夹报错:', error)
}
}
} else {
await fs.unlinkSync(path);
}
}
}
export default new UploadController
三、大文件切片上传
1、后端代码
在上面代码的基础上,添加下方函数。
// 分片上传大文件
uploadfilebig(ctx: Context) {
const files = ctx.request.files?.file // 获取上传文件
const index = ctx.request.body.index // 上传文件的 序号
// console.log(files)
// console.log(Array.isArray(files))
if (!files) {
return response.error(ctx, [], '文件不能为空')
} else if (index == undefined) {
return response.error(ctx, [], '文件序号不能为空')
} else if (Array.isArray(files)) {
// 是否是数组
return response.error(ctx, '', '仅支持单文件上传')
// } else if (!validateFileType(files)) {
// // 校验类型
// return response.error(ctx, '', '非法文件上传')
}
const reader = fs.createReadStream(files.filepath) // 创建可读流
// @ts-ignore
// const ext = path.extname(files.originalFilename)
const nameMd5 = md5(files.originalFilename); // md5加密后的文件名
// 最终要保存到的文件夹目录
const lastDir = path.join(__dirname, '../..', `static/upload/bigfile/${nameMd5}`);
checkDirExist(lastDir); // 检查文件夹是否存在如果不存在则新建文件夹
const filePath = `/upload/bigfile/${md5(String(files.originalFilename))}/` + index;
const writer = fs.createWriteStream('static' + filePath) // 创建可写流
reader.pipe(writer) // 可读流通过管道写入可写流
response.success(ctx, nameMd5)
}
// 上传完成后合并文件操作
uploadfilebigMerge(ctx: Context) {
const fileDir = ctx.request.body['dir'] || '';
if (fileDir === '' || fileDir === undefined) {
return response.error(ctx, '', '文件名不能为空')
}
const fileExt = ctx.request.body['ext'] || 'mp4';
// console.log(fileDir, fileExt)
const namePath = path.join(__dirname, '../..', `static/upload/bigfile/${fileDir}`);
const fileList = fs.readdirSync(namePath)
const soureFileList = fileList.sort().map(r => {
return path.join(__dirname, '../..', `static/upload/bigfile/${fileDir}/${r}`)
})
const filePath = `/upload/bigfile/${fileDir}.${fileExt}`; // 接口返回的路径
const tartget = path.join(__dirname, '../..', `static${filePath}`)
const fileWriteStream = fs.createWriteStream(tartget);
let index = 0;
function createStreamFileFn() {
if (index >= soureFileList.length) {
return fileWriteStream.end(() => {
console.log('Stream 合并完成!')
// 创建完成后删除原来的 切片文件
delFile(namePath)
})
}
let fileReadStream = fs.createReadStream(soureFileList[index]);
fileReadStream.on('error', () => {
fileWriteStream.close()
})
fileReadStream.pipe(fileWriteStream, { end: false });
fileReadStream.on('end', () => {
index += 1;
createStreamFileFn()
})
}
createStreamFileFn();
response.success(ctx, filePath)
}
// 检查
async uploadfilebigInspect(ctx: Context) {
const name = ctx.request.body.name || '';
if (name === '' || name === undefined) {
return response.error(ctx, [], '文件名不能为空')
}
const nameMd5 = md5(name); // md5加密后的文件名
const lastDir = path.join(__dirname, '../..', `static/upload/bigfile/${nameMd5}`);
if (!fs.existsSync(lastDir)) {
return response.success(ctx, { index: 0 }, '文件目录为空')
}
if (fs.statSync(lastDir).isDirectory()) {
let fileList = await fs.readdirSync(lastDir)
// console.log(fileList)
return response.success(ctx, { index: fileList.length }, '查询成功')
}
return response.success(ctx, { index: 0 }, '查询失败')
}
2、前端代码(测试专用)
tip:
注意跨域问题。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>测试专用</title>
<!-- <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script> -->
</head>
<body>
<h3>多文件上传</h3>
<form action="http://localhost:8828/api/uploadfiles" method="post" enctype="multipart/form-data">
<input type="file" name="file" id="file" value="" multiple="multiple" />
<input type="submit" value="提交" />
</form>
<hr />
<hr />
<hr />
<h3>大文件上传切片上传</h3>
<input type="file" name="单文件上传" id="file-big">
<div>上传进度:<span id="file-big-percent"></span></div>
<script>
console.log('初始化----')
document.getElementById('file-big').addEventListener('change', (e) => {
changeFn(e)
})
const changeFn = async (e) => {
const chunkSize = 0.5 * 1024 * 1024;
const target = e.target;
console.log('target:', target)
if (target.files) {
const file = target.files[0];
const {
name,
size,
type
} = file;
let start = 0;
let index = 0;
let resultFileDir = ''; // 后台返回的路径
// 检查是否上传过,获取index
const dataInpect = await uploadfilebigInspect(name);
if (dataInpect.code !== 0) {
alert(`查询错误:${dataInpect.msg}`)
return false
}
index = dataInpect.data.index;
start = index > 0 && chunkSize * index;
console.log(index, start, size)
while (start < size) {
let blob = null;
if (start + chunkSize > size) {
blob = file.slice(start, size);
} else {
blob = file.slice(start, start + chunkSize)
}
start += chunkSize;
let blobFile = new File([blob], name);
let formData = new FormData();
formData.append('file', blobFile);
formData.append('index', index);
function uploadfilebig() {
return new Promise(resolve => {
fetch('/api/uploadfilebig', {
method: 'post',
body: formData,
}).then(response => response.json())
.then((data) => {
// console.log(data);
let percent = parseInt((start / size) * 100);
document.getElementById('file-big-percent').innerText = percent
return resolve(data)
});
})
}
const {
data
} = await uploadfilebig()
resultFileDir = data;
// console.log(data)
index += 1;
}
function uploadfilebigInspect(name) {
return new Promise(resolve => {
fetch('/api/uploadfilebig-inspect', {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name
})
}).then(response => response.json())
.then((data) => {
return resolve(data)
});
})
}
function uploadfilebigMerge(dir, ext) {
return new Promise(resolve => {
fetch('/api/uploadfilebig-merge', {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
dir,
ext,
})
}).then(response => response.json())
.then((data) => {
console.log(data);
percent = Number((start / size) * 100).toFixed(0);
document.getElementById('file-big-percent').innerText = percent
return resolve(data)
});
})
}
let extList = name.split('.');
let ext = extList[extList.length - 1];
const {
data
} = await uploadfilebigMerge(resultFileDir, ext);
console.log('文件路径:', data)
}
}
</script>
</body>
</html>
3、用到的工具函数util.ts
/**
* 生成随机 字符串
* @description 生成随机字符串给文件命名
* @returns {string}
*/
export function randomStr(length: number = 16): string {
const seeder = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'; // 默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1
let str = ''
for (let i = 0; i < length; i++) {
str += seeder.charAt(Math.floor(Math.random() * seeder.length))
}
str += String(new Date().getTime()) // 添加时间戳
return str;
}
/* 时间转换成日期格式 */
export function dateFormart(fmt: string, date: any = new Date()) {
if (/(y+)/.test(fmt)) {
fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length))
}
let o = {
'M+': date.getMonth() + 1,
'd+': date.getDate(),
'h+': date.getHours(),
'm+': date.getMinutes(),
's+': date.getSeconds()
}
for (let k in o) {
if (new RegExp(`(${k})`).test(fmt)) {
// @ts-ignore
let str = o[k] + ''
fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? str : padLeftZero(str))
}
}
return fmt
}
function padLeftZero(str: string) {
return ('00' + str).substr(str.length)
}
export function isJSON(str: string) {
if (typeof str === 'string') {
try {
let obj = JSON.parse(str);
if (typeof obj === 'object' && obj) {
return true;
} else {
return false;
}
} catch (e) {
console.log('error:' + str + '!!!' + e);
return false;
}
}
// console.log('It is not a string!')
}
四、在路由中添加接口路径
路径:/router/upload.ts
import koaRouter from 'koa-router'
import UploadController from '../controller/UploadController'
const router = new koaRouter()
router.prefix('/api')
router.post('/uploadfiles', UploadController.uploadfiles)
router.post('/deletefiles', UploadController.deletefiles)
router.post('/uploadfilebig', UploadController.uploadfilebig)
router.post('/uploadfilebig-merge', UploadController.uploadfilebigMerge)
router.post('/uploadfilebig-inspect', UploadController.uploadfilebigInspect)
export default router