191 lines
5.5 KiB
TypeScript
191 lines
5.5 KiB
TypeScript
/**
|
||
* 对象存储相关 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 =
|
||
| 'polling_status_import'
|
||
| '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<string, string>
|
||
/** 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<BaseResponse<GetUploadUrlResponse>> {
|
||
return this.post<BaseResponse<GetUploadUrlResponse>>('/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<void> {
|
||
try {
|
||
// 开发环境使用代理解决 CORS 问题
|
||
let finalUrl = uploadUrl
|
||
if (import.meta.env.DEV) {
|
||
// 将对象存储域名替换为代理路径
|
||
finalUrl = uploadUrl.replace(/^https?:\/\/obs-helf\.cucloud\.cn/, '/obs-proxy')
|
||
}
|
||
|
||
const headers: Record<string, string> = {}
|
||
|
||
// 只有在明确指定 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<BaseResponse<BatchDownloadUrlsResponse>> {
|
||
return this.post<BaseResponse<BatchDownloadUrlsResponse>>(
|
||
'/api/admin/storage/batch-download-urls',
|
||
data
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 获取单文件下载 URL
|
||
* @param fileKey 文件路径标识
|
||
*/
|
||
static async getDownloadUrl(fileKey: string): Promise<string> {
|
||
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<void> {
|
||
const normalizedFileKey = fileKey.trim()
|
||
if (!normalizedFileKey) {
|
||
throw new Error('文件标识不能为空')
|
||
}
|
||
|
||
const downloadUrl = await this.getDownloadUrl(normalizedFileKey)
|
||
triggerBrowserDownload(downloadUrl, fileName || inferFileNameFromKey(normalizedFileKey))
|
||
}
|
||
}
|