# Ant大文件自定义上传

<!-- a-upload -->
:customRequest="customRequest"

# 接口

// 请求的三个接口
// 检查文件是否被上传
interface CheckResponse {
    uploaded: boolean;
    uploadedList: string[];
    url?: string;
}
interface ICheckParams {
    ext: string; // 文件扩展 如 mp4
    hash: string; // 文件hash
}
export function checkFile(data: ICheckParams) {
    return post<CheckResponse>(Api.checkFile, {}, data);
}

// 上传切片文件
export function uploadChunk(formData: FormData, onUploadProgressCallback?: Function) {
    return upload<any>(Api.uploadChunk, formData, onUploadProgressCallback);
}

// 合并文件
export function mergeFile(data) {
    return post<any>(Api.mergeFile, {},data);
}

# 逻辑代码

import type { UploadRequestOption } from "ant-design-vue/es/vc-upload/interface";
import sparkMD5 from "spark-md5";

const DEFAULT_CHUNK_SIZE: number = 2 * 1024 * 1024; // 默认切片大小为2M
interface FileChunk {
    hash: string;
    name: string;
    index: number;
    chunk: Blob;
    progress: number;
    total: number; // 总块数
}

// 计算文件hash
const calcFileHash = async (file: File): Promise<string> => {
    return new Promise(resolve => {
        const spark = new sparkMD5.ArrayBuffer();
        const reader = new FileReader();
        const size = file.size;
        const offset = 2 * 1024 * 1024;
        const chunks = [file.slice(0, offset)];
        let cur = offset;
        while (cur < size) {
            if (cur + offset >= size) {
                // 最后一个区块
                chunks.push(file.slice(cur, cur + offset));
            } else {
                // 中间的区块
                const mid = cur + offset / 2;
                const end = cur + offset;
                chunks.push(file.slice(cur, cur + 2));
                chunks.push(file.slice(mid, mid + 2));
                chunks.push(file.slice(end - 2, end));
            }
            cur += offset;
        }
        reader.readAsArrayBuffer(new Blob(chunks));
        reader.onload = e => {
            spark.append(e?.target?.result as ArrayBuffer);
            resolve(spark.end());
        };
    });
};

// 文件切片
const handleFileChunk = (file: Blob, chunkSize: number = DEFAULT_CHUNK_SIZE, hash: string): FileChunk[] => {
    const fileChunkList: FileChunk[] = [];
    let cur = 0;
    const maxLen = Math.ceil(file.size / chunkSize);
    while (cur < maxLen) {
        const start = cur * chunkSize;
        const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
        fileChunkList.push({ index: cur, chunk: file.slice(start, end), name: `${hash}-${cur}`, progress: 0, hash, total: maxLen });
        cur++;
    }
    return fileChunkList;
};

/**
 * 上传切片
 * @param {*} uploadedList 已上传切片列表
 * @param {*} chunks 所有切片列表
 * @param {*} fileInfo {ext:string,hash:string,name:string}
 * @param {UploadRequestOption} params a-upload 自定义上传请求对象
 */
const uploadChunks = (uploadedList, chunks, fileInfo, params) => {
    const requests = chunks
        .filter(chunk => !uploadedList.includes(chunk.name))
        .map((chunk, index) => {
            const form = new FormData();
            form.append("chunk", chunk.chunk);
            form.append("hash", chunk.hash);
            form.append("name", chunk.name);
            form.append("total", chunk.total);
            return { form, index: index, error: 0 };
        });
    const sendRequest = (limit = 1) => {
        let count = 0;
        let isStop = false;
        const len = requests.length;
        return new Promise((resolve, reject) => {
            const upLoadReq = async () => {
                if (isStop) return;
                const req = requests.shift();
                if (!req) return;
                const { form, index } = req;
                uploadChunk(form, (p: number = 0) => {
                    chunks[index].progress = p;
                    let percent = 0;
                    if (chunks.length === 1) {
                        percent = p;
                        params.onProgress({ percent });
                    } else {
                        percent = Math.floor((count / (chunks.length - 1)) * 100);
                    }
                    params.onProgress({ percent });
                })
                    .then(() => {
                        if (count == len - 1) {
                            resolve(true);
                        } else {
                            count++;
                            upLoadReq();
                        }
                    })
                    .catch(err => {
                        chunks[index].progress = -1;
                        if (req.error < 3) {
                            req.error++;
                            requests.unshift(req);
                            upLoadReq();
                        } else {
                            isStop = true;
                            reject();
                        }
                    });
            };
            while (limit > 0) {
                setTimeout(() => {
                    upLoadReq();
                }, Math.random() * 2000);
                limit--;
            }
        });
    };

    sendRequest(3).then(async res => {
        const { data } = await mergeFile({ size: DEFAULT_CHUNK_SIZE, ...fileInfo });
    });
};


// 自定义上传
const customRequest = async (params: UploadRequestOption) => {
    let file = params.file as File;
    // 计算文件hash
    const hash = await calcFileHash(params.file as File);
    // 文件切片
    let chunks = handleFileChunk(params.file as File, DEFAULT_CHUNK_SIZE, hash);
    // 检查是否已经上传
    const ext: string = file.name.split(".").pop() || "";
    const { data } = await checkFile({ hash, ext });
    if (data.uploaded) {
        message.success("秒传成功");
        if (params.onSuccess) params.onSuccess(data);
        return;
    }

    chunks = chunks.map((chunk, index) => {
        let name = hash + "-" + index;
        const isChunkUploaded = data.uploadedList.includes(name) ? true : false;
        return {
            index,
            hash,
            name,
            chunk: chunk.chunk,
            total: chunk.total,
            progress: isChunkUploaded ? 100 : 0,
        };
    });

    // 上传未上传的切片
    let fileInfo = { ext, name: file.name, hash };
    uploadChunks(data.uploadedList, chunks, fileInfo, params);
};

# 后端代码

使用koa-generator 快速生成的项目结构


// app.js
const Koa = require("koa");
const app = new Koa();
const views = require("koa-views");
const json = require("koa-json");
const onerror = require("koa-onerror");
const bodyparser = require("koa-bodyparser");
const logger = require("koa-logger");

const file = require("./routes/file");
// 错误处理
onerror(app);

const multer = require("@koa/multer");
const upload = multer({ dest: "uploads/" });
app.use(upload.single("chunk"));

// 中间件
app.use(
  bodyparser({
    enableTypes: ["json", "form", "text"],
  })
);
app.use(json());
app.use(logger());
app.use(require("koa-static")(__dirname + "/public"));

app.use(
  views(__dirname + "/views", {
    extension: "pug",
  })
);

// 日志
app.use(async (ctx, next) => {
  const start = new Date();
  await next();
  const ms = new Date() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

// 路由
app.use(file.routes(), file.allowedMethods());

// 错误处理
app.on("error", (err, ctx) => {
  console.error("server error", err, ctx);
});

module.exports = app;


// routes/file.js
const router = require("koa-router")();
const path = require("path");
// router.prefix("/file");
const fse = require("fs-extra");

const UPLOAD_DIR = path.resolve(__dirname, "../uploads");

// 获取已上传文件列表
const getUploadedList = async (dirPath) => {
  return fse.existsSync(dirPath)
    ? (await fse.readdir(dirPath)).filter((name) => name[0] !== ".")
    : [];
};

/**
 * @api {post} /file/checkfile 检查文件是否已上传
 * @apiName 检查文件是否已上传
 * @apiGroup File
 *
 * @apiBody  {String} [ext] 文件扩展名 eg: mp4.
 * @apiBody  {String} [hash] 文件hash.
 *
 * @apiSuccess {Number} code 状态码.
 * @apiSuccess {Object} data 返回结果.
 * @apiSuccess {Boolean} uploaded 标识文件是否已上传
 * @apiSuccess {Array} uploadedList 已上传切片文件
 *
 * @apiParamExample {json} Request Body Example:
 *     {
 *       "ext": "",
 *       "hash": ""
 *     }
 *
 * @apiSuccessExample {json} Success Response:
 *     HTTP/1.1 200 OK
 *     {
 *       "code": 0,
 *       "data": {
 *         "uploaded": true,
 *         "uploadedList": [
 *              "7d3dc89d0a12fc0bae9dc7c85696a6fc-0",
 *              "7d3dc89d0a12fc0bae9dc7c85696a6fc-1",
 *          ]
 *       }
 *     }
 *
 * @apiErrorExample {json} Error Response:
 *     HTTP/1.1 404 Not Found
 *     {
 *       "code": 1,
 *       "message": "File not found"
 *     }
 */
router.post("/checkfile", async (ctx) => {
  const body = ctx.request.body;
  const { ext, hash } = body;
  const filePath = path.resolve(UPLOAD_DIR, `${hash}.${ext}`);
  let uploaded = false;
  let uploadedList = [];

  if (fse.existsSync(filePath)) {
    uploaded = true;
  } else {
    uploadedList = await getUploadedList(path.resolve(UPLOAD_DIR, hash));
  }
  ctx.body = {
    code: 0,
    data: {
      uploaded,
      uploadedList,
    },
  };
});

/**
 * @api {post} /file/uploadChunk 上传切片
 * @apiName UploadChunk
 * @apiGroup File
 *
 * @apiBody {String} hash 文件哈希值
 * @apiBody {String} name 文件名
 * @apiBody {Number} total 切片总数
 *
 * @apiSuccess {Number} code 返回代码(0为成功,-1为失败)
 * @apiSuccess {String} message 返回消息
 *
 * @apiSuccessExample Success-Response:
 *     HTTP/1.1 200 OK
 *     {
 *       "code": 0,
 *       "message": "切片上传成功"
 *     }
 *
 * @apiErrorExample Error-Response:
 *     HTTP/1.1 200 OK
 *     {
 *       "code": -1,
 *       "message": "所有切片已上传"
 *     }
 */
router.post("/uploadChunk", async (ctx) => {
  const body = ctx.request.body;
  const file = ctx.request.file;
  const { hash, name, total } = body;
  const chunkPath = path.resolve(UPLOAD_DIR, hash);
  if (!fse.existsSync(chunkPath)) {
    await fse.mkdir(chunkPath);
  }
  uploadedList = await getUploadedList(chunkPath);

  if (uploadedList.length == total) {
    return (ctx.body = {
      code: -1,
      message: `所有切片已上传`,
    });
  }

  await fse.move(file.path, `${chunkPath}/${name}`);

  ctx.body = {
    code: 0,
    message: `切片上传成功`,
  };
});

// 合并文件
function mergeChunks(files, dest, CHUNK_SIZE) {
  const pipeStream = (filePath, writeStream) => {
    return new Promise((resolve, reject) => {
      const readStream = fse.createReadStream(filePath);
      readStream.on("end", () => {
        fse.unlinkSync(filePath);
        resolve();
      });
      readStream.pipe(writeStream);
    });
  };

  const pipes = files.map((file, index) => {
    return pipeStream(
      file,
      fse.createWriteStream(dest, {
        start: index * CHUNK_SIZE,
        end: (index + 1) * CHUNK_SIZE,
      })
    );
  });
  return Promise.all(pipes);
}
async function mergeFile(filePath, size, hash) {
  const chunkDir = path.resolve(UPLOAD_DIR, hash);
  let chunks = await fse.readdir(chunkDir);
  chunks = chunks.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
  chunks = chunks.map((cpath) => path.resolve(chunkDir, cpath));
  await mergeChunks(chunks, filePath, size);
}

/**
 * @api {post} /mergeFile 合并文件
 * @apiName MergeFile
 * @apiGroup File
 *
 * @apiBody {String} ext 文件扩展名
 * @apiBody {Number} size 文件总大小
 * @apiBody {String} hash 文件哈希值
 * @apiBody {String} name 文件名
 *
 * @apiSuccess {Number} code 返回代码(0为成功,-1为失败)
 * @apiSuccess {Boolean} data 合并是否成功
 *
 * @apiSuccessExample Success-Response:
 *     HTTP/1.1 200 OK
 *     {
 *       "code": 0,
 *       "data": true
 *     }
 */
router.post("/mergeFile", async (ctx) => {
  const body = ctx.request.body;
  const { ext, size, hash, name } = body;
  //文件最终路径
  const filePath = path.resolve(UPLOAD_DIR, `${name}`);
  await mergeFile(filePath, size, hash);
  ctx.body = {
    code: 0,
    data: true,
  };
});

module.exports = router;

Last Updated: 5/18/2023, 10:28:42 PM