Skip to content

Nest 实现断点续传

目前来说 只要页面不刷新.断点续传能实现.页面要是刷新了.chunks 里面全没了

注意

断点续传 = 单图上传 + 大文件分片上传

前端

html

html
<!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://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
    <script src="./upload.js"></script>
  </head>
  <body>
    <input id="fileInput" type="file" />
    <button onclick="pause()">暂停</button>
    <button onclick="jixu()">继续</button>
    <script>
      const fileInput = document.querySelector("#fileInput");

      // 封装上传方法
      fileInput.addEventListener("change", () => {
        uploadfile();
      });
    </script>
  </body>
</html>

upload.js

js
// 把文件切割
const publicqiege = async () => {
  const chunkSize = 1024 * 20 * 1024; //10M
  const file = fileInput.files[0] || sessionStorage.getItem("fileSize"); // 获取文件
  // 分片上传
  let chunks = [];
  // 开始位置
  let startPos = 0;
  // 循环分片
  while (startPos < file.size) {
    chunks.push(file.slice(startPos, startPos + chunkSize));
    startPos += chunkSize;
  }
  sessionStorage.setItem("fileSize", JSON.stringify(file.size));
  sessionStorage.setItem("filename", file.name);
  return chunks;
};

// 暂停方法
const pause = async () => {
  axios.cancle();
};

// 上传方法
const uploadfile = async () => {
  const randomStr = Math.random().toString().slice(2, 8);
  const chunks = await publicqiege();
  let names = randomStr + "_" + sessionStorage.getItem("filename");
  sessionStorage.setItem("upload_name", names);

  // 循环任务
  const tasks = [];
  chunks.map((chunk, index) => {
    const data = new FormData();
    data.set(
      "name",
      randomStr + "_" + sessionStorage.getItem("filename") + "-" + index
    );
    data.append("files", chunk);
    tasks.push(axios.post("http://localhost:5000/user/upload", data));
  });
  await Promise.all(tasks);

  // axios.get(
  //   "http://localhost:5000/user/merge?name=" + randomStr + "_" + file.name
  // );
};

/* 继续 */
const jixu = async () => {
  // 少的分片汇集
  let upload_arr = [];
  // 少的分片序号
  let upload_index = [];
  // 获取到名字
  let name = sessionStorage.getItem("upload_name");
  // 获取到初始分割
  const chunks = await publicqiege();
  // 后端查询到是否缺少分片
  const { data } = await axios.get(
    "http://localhost:5000/user/checkchunks?name=" +
      name +
      "&" +
      "chunkTotal=" +
      chunks.length
  );
  console.log(data);
  if (data.code == 200) {
    if (data.data.uploadStatus == "uploading") {
      data.data.chunkSignsArr.forEach((item, index) => {
        if (item == 0) {
          upload_arr.push(chunks[index]);
          upload_index.push(index);
        }
      });
    }

    uploadFile(upload_arr, upload_index);
  }

  /* 单独上传 */
  function uploadFile(upload_arr, upload_index) {
    console.log(upload_arr);
    console.log(upload_index);
    const files = upload_arr;
    let count = 0;
    // 添加其他字段,例如文件名
    // 遍历所有选中的文件,添加到FormData中
    let formData_arr = [];
    for (let i = 0; i < files.length; i++) {
      const formData = new FormData();
      formData.append(
        "modulename",
        sessionStorage.getItem("upload_name") + "$" + upload_index[i]
      );
      formData.append("file", files[i]);
      formData_arr.push(formData);
    }
    formData_arr.forEach((formData) => {
      // 使用fetch API发送数据到服务器
      fetch("http://localhost:5000/user/uploadless", {
        // 替换为你的服务器端点URL
        method: "POST",
        body: formData,
      })
        .then((response) => response.json()) // 假设服务器返回JSON格式的响应
        .then((data) => {
          if (data.code == 200) {
            console.log("上传成功");
            count++;
            if (count == files.length - 1) {
              axios.get(
                "http://localhost:5000/user/merge?name=" +
                  sessionStorage.getItem("upload_name")
              );
            }
          } else {
            console.log(data);
            console.log("上传失败");
          }
        })
        .catch((error) => {
          console.error("Error:", error);
        });
    });
  }
};

Nest

安装依赖

bash

npm install --save @nestjs/platform-express multer

npm install -D @types/multer

pnpm add @types/multer -D

先写单图上传

  • utils/storage.js
ts
import * as multer from "multer";
import * as path from "path";
import * as fs from "fs";

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const name = req.body.modulename.split("$");
    const dir = path.join(__dirname, "../../", `uploads/chunks_${name[0]}`);
    cb(null, dir);
  },
  filename: (req, file, cb) => {
    const name = req.body.modulename.split("$");
    let resultname = name[0] + "-" + name[1];
    return cb(null, `${resultname}`);
  },
});
/**
 * @param { delPath:String } (需要删除文件的地址)
 * @param { direct:Boolean } (是否需要处理地址)
 */
const deleteFile = (delPath, direct) => {
  delPath = direct ? delPath : path.join(__dirname, delPath);
  try {
    /**
     * @des 判断文件或文件夹是否存在
     */
    if (fs.existsSync(delPath)) {
      console.log(delPath);
      fs.unlinkSync(delPath);
    } else {
      console.log("inexistence path:", delPath);
    }
  } catch (error) {
    console.log("del error", error);
  }
};

export { storage, deleteFile };

控制器

  • upload 是主接口.分片使用.当他突然断了

  • checkchunks 是告诉你缺少哪个分片

  • uploadless 是单独上传分片用的

  • merge 合并接口

ts
import {
  Controller,
  Post,
  Body,
  UploadedFiles,
  UseInterceptors,
  Get,
  Query,
  Res,
} from "@nestjs/common";
import { UserService } from "./user.service";
import * as fs from "fs";
import { FilesInterceptor } from "@nestjs/platform-express";
import type { Response } from "express";
import { storage } from "../../utils/storage";
@Controller("user")
export class UserController {
  constructor(private readonly userService: UserService) {}

  // 上传接口
  @Post("upload")
  @UseInterceptors(
    FilesInterceptor("files", 20, {
      dest: "uploads",
    })
  )
  uploadFiles(
    @UploadedFiles() files: Array<Express.Multer.File>,
    @Body() body
  ) {
    console.log("body", body);
    console.log("files", files);
    // 将分片移动到单独的目录
    const fileName = body.name.match(/(.+)\-\d+$/)[1];
    const chunkDir = "uploads/chunks_" + fileName;
    if (!fs.existsSync(chunkDir)) {
      fs.mkdirSync(chunkDir);
    }
    fs.cpSync(files[0].path, chunkDir + "/" + body.name);
    fs.rmSync(files[0].path);
    return {
      data: "分片上传成功",
    };
  }

  // 检查接口
  /* 检查已上传文件或者切片 */
  @Get("checkchunks")
  checkChunks(
    @Query("name") name: string,
    @Query("chunkTotal") chunkTotal: string
  ) {
    // 获取切片文件夹、或文件
    const vo: any = {
      uploadStatus: "empty",
      chunkSignsArr: [], // 例子:[1,0,1,0] 表示,第 2、4 块切片没有上传
    };
    const chunkDir = "uploads/chunks_" + name;
    let directory;
    if (fs.existsSync(chunkDir)) {
      directory = fs.readdirSync(chunkDir);
      const chunkSignsArr = new Array<number>(Number(chunkTotal)).fill(0); // 一上来全填充0
      // 有文件夹,说明切片未完全上传,正序返回切片排序 (断点续传)
      if (directory?.length > 0) {
        directory.map((file) => {
          const idx = Number(file.split("-").at(-1)); // 取出最后的序号,分割号
          chunkSignsArr[idx] = 1;
        });
        if (chunkSignsArr.includes(0)) {
          // 包含0 证明有缺陷
          vo.uploadStatus = "uploading";
        } else {
          vo.uploadStatus = "success"; // 不包含证明都完事了
        }
      }

      vo.chunkSignsArr = chunkSignsArr;
    }

    return {
      data: vo,
    };
  }

  // 合并接口
  // 合并接口post
  @Get("merge")
  merge(@Query("name") name: string, @Res() res: Response) {
    console.log(name);
    const chunkDir = "uploads/chunks_" + name;

    const files = fs.readdirSync(chunkDir);
    files.sort((f1: string, f2: string) => {
      const idx1 = Number(f1.split("-").at(-1));
      const idx2 = Number(f2.split("-").at(-1));
      return idx1 < idx2 ? -1 : idx1 > idx2 ? 1 : 0;
    });
    console.log(files);
    // 定义开始位置
    let startPos = 0;

    files.forEach((file, index) => {
      const filePath = chunkDir + "/" + file;
      const outputFilePath = "uploads/" + name.split("_").at(1);
      const readStream = fs.createReadStream(filePath);
      const writeStream = fs.createWriteStream(outputFilePath, {
        start: startPos,
      });

      readStream.pipe(writeStream).on("finish", () => {
        if (index === files.length - 1) {
          fs.rm(chunkDir, { recursive: true }, () => {
            console.log("合并完成333");
            res.send({ data: "合并完成", code: 200 });
          });
        }
      });

      startPos += fs.statSync(filePath).size;
    });
  }

  // 单独上传接口
  // 上传
  @Post("uploadless")
  @UseInterceptors(
    // 独立自己的
    FilesInterceptor("file", 10, {
      storage: storage,
    })
  )
  async upload(@Body() body: any, files: Array<Express.Multer.File>) {
    return {
      data: {
        message: "上传成功",
      },
    };
  }
}