refactor(media): 资源分发 - 统一资源访问策略与云存储重定向

This commit is contained in:
ddCat
2026-09-22 21:41:00 +08:00
parent af6633047c
commit 487c34bfeb
50 changed files with 2112 additions and 1266 deletions

View File

@@ -33,6 +33,9 @@ export type UserOSSSetting = {
region: string;
endpoint: string;
cdnBaseUrl: string;
cdnAuthMode: "" | "public" | "qiniu" | string;
requireCDN: boolean;
allowPrivateProxy: boolean;
bucket: string;
accessKeyId: string;
hasAccessKeySecret: boolean;
@@ -52,6 +55,9 @@ export type UserOSSSetting = {
export type UserOSSSettingInput = Pick<UserOSSSetting, "enabled" | "provider" | "s3Preset" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "pathPrefix" | "pathStyle"> & {
accessKeySecret?: string;
sessionToken?: string;
cdnAuthMode?: "" | "public" | "qiniu" | string;
requireCDN?: boolean;
allowPrivateProxy?: boolean;
};
export type AccountFileStorageUsage = {
@@ -96,9 +102,23 @@ export class ResourceUploadError extends Error {
const resourceCache = new Map<string, RemoteResource>();
const resourceRequests = new Map<string, Promise<RemoteResource>>();
const missingResourceIds = new Set<string>();
const ossUrlCache = new Map<string, { url: string; expiresAt: number }>();
const ossUrlRequests = new Map<string, Promise<string>>();
const OSS_URL_CACHE_TTL_MS = 4 * 60 * 1000;
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<string, { value: ResourceAccess; expiresAt: number }>();
const accessRequests = new Map<string, Promise<ResourceAccess>>();
export function resourceStorageKey(id: string) {
return `resource:${id}`;
@@ -271,42 +291,61 @@ export function refreshResource(id: string): Promise<RemoteResource> {
});
}
export async function getResourceOSSUrl(storageKey?: string) {
export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original") {
const id = resourceIdFromStorageKey(storageKey);
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
const cached = ossUrlCache.get(resourceCacheKey(id));
if (cached && cached.expiresAt > Date.now()) return cached.url;
const pending = ossUrlRequests.get(resourceCacheKey(id));
const key = `${resourceCacheKey(id)}:${purpose}:${variant}`;
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.get<{ url: string }>(`/resources/${encodeURIComponent(id)}/oss-url`);
if (!data.url) throw new Error("后端未返回对象存储地址");
ossUrlCache.set(resourceCacheKey(id), { url: data.url, expiresAt: Date.now() + OSS_URL_CACHE_TTL_MS });
return data.url;
const data = await http.post<{ items: Array<{ resourceId: string; access?: ResourceAccess; error?: { msg?: string } }> }>("/resources/access", [{ resourceId: id, purpose, variant }]);
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 {
ossUrlRequests.delete(resourceCacheKey(id));
accessRequests.delete(key);
}
})();
ossUrlRequests.set(resourceCacheKey(id), request);
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?direct=1`;
}
function resourceProxyFileUrl(id: string) {
const base = String(apiBaseURL).replace(/\/+$/, "");
return `${base}/resources/${encodeURIComponent(id)}/file?proxy=1`;
return `${base}/resources/${encodeURIComponent(id)}/file`;
}
export function resolveResourceUrl(storageKey?: string, fallback = "") {
@@ -320,25 +359,21 @@ export function resolveResourceUrl(storageKey?: string, fallback = "") {
// 副本就绪前由后端回退原件,调用方再按需降级。
export function playbackVariantUrl(id: string) {
const base = String(apiBaseURL).replace(/\/+$/, "");
return `${base}/resources/${encodeURIComponent(id)}/file?variant=playback&direct=1`;
return `${base}/resources/${encodeURIComponent(id)}/file?variant=playback`;
}
export async function getResourceBlob(storageKey: string, options?: { allowProxyFallback?: boolean }) {
export async function getResourceBlob(storageKey: string) {
const id = resourceIdFromStorageKey(storageKey);
if (!id) return null;
// A direct OSS response needs CORS to be readable as a Blob. Native <img>/<video>
// can still display it without CORS, so background cache fills must not proxy it.
try {
const ossUrl = await getResourceOSSUrl(storageKey);
const response = await fetch(ossUrl, { credentials: "omit", mode: "cors" });
if (response.ok) return response.blob();
if (!options?.allowProxyFallback || response.status === 401 || response.status === 403 || response.status === 404) return null;
} catch (error) {
if (!options?.allowProxyFallback) throw error;
}
if (!options?.allowProxyFallback) return null;
const response = await fetch(resourceProxyFileUrl(id), { credentials: "include" });
return response.ok ? response.blob() : 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) {

View File

@@ -1,4 +1,4 @@
import { getResourceOSSUrl } from "@/services/api/resources";
import { getResourceInputURL } from "@/services/api/resources";
import { buildApiUrl, isSystemProxyBaseUrl, modelOptionName } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -184,7 +184,7 @@ function agnesAspectRatio(value: string) {
}
async function resolveAgnesMediaUrl(value: string | undefined, storageKey?: string) {
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
if (storageKey?.startsWith("resource:")) return getResourceInputURL(storageKey);
if (/^https?:\/\//i.test(value || "")) return String(value);
throw new Error("Agnes 参考素材需要公网 URL;请先把素材保存到对象存储");
}

View File

@@ -1,6 +1,6 @@
import { modelCapabilityConfigFor, videoResolutionRequest } from "@/lib/model-capabilities";
import { boolConfig } from "@/lib/seedance-video";
import { getResourceOSSUrl } from "@/services/api/resources";
import { getResourceInputURL } from "@/services/api/resources";
import { modelOptionName } from "@/stores/use-config-store";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import type { ReferenceImage } from "@/types/image";
@@ -63,7 +63,7 @@ function newAPIVideoResolutionRequest(profile: NonNullable<ReturnType<typeof mod
}
async function resolveVideoGenerationsUrl(value: string | undefined, storageKey?: string) {
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
if (storageKey?.startsWith("resource:")) return getResourceInputURL(storageKey);
if (isPublicMediaUrl(value || "")) return String(value);
throw new Error("NewAPI Video Generations 的参考素材需要公网 URL;请先把素材保存到对象存储");
}

View File

@@ -1,7 +1,7 @@
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { isVolcengineArkVideoProtocol } from "@/lib/model-protocols";
import { boolConfig, buildSeedancePromptText, isArkPlanBaseUrl, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
import { getResourceOSSUrl } from "@/services/api/resources";
import { getResourceInputURL } from "@/services/api/resources";
import { getMediaBlob } from "@/services/file-storage";
import { imageToDataUrl } from "@/services/image-storage";
import { buildApiUrl, modelOptionName, type AiConfig } from "@/stores/use-config-store";
@@ -121,7 +121,7 @@ async function buildVolcengineArkContent(prompt: string, references: ReferenceIm
async function resolveVolcengineArkReferenceUrl(value: string | undefined, storageKey?: string) {
// 已录入的方舟素材 ID 优先:已过审或被授权的素材直接引用,无需再换对象存储地址。
if (String(value || "").startsWith("asset://")) return String(value);
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
if (storageKey?.startsWith("resource:")) return getResourceInputURL(storageKey);
if (isPublicMediaUrl(value || "")) return String(value);
throw new Error("火山方舟视频参考素材需要公网 URL 或 asset:// 素材 ID;请先将本地素材保存到对象存储");
}

View File

@@ -125,18 +125,16 @@ export async function getCachedResourceBlob(storageKey: string) {
await pending.catch(() => "");
const downloaded = sessionBlobs.get(target.key) || await blobStore.getItem<Blob>(target.key);
if (downloaded) return downloaded;
return loadAndPersistProxyFallback(storageKey);
return loadAndPersistResource(storageKey);
}
await cacheResourceObjectUrl(storageKey).catch(() => "");
const downloaded = sessionBlobs.get(target.key) || await blobStore.getItem<Blob>(target.key);
if (downloaded) return downloaded;
// Explicit byte consumers (exports/provider inputs) need a same-origin fallback
// when the OSS bucket does not expose CORS. Display-only cache fills never do.
return loadAndPersistProxyFallback(storageKey);
return loadAndPersistResource(storageKey);
}
async function loadAndPersistProxyFallback(storageKey: string) {
const blob = await getResourceBlob(storageKey, { allowProxyFallback: true });
async function loadAndPersistResource(storageKey: string) {
const blob = await getResourceBlob(storageKey);
if (blob) await primeResourceBlobCache(storageKey, blob).catch(() => "");
return blob;
}