import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos'; import { api } from '@/services/api'; import Cos from 'react-native-cos-sdk'; 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; // React Native COS SDK 推荐使用本地文件路径(file:// 或 content://) srcUri?: string; // 为兼容旧实现(Web/Blob)。在 RN SDK 下会忽略 body body?: any; contentType?: string; onProgress?: (progress: { percent: number }) => void; signal?: AbortSignal; }; let rnTransferManager: any | null = null; let rnInitialized = false; async function fetchCredential(): Promise { // 后端返回 { code, message, data },api.get 会提取 data const data = await api.get('/api/users/cos/upload-token'); console.log('fetchCredential', data); 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, srcUri, contentType, onProgress, signal } = options; 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'); } // 确保对象键以服务端授权的前缀开头(去除通配符,折叠斜杠,避免把 * 拼进 Key) const finalKey = ((): string => { const rawPrefix = String(cred.prefix || ''); // 1) 去掉 * 与多余斜杠,再去掉首尾斜杠 const safePrefix = rawPrefix .replace(/\*/g, '') .replace(/\/{2,}/g, '/') .replace(/^\/+|\/+$/g, ''); const normalizedKey = key.replace(/^\//, ''); if (!safePrefix) return normalizedKey; if (normalizedKey.startsWith(safePrefix + '/')) return normalizedKey; return `${safePrefix}/${normalizedKey}`.replace(/\/{2,}/g, '/'); })(); // 初始化 react-native-cos-sdk(一次) if (!rnInitialized) { await Cos.initWithSessionCredentialCallback(async () => { // SDK 会在需要时调用该回调,我们返回当前的临时密钥 return { tmpSecretId: cred.credentials.tmpSecretId, tmpSecretKey: cred.credentials.tmpSecretKey, sessionToken: cred.credentials.sessionToken, startTime: cred.startTime, expiredTime: cred.expiredTime, } as any; }); const serviceConfig = { region, isDebuggable: true, isHttps: true } as any; await Cos.registerDefaultService(serviceConfig); const transferConfig = { forceSimpleUpload: false, enableVerification: true, divisionForUpload: 2 * 1024 * 1024, sliceSizeForUpload: 1 * 1024 * 1024, } as any; rnTransferManager = await Cos.registerDefaultTransferManger(serviceConfig, transferConfig); rnInitialized = true; } if (!srcUri || typeof srcUri !== 'string') { throw new Error('请提供本地文件路径 srcUri(形如 file:/// 或 content://)'); } const controller = new AbortController(); if (signal) { if (signal.aborted) controller.abort(); signal.addEventListener('abort', () => controller.abort(), { once: true }); } return await new Promise((resolve, reject) => { let cancelled = false; let taskRef: any = null; (async () => { try { taskRef = await rnTransferManager.upload( bucket, finalKey, srcUri, { resultListener: { successCallBack: (header: any) => { if (cancelled) return; const publicUrl = buildPublicUrl(finalKey); const etag = header?.ETag || header?.headers?.ETag || header?.headers?.etag; resolve({ key: finalKey, etag, headers: header, publicUrl }); }, failCallBack: (clientError: any, serviceError: any) => { if (cancelled) return; console.log('uploadToCos', { clientError, serviceError }); const err = clientError || serviceError || new Error('COS 上传失败'); reject(err); }, }, progressCallback: (complete: number, target: number) => { if (onProgress) { const percent = target > 0 ? complete / target : 0; onProgress({ percent }); } }, contentType, } ); } catch (e) { if (!cancelled) reject(e); } })(); controller.signal.addEventListener('abort', () => { cancelled = true; try { taskRef?.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++; } } }