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>