import { getActiveUserScope } from "@/lib/user-scope"; import { http, apiBaseURL, ApiError } from "@/services/api/request"; import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings"; export type RemoteResource = { id: string; userId: string; kind: "image" | "video" | "audio" | "file" | string; status: "pending" | "ready" | "failed" | "deleted" | string; provider: string; endpoint: string; bucket: string; objectKey: string; publicUrl: string; mimeType: string; size: number; width?: number; height?: number; durationMs?: number; etag?: string; playbackStatus?: string; playbackObjectKey?: string; playbackError?: string; error?: string; createdAt: string; updatedAt: string; }; export type UserOSSSetting = { enabled: boolean; provider: OSSProvider; s3Preset: S3Preset; region: string; endpoint: string; cdnBaseUrl: string; cdnAuthMode: "" | "public" | "qiniu" | string; requireCDN: boolean; allowPrivateProxy: boolean; bucket: string; accessKeyId: string; hasAccessKeySecret: boolean; sessionToken?: string; hasSessionToken: boolean; pathStyle: boolean; allowUserS3: boolean; publicBaseUrl: string; pathPrefix: string; testedAt?: string; testedDigest?: string; historyCount?: number; referencedResourceCount?: number; updatedAt?: string; }; export type UserOSSSettingInput = Pick & { accessKeySecret?: string; sessionToken?: string; cdnAuthMode?: "" | "public" | "qiniu" | string; requireCDN?: boolean; allowPrivateProxy?: boolean; }; export type AccountFileStorageUsage = { usedBytes: number; totalBytes: number; }; export type ArkPrivateAssetSync = { resourceId: string; status: "active" | string; }; export type ResourceUploadMeta = { width?: number; height?: number; durationMs?: number; fileName?: string; idempotencyKey?: string; }; /** * 资源直传失败。 * * `permanent` 是这条边界上唯一重要的信息:媒体直传失败后,调用方默认会把文件留在本机 * IndexedDB,并由云端数据同步用同一幂等键重传(见 user-data-sync 的 uploadLocalStorageKey)。 * 但鉴权失效、越权、请求本身不合法这几类失败重传多少次都是同样结果,把它们也归入 * "稍后自动同步" 等于向用户撒谎,必须当场抛出。 */ export class ResourceUploadError extends Error { readonly status?: number; /** true 表示重试不会自愈,调用方不得降级为本地暂存。 */ readonly permanent: boolean; constructor(message: string, options: { status?: number; permanent: boolean; cause?: unknown }) { super(message, options.cause === undefined ? undefined : { cause: options.cause }); this.name = "ResourceUploadError"; this.status = options.status; this.permanent = options.permanent; } } const resourceCache = new Map(); const resourceRequests = new Map>(); const missingResourceIds = new Set(); export type ResourceAccessPurpose = "display" | "copy" | "download" | "browser-process" | "provider-input"; export type ResourceAccessVariant = "original" | "playback"; export type ResourceAccess = { resourceId: string; requestedVariant: ResourceAccessVariant; actualVariant: ResourceAccessVariant; url: string; delivery: "cdn" | "origin" | "platform-local" | "platform-proxy"; issuedAt: string; expiresAt?: string; refreshAt: string; revision: string; fallbackReason?: string; }; const accessCache = new Map(); const accessRequests = new Map>(); let accessGeneration = 0; /** * Drop every in-memory access descriptor when the authenticated scope changes. * Signed URLs are credentials, so an old request must not be allowed to publish * its result into the next account's cache after a logout/login race. */ export function clearResourceAccessCache() { accessGeneration += 1; accessCache.clear(); accessRequests.clear(); resourceCache.clear(); resourceRequests.clear(); missingResourceIds.clear(); } export function resourceStorageKey(id: string) { return `resource:${id}`; } export function getUserOSSSetting() { return http.get<{ setting: UserOSSSetting }>("/settings/oss"); } export function updateUserOSSSetting(input: UserOSSSettingInput) { return http.patch<{ setting: UserOSSSetting }>("/settings/oss", input); } export function testUserOSSConnection(input: OSSConnectionTestInput) { return http.post("/settings/oss/test", input); } export async function getAccountFileStorageUsage() { const data = await http.get<{ usage: AccountFileStorageUsage }>("/resources/storage-usage"); return data.usage; } export async function syncResourceToArkPrivateAsset(id: string) { const data = await http.post<{ sync: ArkPrivateAssetSync }>(`/resources/${encodeURIComponent(id)}/ark-private-asset`); return data.sync; } export function resourceIdFromStorageKey(storageKey?: string) { return storageKey?.startsWith("resource:") ? storageKey.slice("resource:".length) : ""; } export function isResourceUrl(url?: string) { const base = String(apiBaseURL).replace(/\/+$/, ""); const path = url?.split(/[?#]/, 1)[0] || ""; return path.startsWith(`${base}/resources/`) && path.endsWith("/file"); } // 超过该阈值(与后端单请求 multipart 上限 50MB 一致)的本地媒体走分片上传,避免大视频导入失败。 const CHUNK_UPLOAD_THRESHOLD = 50 << 20; const CHUNK_UPLOAD_RETRIES = 2; export async function uploadResourceFile(file: Blob, kind: "image" | "video" | "audio" | "file", meta?: ResourceUploadMeta, onProgress?: (uploadedBytes: number, totalBytes: number) => void): Promise { const name = meta?.fileName || (file instanceof File ? file.name : `${kind}.${extensionFromMime(file.type, kind)}`); // 分片与 multipart 两条路径的失败都要归一成 ResourceUploadError, // 否则调用方只能靠文案猜测该重试还是该报错。 try { if (file.size > CHUNK_UPLOAD_THRESHOLD) { const resource = await uploadFileInChunks(file, name, kind, meta, onProgress); resourceCache.set(resourceCacheKey(resource.id), resource); return resource; } const formData = new FormData(); formData.append("kind", kind); formData.append("file", file, name); if (meta?.width) formData.append("width", String(Math.round(meta.width))); if (meta?.height) formData.append("height", String(Math.round(meta.height))); if (meta?.durationMs) formData.append("durationMs", String(Math.round(meta.durationMs))); const data = await http.post<{ resource: RemoteResource }>("/resources", formData, { ...uploadRequestConfig(meta?.idempotencyKey), onUploadProgress: onProgress ? ({ loaded, total }) => { if (total && total > 0) onProgress(Math.min(file.size, (file.size * loaded) / total), file.size); } : undefined, }); resourceCache.set(resourceCacheKey(data.resource.id), data.resource); return data.resource; } catch (error) { throw normalizeUploadError(error); } } // 分片上传:POST 开始会话 → 逐片 PUT 原始二进制(每片 8MB)→ POST 合并落库。 // 单请求体积小、可断点续传/失败重试;单文件不再受 50MB 限制(仅日/总量配额约束)。 async function uploadFileInChunks(file: Blob, name: string, kind: "image" | "video" | "audio" | "file", meta: ResourceUploadMeta | undefined, onProgress?: (uploadedBytes: number, totalBytes: number) => void) { // 片级失败通常意味着会话过期/网络抖动:整体重开一次会话重传(整传重试)。 for (let attempt = 0; attempt < CHUNK_UPLOAD_RETRIES; attempt++) { try { return await runChunkedUpload(file, name, kind, meta, onProgress); } catch (error) { if (attempt === CHUNK_UPLOAD_RETRIES - 1) throw error; } } throw new Error("上传失败"); } async function runChunkedUpload(file: Blob, name: string, kind: "image" | "video" | "audio" | "file", meta: ResourceUploadMeta | undefined, onProgress?: (uploadedBytes: number, totalBytes: number) => void) { const session = await http.post<{ uploadId: string; chunkSize: number; chunkCount: number }>( "/resources/uploads", { fileName: name, kind, size: file.size, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs }, uploadRequestConfig(meta?.idempotencyKey), ); for (let index = 0; index < session.chunkCount; index++) { const start = index * session.chunkSize; const end = Math.min(file.size, start + session.chunkSize); const blob = file.slice(start, end); const data = new FormData(); data.append("chunk", blob); // raw 二进制直传,与后端按裸 body 逐片落盘对齐(勿设手动 Content-Type,让 axios 处理)。 await http.put<{ index: number }>(`/resources/uploads/${encodeURIComponent(session.uploadId)}/chunks/${index}`, blob, { headers: { "Content-Type": "application/octet-stream" }, onUploadProgress: onProgress ? ({ loaded }) => onProgress(start + Math.min(loaded, blob.size), file.size) : undefined, }); onProgress?.(Math.min(end, file.size), file.size); } const complete = await http.post<{ resource: RemoteResource }>(`/resources/uploads/${encodeURIComponent(session.uploadId)}/complete`); return complete.resource; } // 失败分类直接复用 request() 已经算好的 ApiError.retryable(408/425/429/5xx 可重试), // 不在这里另起一套响应码判断,避免两处规则漂移。 // 差别只有一处:没有拿到任何 HTTP 响应(断网、超时)时 retryable 为 false, // 但这种失败恰恰是最该退回本机等待重传的,因此单独按瞬时处理。 function normalizeUploadError(error: unknown): ResourceUploadError { if (error instanceof ResourceUploadError) return error; // 请求取消不是上传失败,保持原始语义交给调用方。 if (error instanceof DOMException && error.name === "AbortError") throw error; if (error instanceof ApiError) { const status = error.status; const permanent = status !== undefined && !error.retryable; // 即使误超 50MB multipart 上限(后端 http.MaxBytesError),也给出可读中文而非英文裸错。 if (status === 400 && /body too large|MaxBytes/i.test(error.message)) { return new ResourceUploadError("文件过大,请使用小于 50MB 的文件或稍后重试", { status, permanent: true, cause: error }); } if (status === 413) return new ResourceUploadError("文件过大,无法上传", { status, permanent: true, cause: error }); return new ResourceUploadError(error.message || "上传失败", { status, permanent, cause: error }); } return new ResourceUploadError(error instanceof Error ? error.message : "上传失败", { permanent: false, cause: error }); } export async function importResourceFromUrl(url: string, kind: "image" | "video" | "audio" | "file", meta?: Omit) { const data = await http.post<{ resource: RemoteResource }>("/resources/import", { url, kind, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs }, uploadRequestConfig(meta?.idempotencyKey)); resourceCache.set(resourceCacheKey(data.resource.id), data.resource); return data.resource; } function uploadRequestConfig(idempotencyKey?: string) { const value = idempotencyKey?.trim(); return value ? { headers: { "X-Idempotency-Key": value } } : undefined; } export function getResource(id: string): Promise { const generation = accessGeneration; const scope = getActiveUserScope(); const cacheKey = resourceCacheKey(id); const cached = resourceCache.get(cacheKey); if (cached) return Promise.resolve(cached); if (missingResourceIds.has(cacheKey)) return Promise.reject(new Error("资源不存在或已被删除")); const pending = resourceRequests.get(cacheKey); if (pending) return pending; const task = http .get<{ resource: RemoteResource }>(`/resources/${encodeURIComponent(id)}`) .then((data) => { assertResourceRequestCurrent(generation, scope); resourceCache.set(cacheKey, data.resource); return data.resource; }) .catch((error) => { assertResourceRequestCurrent(generation, scope); if (error instanceof ApiError && error.status === 404) missingResourceIds.add(cacheKey); throw error; }) .finally(() => { if (resourceRequests.get(cacheKey) === task) resourceRequests.delete(cacheKey); }); resourceRequests.set(cacheKey, task); return task; } // refreshResource 绕过缓存强制拉取资源最新状态(转码副本就绪轮询用),并回写缓存。 export function refreshResource(id: string): Promise { const generation = accessGeneration; const scope = getActiveUserScope(); const cacheKey = resourceCacheKey(id); return http.get<{ resource: RemoteResource }>(`/resources/${encodeURIComponent(id)}`).then((data) => { assertResourceRequestCurrent(generation, scope); resourceCache.set(cacheKey, data.resource); missingResourceIds.delete(cacheKey); return data.resource; }); } function assertResourceRequestCurrent(generation: number, scope: string) { if (generation !== accessGeneration || scope !== getActiveUserScope()) { throw new DOMException("资源请求已因账号切换失效", "AbortError"); } } export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "") { const id = resourceIdFromStorageKey(storageKey); if (!id) throw new Error("当前媒体尚未上传到后端资源存储"); const scope = getActiveUserScope(); const generation = accessGeneration; const key = `${scope}:${id}:${purpose}:${variant}:${downloadName}`; const cached = accessCache.get(key); if (cached && cached.expiresAt > Date.now()) return cached.value; const pending = accessRequests.get(key); if (pending) return pending; let request!: Promise; request = (async () => { try { const data = await http.post<{ items: Array<{ resourceId: string; access?: ResourceAccess; error?: { msg?: string } }> }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]); if (generation !== accessGeneration || scope !== getActiveUserScope()) { throw new DOMException("资源访问请求已因账号切换失效", "AbortError"); } const item = data.items?.[0]; if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址"); const value = item.access; const ttl = value.expiresAt ? Math.max(10_000, new Date(value.expiresAt).getTime() - Date.now() - 15_000) : 5 * 60_000; accessCache.set(key, { value, expiresAt: Date.now() + ttl }); return value; } catch (error) { if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败"); throw error; } finally { if (accessRequests.get(key) === request) accessRequests.delete(key); } })(); accessRequests.set(key, request); return request; } /** 模型上游读取资源使用更长 TTL,但仍走统一资源访问合同。 */ export async function getResourceInputURL(storageKey?: string) { return (await getResourceAccess(storageKey, "provider-input")).url; } /** * Resolve a platform delivery URL against the configured API origin. * * Cloud deliveries are already absolute CDN/origin URLs. Local/proxy * deliveries are intentionally returned by the backend as controlled API * paths; when the frontend talks to a separate backend origin, resolving the * path here prevents a Blob read from accidentally targeting the web origin. */ export function resolveResourceAccessURL(url: string) { if (!url || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url)) return url; const base = String(apiBaseURL).trim(); if (!/^https?:\/\//i.test(base)) return url; return new URL(url, `${base.replace(/\/+$/, "")}/`).toString(); } function resourceCacheKey(id: string) { return `${getActiveUserScope()}:${id}`; } export function resourceFileUrl(id: string) { const base = String(apiBaseURL).replace(/\/+$/, ""); return `${base}/resources/${encodeURIComponent(id)}/file`; } export function resolveResourceUrl(storageKey?: string, fallback = "") { const id = resourceIdFromStorageKey(storageKey); // 资源引用本身已经包含稳定 ID;恢复/展示阶段不需要再查一遍元数据。 // 需要 publicUrl、mime 或尺寸时必须显式调用 getResource,避免隐式 N+1。 return id ? resourceFileUrl(id) : fallback; } // playbackVariantUrl 返回浏览器兼容播放副本 URL(H.265→H.264 转码结果)。 // 副本就绪前由后端回退原件,调用方再按需降级。 export function playbackVariantUrl(id: string) { const base = String(apiBaseURL).replace(/\/+$/, ""); return `${base}/resources/${encodeURIComponent(id)}/file?variant=playback`; } export async function getResourceBlob(storageKey: string) { const id = resourceIdFromStorageKey(storageKey); if (!id) return null; const access = await getResourceAccess(storageKey, "browser-process"); // CDN/origin URLs carry their own authorization and must not receive the // application session cookie. Local/proxy delivery is intentionally // session-bound, so a Blob read must include it even when the URL is // relative to the platform origin. const credentials = access.delivery === "platform-local" || access.delivery === "platform-proxy" ? "include" : "omit"; const response = await fetch(resolveResourceAccessURL(access.url), { credentials, mode: "cors" }); if (!response.ok) throw new Error(`资源读取失败(${response.status})`); return response.blob(); } function extensionFromMime(mimeType: string, kind: string) { if (mimeType.includes("png")) return "png"; if (mimeType.includes("jpeg")) return "jpg"; if (mimeType.includes("webp")) return "webp"; if (mimeType.includes("gif")) return "gif"; if (mimeType.includes("mp4")) return "mp4"; if (mimeType.includes("webm")) return "webm"; if (mimeType.includes("mpeg")) return "mp3"; if (mimeType.includes("wav")) return "wav"; return kind === "image" ? "png" : "bin"; }