Files
xk-admin/apps/web-antd/src/utils/oss-upload.ts

373 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OSS直传工具函数
*
* 该模块实现了阿里云OSS的Web直传功能支持客户端直接上传文件到OSS存储桶
* 参考文档https://help.aliyun.com/zh/oss/user-guide/obtain-signature-information-from-the-server-and-upload-data-to-oss
*
* 主要功能:
* 1. 从后端获取OSS上传签名信息
* 2. 生成全局唯一的文件名UUID + 时间戳)
* 3. 使用FormData构造POST请求直接上传到OSS
* 4. 处理上传进度和错误
* 5. 返回上传后的文件URL
*/
import { getOssSignature, registerOssFile } from '#/api/core/upload';
/**
* OSS签名信息接口
* 后端返回的签名数据结构
*/
export interface OssSignature {
/** 阿里云AccessKeyId */
accessKeyId: string;
/** Base64编码的PostPolicy策略 */
policy: string;
/** 签名值 */
signature: string;
/** OSS服务端点URL */
host: string;
/** 存储桶名称(从后端返回,不能硬编码) */
bucket: string;
/** 文件路径前缀(仅作为路径前缀,完整文件名由前端生成) */
key: string;
/** 签名过期时间戳Unix时间戳秒级 */
expire: number;
}
/**
* 上传选项接口
*/
export interface UploadOptions {
/** 要上传的文件对象 */
file: File;
/** 上传进度回调函数参数为0-100的进度百分比 */
onProgress?: (percent: number) => void;
}
/**
* 上传结果接口
*/
export interface UploadResult {
/** 上传后的文件完整URL */
url: string;
/** 文件在OSS中的objectName相对路径 */
objectName: string;
}
/**
* 生成全局唯一的文件名
*
* 文件名格式xk_upload_日期/UUID_时间戳.扩展名
* 例如xk_upload_20250108/550e8400-e29b-41d4-a716-446655440000_1736323200.jpg
*
* 规则说明:
* - 基础路径xk_upload_日期/ 日期格式YYYYMMDD
* - 文件名UUID_时间戳.扩展名
* - UUID使用浏览器原生crypto.randomUUID()生成,确保全局唯一
* - 时间戳使用Unix时间戳秒级通过Math.floor(Date.now() / 1000)获取
* - 扩展名:从原始文件名提取
*
* @param file 文件对象,用于获取文件名和扩展名
* @returns 返回完整的objectName路径
*
* @example
* const objectName = generateObjectName(file);
* // 返回: "xk_upload_20250108/550e8400-e29b-41d4-a716-446655440000_1736323200.jpg"
*/
export function generateObjectName(file: File): string {
// 获取当前日期格式YYYYMMDD
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateStr = `${year}${month}${day}`;
// 生成UUID使用浏览器原生API确保全局唯一
// crypto.randomUUID() 是Web Crypto API的一部分支持现代浏览器
let uuid: string;
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
uuid = crypto.randomUUID();
} else {
// 降级方案如果浏览器不支持randomUUID使用替代方案
// 生成类似UUID格式的随机字符串
uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// 获取Unix时间戳秒级
// Date.now()返回毫秒级时间戳除以1000并向下取整得到秒级时间戳
const timestamp = Math.floor(Date.now() / 1000);
// 获取文件扩展名
// 从文件名中提取扩展名,如果没有扩展名则使用空字符串
const fileName = file.name;
const lastDotIndex = fileName.lastIndexOf('.');
const extension = lastDotIndex > 0 ? fileName.substring(lastDotIndex + 1).toLowerCase() : '';
// 组合成完整的objectName路径
// 格式xk_upload_日期/UUID_时间戳.扩展名
const objectName = `xk_upload_${dateStr}/${uuid}_${timestamp}${extension ? '.' + extension : ''}`;
return objectName;
}
/**
* 根据 File 推断上传用 Content-Type浏览器可能给空 type
*/
function resolveUploadContentType(file: File): string {
if (file.type && file.type !== 'application/octet-stream') {
return file.type;
}
const name = (file.name || '').toLowerCase();
if (name.endsWith('.pdf')) return 'application/pdf';
if (name.endsWith('.png')) return 'image/png';
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg';
if (name.endsWith('.gif')) return 'image/gif';
if (name.endsWith('.webp')) return 'image/webp';
if (name.endsWith('.bmp')) return 'image/bmp';
return file.type || 'application/octet-stream';
}
/**
* 从后端获取OSS上传签名信息
*
* 该函数调用后端API获取OSS上传所需的签名信息包括
* - accessKeyId: 阿里云访问密钥ID
* - policy: Base64编码的上传策略
* - signature: 签名值
* - host: OSS服务端点
* - bucket: 存储桶名称(从后端返回,确保配置灵活性)
* - key: 文件路径前缀
* - expire: 签名过期时间
*
* 错误处理:
* - 如果API调用失败会抛出错误
* - 调用方需要捕获错误并进行相应处理
*
* @returns Promise<OssSignature> 返回签名信息对象
* @throws 如果API调用失败或返回数据格式不正确会抛出错误
*
* @example
* try {
* const signature = await getOssSignature();
* console.log('获取签名成功', signature);
* } catch (error) {
* console.error('获取签名失败', error);
* }
*/
export async function getOssSignatureFromBackend(): Promise<OssSignature> {
try {
// 调用后端API获取签名信息
const response = await getOssSignature();
// 验证返回数据的完整性
if (!response || !response.accessKeyId || !response.policy || !response.signature) {
throw new Error('后端返回的签名信息不完整');
}
// 确保bucket字段存在重要bucket必须从后端返回
if (!response.bucket) {
throw new Error('后端未返回bucket信息无法进行上传');
}
return response as OssSignature;
} catch (error) {
// 记录错误信息,便于调试
console.error('获取OSS签名失败:', error);
throw error;
}
}
/**
* 上传文件到OSS
*
* 该函数实现了完整的OSS直传流程
* 1. 从后端获取签名信息
* 2. 生成全局唯一的文件名
* 3. 构造FormData并设置必要的字段
* 4. 使用XMLHttpRequest发送POST请求到OSS
* 5. 处理上传进度和错误
* 6. 返回上传后的文件URL
*
* 上传流程说明:
* - 使用FormData构造multipart/form-data格式的请求
* - 必须包含的字段key, policy, OSSAccessKeyId, signature, file
* - key字段使用生成的objectName
* - 上传到OSS的host端点
* - 上传成功后文件URL格式host/objectName
*
* 进度处理:
* - 使用XMLHttpRequest的upload.onprogress事件监听上传进度
* - 进度值范围0-100表示上传百分比
* - 通过onProgress回调函数通知调用方
*
* 错误处理:
* - 网络错误捕获XMLHttpRequest的错误事件
* - OSS错误解析OSS返回的XML错误响应
* - 签名过期:检查签名是否在有效期内
*
* @param options 上传选项,包含文件和进度回调
* @returns Promise<UploadResult> 返回上传结果包含文件URL和objectName
* @throws 如果上传失败,会抛出包含错误信息的异常
*
* @example
* try {
* const result = await uploadToOss({
* file: fileObject,
* onProgress: (percent) => {
* console.log(`上传进度: ${percent}%`);
* }
* });
* console.log('上传成功文件URL:', result.url);
* } catch (error) {
* console.error('上传失败', error);
* }
*/
export async function uploadToOss(options: UploadOptions): Promise<UploadResult> {
const { file, onProgress } = options;
// 步骤1从后端获取OSS签名信息
// 签名信息包含上传所需的所有认证参数
const signature = await getOssSignatureFromBackend();
// 步骤2生成全局唯一的文件名
// 使用UUID + 时间戳确保文件名全局唯一,避免冲突
const objectName = generateObjectName(file);
// 步骤3构造FormData设置OSS上传所需的字段
// FormData用于构造multipart/form-data格式的请求体
const formData = new FormData();
// key: 文件在OSS中的完整路径objectName
// 这是OSS中文件的唯一标识
formData.append('key', objectName);
// policy: Base64编码的上传策略
// 策略定义了上传的条件,如文件大小限制、允许的文件类型等
formData.append('policy', signature.policy);
// OSSAccessKeyId: 阿里云访问密钥ID
// 用于标识上传请求的发起者
formData.append('OSSAccessKeyId', signature.accessKeyId);
// signature: 签名值
// 使用AccessKeySecret对policy进行签名确保策略未被篡改
formData.append('signature', signature.signature);
// success_action_status: 指定上传成功后OSS返回的HTTP状态码
// 默认是204无内容设置为200后OSS返回200状态码
// 这样前端可以通过200状态码明确判断上传成功
// 注意此字段必须与后端Policy中的条件一致否则会上传失败
formData.append('success_action_status', '200');
// x-oss-object-acl: 指定文件的访问权限ACL
// 设置为 public-read 表示文件可以被公开访问无需签名URL
// 这与后端上传保持一致后端上传后会调用putObjectAcl设置公共读
// 注意此字段必须与后端Policy中的条件一致否则会上传失败
formData.append('x-oss-object-acl', 'public-read');
// Content-Type按文件 MIME 写入,避免 PDF 被当成 octet-stream 导致浏览器直接下载
const contentType = resolveUploadContentType(file);
formData.append('Content-Type', contentType);
// Content-Disposition: inline —— 浏览器倾向预览而非附件下载
formData.append('Content-Disposition', 'inline');
// file: 要上传的文件
// 必须是最后一个字段OSS要求file字段在FormData的最后
formData.append('file', file);
// 步骤4使用XMLHttpRequest发送POST请求到OSS
// 使用XMLHttpRequest而不是fetch因为需要监听上传进度
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 监听上传进度
// upload.onprogress事件在上传过程中会多次触发
if (onProgress && xhr.upload) {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
// 计算上传进度百分比
// loaded: 已上传的字节数
// total: 文件总字节数
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
});
}
// 监听请求完成事件
xhr.addEventListener('load', () => {
// 检查HTTP状态码
// 200-299表示成功204表示成功但无内容
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 204) {
// 上传成功构造文件URL
// URL格式host/objectName
// 注意使用后端返回的host确保配置的灵活性
const fileUrl = `${signature.host}/${objectName}`;
registerOssFile({ url: fileUrl, source: 0, file_size: file.size, file_name: file.name }).catch((err) => {
console.warn('OSS 文件登记失败:', err);
});
resolve({
url: fileUrl,
objectName: objectName,
});
} else {
// HTTP状态码表示错误尝试解析OSS返回的错误信息
let errorMessage = `上传失败HTTP状态码: ${xhr.status}`;
try {
// OSS错误响应通常是XML格式
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xhr.responseText, 'text/xml');
const errorNode = xmlDoc.querySelector('Error');
if (errorNode) {
const codeNode = errorNode.querySelector('Code');
const messageNode = errorNode.querySelector('Message');
if (codeNode && messageNode) {
errorMessage = `OSS错误 [${codeNode.textContent}]: ${messageNode.textContent}`;
}
}
} catch (parseError) {
// 如果解析失败,使用默认错误信息
console.warn('解析OSS错误响应失败:', parseError);
}
reject(new Error(errorMessage));
}
});
// 监听网络错误事件
// 当网络请求失败时触发(如网络断开、超时等)
xhr.addEventListener('error', () => {
reject(new Error('网络错误无法连接到OSS服务器'));
});
// 监听请求超时事件
// 如果请求时间超过timeout设置的时间会触发此事件
xhr.addEventListener('timeout', () => {
reject(new Error('上传超时:请求时间过长'));
});
// 设置请求超时时间30秒
// 对于大文件,可能需要增加超时时间
xhr.timeout = 30000;
// 打开POST请求
// 使用后端返回的host作为请求URL
xhr.open('POST', signature.host, true);
// 发送请求
// 将FormData作为请求体发送
xhr.send(formData);
});
}