koa+ts+mysql后台开发——(七)文件上传、多文件上传,大文件切片上传


前言

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
  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值