Skip to content

Nest 使用阿里云 OSS 上传

客户端直传

服务端生成 STS 临时访问凭证(本文使用)

简单来说 就是 客户调用服务端接口获取临时访问凭证,然后通过这个临时凭证访问 OSS(支持分片)

服务端生成 PostObject 所需的签名和 Post Policy

需要注意的是,此方案不支持基于分片上传大文件、基于分片断点续传的场景

服务端生成 PutObject 所需的签名 URL

此方案不适用于基于分片上传大文件、基于分片断点续传的场景。在服务端对每个分片生成签名 URL,并将签名 URL 返回给客户端,会增加与服务端的交互次数和网络请求的复杂性

流程

  • (1) 客户端向业务服务器请求临时访问凭证。

  • (2) 业务服务器使用 STS SDK 调用 AssumeRole 接口,获取临时访问凭证。

  • (3) STS 生成并返回临时访问凭证给业务服务器。

  • (4) 业务服务器返回临时访问凭证给客户端。

  • (5) 客户端使用 OSS SDK 通过该临时访问凭证上传文件到 OSS。

  • (6) OSS 返回成功响应给客户端。

前期准备

参照 用户角色权限开启权限分配

后端出获取临时凭证接口

安装依赖

bash
pnpm i ali-oss -S

写接口

  • ConfigService 配置文件
bash
# 阿里云cos配置
cos:
  secretId: 'xxx'
  secretKey: 'yyy'
  arn: 'zzzz'
  bucket: ''
  region: ''
  domain: ''
  location: ''
  • 接口文件
ts
import { Controller, Get } from "@nestjs/common";
// 引入阿里云
import { STS } from "ali-oss";
import { ConfigService } from "@nestjs/config";
@Controller("upload")
export class UploadController {
  constructor(private readonly configService: ConfigService) {}

  @Get("getsts")
  async getsts() {
    // 存到redis 里面 要是过期就自动删除 没过期就发送请求
    let configoss = this.configService.get("cos");
    let secretId = configoss.secretId;
    let secretKey = configoss.secretKey;
    let roleArn = configoss.arn;
    let sts = new STS({
      accessKeyId: secretId,
      accessKeySecret: secretKey,
    });
    /* 第一个参数 就是RAM 角色权限 */
    /* 第二个参数 就是自定义权限策略,用于进一步限制STS临时访问凭证的权限。如果不指定Policy,则返回的STS临时访问凭证默认拥有指定角色的所有权限。 */
    /* 第三个参数 就是 临时凭证的生效时间,单位是秒,最小值900,本示例指定3600秒 */
    /* 第四个参数 用于自定义角色会话名称,用来区分不同的令牌,例如填写为sessiontest。*/
    const result = await sts.assumeRole(roleArn, ``, "3600", "sessiontest");
    if (result.credentials) {
      return {
        data: {
          AccessKeyId: result.credentials.AccessKeyId,
          AccessKeySecret: result.credentials.AccessKeySecret,
          SecurityToken: result.credentials.SecurityToken,
          Expiration: result.credentials.Expiration,
        },
      };
    } else {
      return {
        code: 403,
        data: result,
      };
    }
  }
}
  • 最后返回格式
ts
{
  "code": 200,
  "message": "操作成功",
  "data": {
    "AccessKeyId": "STS.xxxx",
    "AccessKeySecret": "xx",
    "SecurityToken": "yyyy",
    "Expiration": "2025-09-16T13:35:34Z"
  }
}

前端使用

安装依赖

bash
pnpm i ali-oss -S

创建工具类

  • 新建一个 utils/oss.ts
ts
import OSS from "ali-oss";
import axios from "axios";
/**
 * 创建oss客户端对象
 * @returns {*}
 */
function createOssClient(region, bucket) {
  // 这里就是调取后端的getsts接口 获取到数据
  const AccessKeyId = "生成的AccessKeyId";
  const AccessKeySecret = "生成的Secret";
  const SecurityToken = "生成的SecurityToken";

  return new Promise((resolve, reject) => {
    const client = new OSS({
      endpoint: "https://xxx.com", // 你自己的自定义域名
      cname: true, // 是否使用自定义域名
      // yourRegion填写Bucket所在地域。以华东1(杭州)为例,yourRegion填写为oss-cn-hangzhou
      region: region,
      // 填写步骤1.5生成的临时访问密钥AccessKey ID和AccessKey Secret,非阿里云账号AccessKey ID和AccessKey Secret
      accessKeyId: AccessKeyId,
      accessKeySecret: AccessKeySecret,
      // 填写步骤1.5生成的STS安全令牌(SecurityToken)
      stsToken: SecurityToken,
      authorizationV4: true,
      // 填写Bucket名称
      bucket: bucket,
      // 刷新临时访问凭证
      refreshSTSToken: async () => {
        const refreshToken = await axios.get(
          "http://localhost:5000/upload/getsts"
        );
        return {
          accessKeyId: refreshToken.data.AccessKeyId,
          accessKeySecret: refreshToken.data.AccessKeySecret,
          stsToken: refreshToken.data.SecurityToken,
        };
      },
    });
    resolve(client);
  });
}

/**
 * 生成上传文件名
 * @param name 原始文件名称
 * @param perfix 模块前缀
 */
function createObjectName(name, perfix) {
  const year = dateFormat(new Date(), "yyyy");
  const month = dateFormat(new Date(), "MM");
  const randomStr = randomString(18);
  console.log(name);
  const extensionName = name.substr(name.lastIndexOf(".")); // 文件扩展名
  const rootDir = "testupload";
  const fileName =
    rootDir +
    "/" +
    perfix +
    "/" +
    year +
    "/" +
    month +
    "/" +
    randomStr +
    extensionName; // 文件名字(相对于根目录的路径 + 文件名)
  return fileName;
}

/**
 * 创建随机字符串
 * @param num
 * @returns {string}
 */
const randomString = (num) => {
  const chars = [
    "0",
    "1",
    "2",
    "3",
    "4",
    "5",
    "6",
    "7",
    "8",
    "9",
    "a",
    "b",
    "c",
    "d",
    "e",
    "f",
    "g",
    "h",
    "i",
    "j",
    "k",
    "l",
    "m",
    "n",
    "o",
    "p",
    "q",
    "r",
    "s",
    "t",
    "u",
    "v",
    "w",
    "x",
    "y",
    "z",
  ];
  let res = "";
  for (let i = 0; i < num; i++) {
    var id = Math.ceil(Math.random() * 35);
    res += chars[id];
  }
  return res;
};

/**
 * 时间日期格式化
 * @param format
 * @returns {*}
 */
export const dateFormat = function (dateObj, format) {
  const date = {
    "M+": dateObj.getMonth() + 1,
    "d+": dateObj.getDate(),
    "h+": dateObj.getHours(),
    "m+": dateObj.getMinutes(),
    "s+": dateObj.getSeconds(),
    "q+": Math.floor((dateObj.getMonth() + 3) / 3),
    "S+": dateObj.getMilliseconds(),
  };
  if (/(y+)/i.test(format)) {
    format = format.replace(
      RegExp.$1,
      (dateObj.getFullYear() + "").substr(4 - RegExp.$1.length)
    );
  }
  for (const k in date) {
    if (new RegExp("(" + k + ")").test(format)) {
      format = format.replace(
        RegExp.$1,
        RegExp.$1.length === 1
          ? date[k]
          : ("00" + date[k]).substr(("" + date[k]).length)
      );
    }
  }
  return format;
};

/**
 * 大数据分片文件上传
 * @param file
 * @param fn
 * @returns {Promise<unknown>}
 */
export function BiguploadFile(
  file,
  fn,
  perfix,
  region = "oss-cn-beijing",
  bucket = "jsopy-upload-oss"
) {
  return new Promise((resolve, reject) => {
    const fileName = createObjectName(file.name, perfix, bucket);
    // 执行上传
    createOssClient(region, bucket).then((client) => {
      // 分片上传文件
      client
        .multipartUpload(fileName, file, {
          progress: fn,
        })
        .then(
          (val) => {
            if (val.res.statusCode === 200) {
              resolve(val);
            } else {
              reject(val);
            }
          },
          (err) => {
            reject(err);
          }
        );
    });
  });
}

/**
 * 小数据单独上传
 * @param file
 * @param fn
 * @returns {Promise<unknown>}
 */
export function uploadFile(
  file,
  fn,
  perfix,
  region = "oss-cn-beijing",
  bucket = "jsopy-upload-oss"
) {
  return new Promise((resolve, reject) => {
    const fileName = createObjectName(file.name, perfix, bucket);
    // 执行上传
    createOssClient(region, bucket).then((client) => {
      // 分片上传文件
      client
        .put(fileName, file, {
          progress: fn,
        })
        .then(
          (val) => {
            if (val.res.statusCode === 200) {
              resolve(val);
            } else {
              reject(val);
            }
          },
          (err) => {
            reject(err);
          }
        );
    });
  });
}

页面使用

html
<template>
  <h1>图片上传</h1>

  <div class="upload-container" id="dropZone">
    <div class="upload-icon">📷</div>
    <p class="upload-text">拖放图片到此处或点击上传</p>
    <button class="upload-btn">选择图片</button>
    <input
      type="file"
      id="fileInput"
      class="file-input"
      multiple
      accept="image/*"
    />
  </div>
</template>

<script setup>
  import { onMounted } from "vue";
  import { BiguploadFile, uploadFile } from "@/utils/oss";

  onMounted(() => {
    document.addEventListener("DOMContentLoaded", function () {
      const dropZone = document.getElementById("dropZone");
      const fileInput = document.getElementById("fileInput");
      const uploadBtn = document.querySelector(".upload-btn");
      const previewContainer = document.getElementById("previewContainer");
      const progressContainer = document.getElementById("progressContainer");

      // 这里替换为你的上传URL
      const uploadUrl = "https://example.com/upload";

      // 点击上传按钮触发文件选择
      uploadBtn.addEventListener("click", () => {
        fileInput.click();
      });

      // 点击上传区域触发文件选择
      dropZone.addEventListener("click", (e) => {
        if (e.target !== uploadBtn) {
          fileInput.click();
        }
      });

      // 文件选择变化处理
      fileInput.addEventListener("change", handleFiles);

      // 拖放事件处理
      dropZone.addEventListener("dragover", (e) => {
        e.preventDefault();
        dropZone.classList.add("dragover");
      });

      dropZone.addEventListener("dragleave", () => {
        dropZone.classList.remove("dragover");
      });

      dropZone.addEventListener("drop", (e) => {
        e.preventDefault();
        dropZone.classList.remove("dragover");

        if (e.dataTransfer.files.length) {
          handleFiles({ target: { files: e.dataTransfer.files } });
        }
      });

      // 处理文件
      async function handleFiles(e) {
        const files = e.target.files;

        if (!files.length) return;
        // 上传文件
        for (let i = 0; i < files.length; i++) {
          // await uploadFilesAll(files[i]);
          await uploadFilesBig(files[i]);
        }
      }

      // 上传小文件
      async function uploadFilesAll(files) {
        const result = await uploadFile(
          files,
          () => {
            console.log("2");
          },
          "cb"
        );
        console.log(result);
        console.log(result.url);
      }

      // 上传大文件
      async function uploadFilesBig(files) {
        const result = await BiguploadFile(
          files,
          () => {
            console.log("2");
          },
          "cb"
        );
        console.log(result);
        if (result.res.status === 200) {
          console.log(result.res.requestUrls[0]);
        }
      }
    });
  });
</script>

<style>
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  body {
    font-family: "Arial", sans-serif;
    line-height: 1.6;
    color: #333;
    max-width: 800px;
    margin: 0 auto;
    padding: 20px;
  }

  .upload-container {
    border: 2px dashed #ccc;
    border-radius: 8px;
    padding: 40px 20px;
    text-align: center;
    transition: all 0.3s;
    cursor: pointer;
    background-color: #f9f9f9;
  }

  .upload-container:hover,
  .upload-container.dragover {
    border-color: #4a90e2;
    background-color: #f0f7ff;
  }

  .upload-icon {
    font-size: 48px;
    color: #ccc;
    margin-bottom: 15px;
  }

  .upload-text {
    margin-bottom: 10px;
    color: #666;
  }

  .file-input {
    display: none;
  }

  .upload-btn {
    background-color: #4a90e2;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 4px;
    cursor: pointer;
    font-size: 16px;
    transition: background-color 0.3s;
  }

  .upload-btn:hover {
    background-color: #3a7bc8;
  }

  .preview-container {
    margin-top: 20px;
    display: flex;
    flex-wrap: wrap;
    gap: 15px;
  }

  .preview-item {
    position: relative;
    width: 150px;
    height: 150px;
    border-radius: 4px;
    overflow: hidden;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
  }

  .preview-image {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .preview-remove {
    position: absolute;
    top: 5px;
    right: 5px;
    background-color: rgba(0, 0, 0, 0.5);
    color: white;
    border: none;
    border-radius: 50%;
    width: 24px;
    height: 24px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    font-size: 12px;
  }

  .progress-container {
    margin-top: 20px;
    display: none;
  }

  .progress-bar {
    height: 10px;
    background-color: #eee;
    border-radius: 5px;
    overflow: hidden;
  }

  .progress {
    height: 100%;
    background-color: #4a90e2;
    width: 0;
    transition: width 0.3s;
  }

  .status-message {
    margin-top: 10px;
    padding: 10px;
    border-radius: 4px;
    text-align: center;
    display: none;
  }

  .success {
    background-color: #d4edda;
    color: #155724;
  }

  .error {
    background-color: #f8d7da;
    color: #721c24;
  }
</style>