Skip to content

Vue.js 和 Nest.js 文件上传方案

Published:

文件上传业务

  1. 前端发送文件名、MD5,、文件大小等信息提交后端文件服务。多文件多请求
const { getUploadUrl } = useFileUpload();

async function onFileSelection(file: File) {
  // ... 一些字段处理
  await getUploadUrl(file);
}
  1. 后端文件服务从 OBS 获取对应文件上传 URL,该 URL 短期有效,且只能上传该 MD5、文件名的文件,上传目录为临时目录。
// file.service.ts
@Controller('file')
export class FileController {
  constructor(private readonly fileService: FileService) {}
}
async getUploadUrl(fileName: string, fileSize: number, md5: string) {
  // ...生成上传URL逻辑...
  return {
    url,
    key
  }
}
  1. 后端文件服务返回上传URL与后端数据库唯一 Key 给前端。
  2. 前端通过 URL 上传文件,通过 key 提交后到后端,通知文件上传完成。
  3. 前端通过业务表单,表单中文件部分用文件 key 表示。
  4. 后端获取 key 后,发送请求值文件服务,文件服务请求 OBS 把文件从临时文件夹移动到到具体业务文件夹。

文件查看业务

  1. 前端请求业务内容,业务内容包括文件内容。
  2. 后端通过数据库中存储的 Key 调用文件服务获取 URL。
  3. 文件服务通过 key 查询具体存放路径和文件名,从 OBS 获取临时 URL,URL 会在一定时间内过期。
  4. 文件服务通过控制器返回 URL 给前端。
  5. 前端通过 URL 获取内容。

一些伪代码

// useFileUpload.ts
import { ref } from "vue";
import axios from "axios";
import { MD5 } from "crypto";

export function useFileUpload() {
  const uploadUrl = ref<string>("");
  const fileKey = ref<string>("");
  const isUploading = ref<boolean>(false);

  // 计算文件的MD5值
  async function calculateFileMD5(file: File): Promise<string> {
    const arrayBuffer = await file.arrayBuffer();
    const wordArray = MD5(arrayBuffer);
    return wordArray.toString();
  }

  // 获取上传URL和文件Key
  async function getUploadUrl(file: File) {
    const { name, size } = file;
    const md5 = await calculateFileMD5(file);

    const response = await axios.post("/api/file/upload-url", {
      fileName: name,
      fileSize: size,
      md5: md5,
    });

    uploadUrl.value = response.data.uploadUrl;
    fileKey.value = response.data.key;
  }

  // 上传文件到获取的URL
  async function uploadFile(file: File) {
    if (!uploadUrl.value) return;
    isUploading.value = true;

    const formData = new FormData();
    formData.append("file", file);

    await axios.put(uploadUrl.value, formData, {
      headers: {
        "Content-Type": file.type,
      },
    });

    isUploading.value = false;
  }

  // 通知后端文件上传完成
  async function notifyUploadCompletion() {
    if (!fileKey.value) return;

    await axios.post("/api/file/upload-complete", {
      key: fileKey.value,
    });
  }

  return {
    getUploadUrl,
    uploadFile,
    notifyUploadCompletion,
    fileKey,
    isUploading,
  };
}
// dto.ts
export class GetUploadUrlDto {
  fileName: string;
  fileSize: number;
  md5: string;
}

export class UploadCompleteDto {
  key: string;
}

// file.service.ts
import { Injectable, Inject } from "@nestjs/common";
import { Client, InjectMinio } from "minio";

@Injectable()
export class FileService {
  constructor(
    @InjectMinio(MINIO_CONNECTION) private readonly minioClient: Minio.Client
  ) {}

  // 获取上传URL
  async getUploadUrl({ fileName, fileSize, md5 }: GetUploadUrlD) {
    const bucketName = "temporary-bucket";
    const objectName = `temp/${md5}_${fileName}`;
    const metaData = {
      "Content-Type": "application/octet-stream",
      "Content-Length": fileSize.toString(),
      "Content-MD5": md5,
    };

    // 生成临时上传URL
    const uploadUrl = await this.minioClient.presignedPutObject(
      bucketName,
      objectName,
      300
    ); // 5分钟有效期
    const key = `${md5}_${fileName}`;

    // 假设你有一个方法存储Key到数据库
    await this.storeKeyInDatabase(key);

    return { uploadUrl, key };
  }

  // 处理文件上传完成
  async handleUploadCompletion(key: string) {
    const srcBucket = "temporary-bucket";
    const destBucket = "business-bucket";
    const srcObject = `temp/${key}`;
    const destObject = `business/${key}`;

    // 进行文件复制和移动 (伪代码)
    await this.minioClient.copyObject(
      destBucket,
      destObject,
      `/${srcBucket}/${srcObject}`,
      {
        // 复制对象时也可以设置一些元数据
      }
    );

    // 删除源文件
    await this.minioClient.removeObject(srcBucket, srcObject);

    // 更新数据库状态
    await this.updateFileStatusInDatabase(key, "moved");
  }

  // 存储Key到数据库(伪代码)
  async storeKeyInDatabase(key: string) {
    // 存储到数据库逻辑
  }

  // 更新文件状态到数据库(伪代码)
  async updateFileStatusInDatabase(key: string, status: string) {
    // 更新数据库逻辑
  }
}
<template>
  <div>
    <input type="file" @change="handleFileChange" />
    <button @click="submitForm" :disabled="isUploading">Submit</button>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import { useFileUpload } from "./useFileUpload";

export default defineComponent({
  setup() {
    const {
      getUploadUrl,
      uploadFile,
      notifyUploadCompletion,
      fileKey,
      isUploading,
    } = useFileUpload();
    const selectedFile = ref<File | null>(null);

    // 处理文件选择
    const handleFileChange = async (event: Event) => {
      const input = event.target as HTMLInputElement;
      if (input.files && input.files.length > 0) {
        selectedFile.value = input.files[0];
        await getUploadUrl(selectedFile.value);
      }
    };

    // 提交表单逻辑
    const submitForm = async () => {
      if (selectedFile.value) {
        await uploadFile(selectedFile.value);
        await notifyUploadCompletion();
        // 提交业务表单逻辑
        const formData = {
          // 其他业务表单数据
          key: fileKey.value,
        };
        await axios.post("/api/xxx", formData);
      }
    };

    return {
      handleFileChange,
      submitForm,
      isUploading,
    };
  },
});
</script>