perf(media): OSS 资源 - 通过签名地址直连下载并禁止浏览器代理媒体
This commit is contained in:
@@ -291,17 +291,17 @@ export function refreshResource(id: string): Promise<RemoteResource> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original") {
|
||||
export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "") {
|
||||
const id = resourceIdFromStorageKey(storageKey);
|
||||
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
|
||||
const key = `${resourceCacheKey(id)}:${purpose}:${variant}`;
|
||||
const key = `${resourceCacheKey(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;
|
||||
const request = (async () => {
|
||||
try {
|
||||
const data = await http.post<{ items: Array<{ resourceId: string; access?: ResourceAccess; error?: { msg?: string } }> }>("/resources/access", [{ resourceId: id, purpose, variant }]);
|
||||
const data = await http.post<{ items: Array<{ resourceId: string; access?: ResourceAccess; error?: { msg?: string } }> }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]);
|
||||
const item = data.items?.[0];
|
||||
if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
|
||||
const value = item.access;
|
||||
|
||||
39
web/src/services/browser-download.ts
Normal file
39
web/src/services/browser-download.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
getResourceAccess,
|
||||
resolveResourceAccessURL,
|
||||
resourceIdFromStorageKey,
|
||||
type ResourceAccessVariant,
|
||||
} from "@/services/api/resources";
|
||||
|
||||
export type BrowserMediaDownload = {
|
||||
fileName: string;
|
||||
storageKey?: string;
|
||||
url?: string;
|
||||
variant?: ResourceAccessVariant;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hand a media download directly to the browser.
|
||||
*
|
||||
* Remote resources use the OSS/CDN download access contract so the object
|
||||
* store sends the bytes and Content-Disposition header. This function must
|
||||
* not fetch the payload or materialize a Blob: doing so wastes browser memory
|
||||
* and can accidentally turn the platform into a media relay.
|
||||
*/
|
||||
export async function downloadBrowserMedia({ fileName, storageKey, url, variant = "original" }: BrowserMediaDownload) {
|
||||
const normalizedStorageKey = storageKey?.trim();
|
||||
const normalizedUrl = url?.trim();
|
||||
const href = normalizedStorageKey && resourceIdFromStorageKey(normalizedStorageKey)
|
||||
? resolveResourceAccessURL((await getResourceAccess(normalizedStorageKey, "download", variant, fileName)).url)
|
||||
: normalizedUrl;
|
||||
if (!href) throw new Error("媒体资源不存在");
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.download = fileName;
|
||||
anchor.rel = "noopener noreferrer";
|
||||
anchor.style.display = "none";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { nanoid } from "nanoid";
|
||||
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { captureVideoPoster, detectVideoAudioTrackFromBlob } from "@/lib/video-poster";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources";
|
||||
import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources";
|
||||
import { uploadImage, type UploadedImage } from "@/services/image-storage";
|
||||
import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
|
||||
@@ -115,8 +115,8 @@ export async function resolveMediaUrl(storageKey?: string, fallback = "") {
|
||||
if (!storageKey) return fallback;
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (resourceId) {
|
||||
// 远程资源展示统一走稳定的云端/资源文件地址;Blob 缓存仅服务于字节读取和媒体处理。
|
||||
return resourceFileUrl(resourceId);
|
||||
// 展示直接命中 OSS/CDN;平台资源文件接口只保留给私有源站代理或本地存储兜底。
|
||||
return resolveResourceAccessURL((await getResourceAccess(storageKey, "display")).url);
|
||||
}
|
||||
const cached = objectUrls.get(storageKey);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -3,7 +3,7 @@ import localforage from "localforage";
|
||||
import { nanoid } from "nanoid";
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { importResourceFromUrl, isResourceUrl, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources";
|
||||
import { getResourceAccess, importResourceFromUrl, isResourceUrl, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources";
|
||||
import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
|
||||
export type UploadedImage = {
|
||||
@@ -81,8 +81,8 @@ export async function resolveImageUrl(storageKey?: string, fallback = "", option
|
||||
if (!storageKey) return fallback;
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (resourceId) {
|
||||
// 远程资源展示不返回 blob: URL,确保节点、素材库和弹窗都使用云端稳定地址。
|
||||
return resourceFileUrl(resourceId);
|
||||
// 远程资源展示直接使用 OSS/CDN 授权地址,不把媒体内容读进浏览器 Blob。
|
||||
return resolveResourceAccessURL((await getResourceAccess(storageKey, "display")).url);
|
||||
}
|
||||
const cached = objectUrls.get(storageKey);
|
||||
if (cached) return cached;
|
||||
|
||||
Reference in New Issue
Block a user