import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos'; import { api } from '@/services/api'; type ServerCosToken = { tmpSecretId: string; tmpSecretKey: string; sessionToken: string; startTime?: number; expiredTime?: number; bucket?: string; region?: string; prefix?: string; cdnDomain?: string; }; type CosCredential = { credentials: { tmpSecretId: string; tmpSecretKey: string; sessionToken: string; }; startTime?: number; expiredTime?: number; bucket?: string; region?: string; prefix?: string; cdnDomain?: string; }; type UploadOptions = { key: string; body: any; contentType?: string; onProgress?: (progress: { percent: number }) => void; signal?: AbortSignal; }; let CosSdk: any | null = null; async function ensureCosSdk(): Promise { if (CosSdk) return CosSdk; // RN 兼容:SDK 在初始化时会访问 navigator.userAgent const g: any = globalThis as any; if (!g.navigator) g.navigator = {}; if (!g.navigator.userAgent) g.navigator.userAgent = 'react-native'; // 动态导入避免影响首屏,并加入 require 回退,兼容打包差异 let mod: any = null; try { mod = await import('cos-js-sdk-v5'); } catch (_) { try { // eslint-disable-next-line @typescript-eslint/no-var-requires mod = require('cos-js-sdk-v5'); } catch { } } const Candidate = mod?.COS || mod?.default || mod; if (!Candidate) { throw new Error('cos-js-sdk-v5 加载失败'); } CosSdk = Candidate; return CosSdk; } async function fetchCredential(): Promise { // 后端返回 { code, message, data },api.get 会提取 data const data = await api.get('/api/users/cos/upload-token'); return { credentials: { tmpSecretId: data.tmpSecretId, tmpSecretKey: data.tmpSecretKey, sessionToken: data.sessionToken, }, startTime: data.startTime, expiredTime: data.expiredTime, bucket: data.bucket, region: data.region, prefix: data.prefix, cdnDomain: data.cdnDomain, }; } export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record; publicUrl?: string }> { const { key, body, contentType, onProgress, signal } = options; const COS = await ensureCosSdk(); const cred = await fetchCredential(); const bucket = COS_BUCKET || cred.bucket; const region = COS_REGION || cred.region; if (!bucket || !region) { throw new Error('未配置 COS_BUCKET / COS_REGION,且服务端未返回 bucket/region'); } // 确保对象键以服务端授权的前缀开头 const finalKey = ((): string => { const prefix = (cred.prefix || '').replace(/^\/+|\/+$/g, ''); if (!prefix) return key.replace(/^\//, ''); const normalizedKey = key.replace(/^\//, ''); return normalizedKey.startsWith(prefix + '/') ? normalizedKey : `${prefix}/${normalizedKey}`; })(); const controller = new AbortController(); if (signal) { if (signal.aborted) controller.abort(); signal.addEventListener('abort', () => controller.abort(), { once: true }); } return await new Promise((resolve, reject) => { const cos = new COS({ getAuthorization: (_opts: any, cb: any) => { cb({ TmpSecretId: cred.credentials.tmpSecretId, TmpSecretKey: cred.credentials.tmpSecretKey, SecurityToken: cred.credentials.sessionToken, StartTime: cred.startTime, ExpiredTime: cred.expiredTime, }); }, }); const task = cos.putObject( { Bucket: bucket, Region: region, Key: finalKey, Body: body, ContentType: contentType, onProgress: (progressData: any) => { if (onProgress) { const percent = progressData && progressData.percent ? progressData.percent : 0; onProgress({ percent }); } }, }, (err: any, data: any) => { if (err) return reject(err); const publicUrl = cred.cdnDomain ? `${String(cred.cdnDomain).replace(/\/$/, '')}/${finalKey.replace(/^\//, '')}` : buildPublicUrl(finalKey); resolve({ key: finalKey, etag: data && data.ETag, headers: data && data.headers, publicUrl }); } ); controller.signal.addEventListener('abort', () => { try { task && task.cancel && task.cancel(); } catch { } reject(new DOMException('Aborted', 'AbortError')); }); }); } export async function uploadWithRetry(options: UploadOptions & { maxRetries?: number; backoffMs?: number }): Promise<{ key: string; etag?: string }> { const { maxRetries = 2, backoffMs = 800, ...rest } = options; let attempt = 0; // 简单指数退避 while (true) { try { return await uploadToCos(rest); } catch (e: any) { if (rest.signal?.aborted) throw e; if (attempt >= maxRetries) throw e; const wait = backoffMs * Math.pow(2, attempt); await new Promise(r => setTimeout(r, wait)); attempt++; } } }