后端新增 OssUtils/OssTokenVo/OssCorsInitRunner,通过 STS 临时凭证实现客户端直传 OSS; 前端 upload API 适配直传流程,赛事创建/作品提交/作业/富文本编辑器均已切换; 多环境(dev/test/prod) OSS 配置补全;新增 oss-direct-upload-demo 示例项目及 E2E 测试。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
163 lines
4.1 KiB
TypeScript
163 lines
4.1 KiB
TypeScript
import axios from "axios";
|
||
import { buildOssDirPath } from "./env";
|
||
|
||
// ==================== 类型定义 ====================
|
||
|
||
/**
|
||
* OSS 直传 Token 响应
|
||
*/
|
||
export interface OssToken {
|
||
/** OSS 访问 ID */
|
||
accessid: string;
|
||
/** Base64 编码的 Policy */
|
||
policy: string;
|
||
/** 签名 */
|
||
signature: string;
|
||
/** 上传目录前缀 */
|
||
dir: string;
|
||
/** OSS 上传地址(https://bucketname.endpoint) */
|
||
host: string;
|
||
/** 完整文件路径(dir + 日期路径 + UUID文件名) */
|
||
key: string;
|
||
/** 过期时间(秒) */
|
||
expire: number;
|
||
}
|
||
|
||
/**
|
||
* 上传结果
|
||
*/
|
||
export interface UploadResult {
|
||
success: boolean;
|
||
filePath: string;
|
||
fileName: string;
|
||
fileSize: number;
|
||
mimeType: string;
|
||
}
|
||
|
||
// ==================== 配置 ====================
|
||
|
||
/**
|
||
* 后端 API 基础地址
|
||
* 根据你的项目修改此值
|
||
*/
|
||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";
|
||
|
||
// ==================== 核心方法 ====================
|
||
|
||
/**
|
||
* 获取阿里云 OSS 直传 Token
|
||
*
|
||
* @param fileName 文件名(如:图片.jpg)
|
||
* @param dir 业务目录(如:avatar, course/cover),会自动添加环境前缀
|
||
* @returns OSS 直传 Token
|
||
*/
|
||
export async function getOssToken(
|
||
fileName: string,
|
||
dir?: string,
|
||
): Promise<OssToken> {
|
||
// 自动添加环境前缀(dev/test/prod)
|
||
const fullDir = buildOssDirPath(dir);
|
||
|
||
const response = await axios.get<{ code: number; data: OssToken }>(
|
||
`${API_BASE_URL}/v1/files/oss/token`,
|
||
{
|
||
params: { fileName, dir: fullDir },
|
||
},
|
||
);
|
||
return response.data.data;
|
||
}
|
||
|
||
/**
|
||
* 直接上传文件到阿里云 OSS
|
||
*
|
||
* 使用 FormData + POST 方式直传,无需经过后端中转。
|
||
* 支持:进度回调、取消上传、超时设置。
|
||
*
|
||
* @param file 要上传的文件
|
||
* @param token OSS Token(通过 getOssToken 获取)
|
||
* @param options 可选配置
|
||
* @returns 上传后的文件 URL
|
||
*/
|
||
export async function uploadToOss(
|
||
file: File,
|
||
token: OssToken,
|
||
options?: {
|
||
/** 上传进度回调(0-100) */
|
||
onProgress?: (percent: number) => void;
|
||
/** 取消信号 */
|
||
signal?: AbortSignal;
|
||
/** 超时时间(毫秒),默认 5 分钟 */
|
||
timeout?: number;
|
||
},
|
||
): Promise<{ url: string }> {
|
||
const opts = options ?? {};
|
||
const formData = new FormData();
|
||
|
||
// 按照阿里云 OSS PostObject 要求构造表单
|
||
// 注意:file 必须为最后一个表单域
|
||
formData.append("success_action_status", "200"); // 成功时返回 200
|
||
formData.append("OSSAccessKeyId", token.accessid);
|
||
formData.append("policy", token.policy);
|
||
formData.append("signature", token.signature);
|
||
formData.append("key", token.key);
|
||
formData.append("file", file); // file 必须为最后一个表单域
|
||
|
||
await axios.post(token.host, formData, {
|
||
headers: {
|
||
"Content-Type": "multipart/form-data",
|
||
},
|
||
timeout: opts.timeout ?? 1000 * 60 * 5, // 默认 5 分钟
|
||
signal: opts.signal,
|
||
onUploadProgress: (progressEvent) => {
|
||
if (opts.onProgress) {
|
||
const percent =
|
||
progressEvent.progress != null
|
||
? progressEvent.progress * 100
|
||
: progressEvent.total
|
||
? (progressEvent.loaded * 100) / progressEvent.total
|
||
: 0;
|
||
opts.onProgress(Math.round(percent));
|
||
}
|
||
},
|
||
});
|
||
|
||
return {
|
||
url: `${token.host}/${token.key}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 一站式上传文件(获取 Token + 直传 OSS)
|
||
*
|
||
* @param file 要上传的文件
|
||
* @param dir 业务目录(如:avatar, course/cover)
|
||
* @param options 可选配置
|
||
* @returns 上传结果
|
||
*/
|
||
export async function uploadFile(
|
||
file: File,
|
||
dir?: string,
|
||
options?: {
|
||
onProgress?: (percent: number) => void;
|
||
signal?: AbortSignal;
|
||
},
|
||
): Promise<UploadResult> {
|
||
// 1. 获取 OSS 直传 Token
|
||
const token = await getOssToken(file.name, dir);
|
||
|
||
// 2. 直传到 OSS
|
||
await uploadToOss(file, token, {
|
||
onProgress: options?.onProgress,
|
||
signal: options?.signal,
|
||
});
|
||
|
||
// 3. 返回结果
|
||
return {
|
||
success: true,
|
||
filePath: `${token.host}/${token.key}`,
|
||
fileName: file.name,
|
||
fileSize: file.size,
|
||
mimeType: file.type,
|
||
};
|
||
}
|