import { getActiveUserScope } from "@/lib/user-scope"; import axios from "axios"; import { apiBaseURL, apiClient, request, type BackendEnvelope } 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; error?: string; createdAt: string; updatedAt: string; }; export type UserOSSSetting = { enabled: boolean; provider: OSSProvider; s3Preset: S3Preset; region: string; endpoint: string; cdnBaseUrl: string; 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; }; export type AccountFileStorageUsage = { usedBytes: number; totalBytes: number; }; export type ArkPrivateAssetSync = { resourceId: string; status: "active" | string; }; type ResourceUploadMeta = { width?: number; height?: number; durationMs?: number; fileName?: string; idempotencyKey?: string; }; const api = apiClient; const resourceCache = new Map(); const resourceRequests = new Map>(); const missingResourceIds = new Set(); export function resourceStorageKey(id: string) { return `resource:${id}`; } export function getUserOSSSetting() { return request<{ setting: UserOSSSetting }>(api.get("/settings/oss")); } export function updateUserOSSSetting(input: UserOSSSettingInput) { return request<{ setting: UserOSSSetting }>(api.patch("/settings/oss", input)); } export function testUserOSSConnection(input: OSSConnectionTestInput) { return request(api.post("/settings/oss/test", input)); } export async function getAccountFileStorageUsage() { const data = await request<{ usage: AccountFileStorageUsage }>(api.get("/resources/storage-usage")); return data.usage; } export async function syncResourceToArkPrivateAsset(id: string) { const data = await request<{ sync: ArkPrivateAssetSync }>(api.post(`/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"); } export async function uploadResourceFile(file: Blob, kind: "image" | "video" | "audio" | "file", meta?: ResourceUploadMeta) { const formData = new FormData(); const name = meta?.fileName || (file instanceof File ? file.name : `${kind}.${extensionFromMime(file.type, kind)}`); 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 request<{ resource: RemoteResource }>(api.post("/resources", formData, uploadRequestConfig(meta?.idempotencyKey))); resourceCache.set(resourceCacheKey(data.resource.id), data.resource); return data.resource; } export async function importResourceFromUrl(url: string, kind: "image" | "video" | "audio" | "file", meta?: Omit) { const data = await request<{ resource: RemoteResource }>(api.post("/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 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 = request<{ resource: RemoteResource }>(api.get(`/resources/${encodeURIComponent(id)}`)) .then((data) => { resourceCache.set(cacheKey, data.resource); return data.resource; }) .catch((error) => { if (axios.isAxiosError(error) && error.response?.status === 404) missingResourceIds.add(cacheKey); throw error; }) .finally(() => resourceRequests.delete(cacheKey)); resourceRequests.set(cacheKey, task); return task; } export async function getResourceOSSUrl(storageKey?: string) { const id = resourceIdFromStorageKey(storageKey); if (!id) throw new Error("当前媒体尚未上传到后端资源存储"); try { const data = await request<{ url: string }>(api.get(`/resources/${encodeURIComponent(id)}/oss-url`)); if (!data.url) throw new Error("后端未返回对象存储地址"); return data.url; } catch (error) { if (axios.isAxiosError>(error)) throw new Error(error.response?.data.msg || error.message || "获取对象存储地址失败"); throw error; } } function resourceCacheKey(id: string) { return `${getActiveUserScope()}:${id}`; } export function resourceFileUrl(id: string) { const base = String(apiBaseURL).replace(/\/+$/, ""); return `${base}/resources/${encodeURIComponent(id)}/file`; } function resourceProxyFileUrl(id: string) { const base = String(apiBaseURL).replace(/\/+$/, ""); return `${base}/resources/${encodeURIComponent(id)}/file?proxy=1`; } export function resolveResourceUrl(storageKey?: string, fallback = "") { const id = resourceIdFromStorageKey(storageKey); // 资源引用本身已经包含稳定 ID;恢复/展示阶段不需要再查一遍元数据。 // 需要 publicUrl、mime 或尺寸时必须显式调用 getResource,避免隐式 N+1。 return id ? resourceFileUrl(id) : fallback; } export async function getResourceBlob(storageKey: string) { const id = resourceIdFromStorageKey(storageKey); if (!id) return null; const url = resourceProxyFileUrl(id); const response = await fetch(url, { credentials: isResourceUrl(url) ? "include" : "same-origin" }); if (!response.ok) return null; 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"; }