/** * 对象存储相关 API */ import { BaseService } from '../BaseService' import type { BaseResponse } from '@/types/api' const inferFileNameFromKey = (fileKey: string) => { return fileKey.split('/').pop() || 'download' } const triggerBrowserDownload = (downloadUrl: string, fileName: string) => { const link = document.createElement('a') link.href = downloadUrl link.download = fileName link.target = '_blank' link.rel = 'noopener noreferrer' document.body.appendChild(link) link.click() document.body.removeChild(link) } /** * 文件用途枚举 */ export type FilePurpose = | 'iot_import' | 'batch_purchase' | 'device_batch_allocation' | 'shop_import' | 'phone_unbind_import' | 'export' | 'attachment' /** * 获取上传 URL 请求参数 */ export interface GetUploadUrlRequest { /** 文件名(如:cards.csv) */ file_name: string /** 文件 MIME 类型(如:text/csv),留空则自动推断 */ content_type?: string /** 文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件) */ purpose: FilePurpose } /** * 获取上传 URL 响应 */ export interface GetUploadUrlResponse { /** 预签名上传 URL,使用 PUT 方法上传文件 */ upload_url: string /** 文件路径标识,上传成功后用于调用业务接口 */ file_key: string /** URL 有效期(秒) */ expires_in: number } /** * 批量获取文件下载预签名 URL 请求 */ export interface BatchDownloadUrlsRequest { /** 文件路径标识列表,最多 50 个 */ file_keys: string[] } /** * 批量获取文件下载预签名 URL 响应 */ export interface BatchDownloadUrlsResponse { /** 文件下载地址映射,key 为 file_key */ urls: Record /** URL 有效期(秒) */ expires_in: number } export class StorageService extends BaseService { /** * 获取文件上传预签名 URL * * ## 完整上传流程 * 1. 调用本接口获取预签名 URL 和 file_key * 2. 使用预签名 URL 上传文件(发起 PUT 请求直接上传到对象存储) * 3. 使用 file_key 调用相关业务接口 * * @param data 请求参数 */ static getUploadUrl(data: GetUploadUrlRequest): Promise> { return this.post>('/api/admin/storage/upload-url', data) } /** * 使用预签名 URL 上传文件到对象存储 * * 注意事项: * - 预签名 URL 有效期 15 分钟,请及时使用 * - 上传时 Content-Type 需与请求时一致 * - file_key 在上传成功后永久有效 * - 开发环境通过代理上传,生产环境直接上传 * * @param uploadUrl 预签名 URL(由对象存储生成) * @param file 文件 * @param contentType 文件类型(需与 getUploadUrl 请求时保持一致) */ static async uploadFile(uploadUrl: string, file: File, contentType?: string): Promise { try { // 开发环境使用代理解决 CORS 问题 let finalUrl = uploadUrl if (import.meta.env.DEV) { // 将对象存储域名替换为代理路径 finalUrl = uploadUrl.replace(/^https?:\/\/obs-helf\.cucloud\.cn/, '/obs-proxy') } const headers: Record = {} // 只有在明确指定 contentType 时才设置,否则让浏览器自动处理 if (contentType) { headers['Content-Type'] = contentType } const response = await fetch(finalUrl, { method: 'PUT', body: file, headers }) if (!response.ok) { const errorText = await response.text().catch(() => response.statusText) throw new Error(`上传文件失败 (${response.status}): ${errorText}`) } } catch (error: any) { // 增强错误信息 if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) { throw new Error( 'CORS 错误: 无法上传文件到对象存储。' + '这通常是因为对象存储服务器未正确配置 CORS 策略。' + '请联系后端开发人员检查对象存储的 CORS 配置。' ) } throw error } } /** * 批量获取文件下载预签名 URL * 用于获取私有文件的临时下载地址 * @param data 请求参数 */ static batchDownloadUrls( data: BatchDownloadUrlsRequest ): Promise> { return this.post>( '/api/admin/storage/batch-download-urls', data ) } /** * 获取单文件下载 URL * @param fileKey 文件路径标识 */ static async getDownloadUrl(fileKey: string): Promise { const normalizedFileKey = fileKey.trim() const res = await this.batchDownloadUrls({ file_keys: [normalizedFileKey] }) if (res.code !== 0 || !res.data?.urls?.[normalizedFileKey]) { throw new Error(res.msg || '获取下载地址失败') } return res.data.urls[normalizedFileKey] } /** * 按文件标识直接触发浏览器下载 * @param fileKey 文件路径标识 * @param fileName 可选文件名 */ static async downloadFileByKey(fileKey: string, fileName?: string): Promise { const normalizedFileKey = fileKey.trim() if (!normalizedFileKey) { throw new Error('文件标识不能为空') } const downloadUrl = await this.getDownloadUrl(normalizedFileKey) triggerBrowserDownload(downloadUrl, fileName || inferFileNameFromKey(normalizedFileKey)) } }