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

@@ -15,6 +15,9 @@ type OSSFormValues = {
region?: string;
endpoint?: string;
cdnBaseUrl?: string;
cdnAuthMode?: "" | "public" | "qiniu" | string;
requireCDN?: boolean;
allowPrivateProxy?: boolean;
bucket?: string;
accessKeyId?: string;
accessKeySecret?: string;
@@ -77,6 +80,9 @@ export function UserOSSSettingsForm() {
region: values.region?.trim() || "",
endpoint: values.endpoint?.trim() || "",
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
cdnAuthMode: values.cdnAuthMode || "",
requireCDN: values.requireCDN === true,
allowPrivateProxy: values.allowPrivateProxy === true,
bucket: values.bucket?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
@@ -138,7 +144,7 @@ export function UserOSSSettingsForm() {
<Select
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }, { label: "七牛云 Kodo", value: "qiniu" }, { label: "S3 兼容存储", value: "s3", disabled: setting?.allowUserS3 === false }]}
onChange={(nextProvider: OSSFormValues["provider"]) => {
if (nextProvider !== provider) form.setFieldsValue({ s3Preset: "custom", region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "", sessionToken: "", pathStyle: false });
if (nextProvider !== provider) form.setFieldsValue({ s3Preset: "custom", region: "", endpoint: "", cdnBaseUrl: "", cdnAuthMode: "", requireCDN: false, allowPrivateProxy: false, bucket: "", accessKeyId: "", accessKeySecret: "", sessionToken: "", pathStyle: false });
}}
/>
</Form.Item>
@@ -166,6 +172,20 @@ export function UserOSSSettingsForm() {
>
<Input inputMode="url" spellCheck={false} placeholder="https://media.example.com" />
</Form.Item>
<Form.Item
name="cdnAuthMode"
label="CDN 访问鉴权"
extra={isQiniuKodo ? "七牛私有 CDN 可选择七牛签名;公开绑定域名选择公开 CDN。阿里云/腾讯云私有 CDN 暂不自动签名。" : "公开 CDN 适合 CDN 自行鉴权;平台目前不会自动签发阿里云/腾讯云私有 CDN URL。"}
className="mb-3"
>
<Select options={[{ label: "未配置", value: "" }, { label: "公开 CDN", value: "public" }, { label: "七牛私有下载签名", value: "qiniu", disabled: !isQiniuKodo }]} />
</Form.Item>
<Form.Item name="requireCDN" label="必须走 CDN" valuePropName="checked" extra="开启后 CDN 鉴权未配置或不支持时直接失败,不会静默回源。" className="mb-3">
<Switch checkedChildren="必须" unCheckedChildren="允许回源" />
</Form.Item>
<Form.Item name="allowPrivateProxy" label="允许私有源站代理" valuePropName="checked" extra="仅作为显式兜底;开启后私有源站会经平台返回,平台仍会承担带宽。" className="mb-3">
<Switch checkedChildren="允许" unCheckedChildren="禁止" />
</Form.Item>
<Form.Item name="bucket" label="Bucket" className="mb-3">
<Input spellCheck={false} placeholder={isTencentCOS ? "my-canvas-assets-1250000000" : isQiniuKodo ? "七牛云存储空间名称" : "my-canvas-assets"} />
</Form.Item>
@@ -213,6 +233,9 @@ function toFormValues(setting: UserOSSSetting): OSSFormValues {
region: setting.region,
endpoint: setting.endpoint,
cdnBaseUrl: setting.cdnBaseUrl,
cdnAuthMode: setting.cdnAuthMode || "",
requireCDN: setting.requireCDN === true,
allowPrivateProxy: setting.allowPrivateProxy === true,
bucket: setting.bucket,
accessKeyId: setting.accessKeyId,
accessKeySecret: "",

View File

@@ -19,6 +19,9 @@ type OSSFormValues = {
region: string;
endpoint: string;
cdnBaseUrl: string;
cdnAuthMode: "" | "public" | "qiniu" | string;
requireCDN: boolean;
allowPrivateProxy: boolean;
bucket: string;
accessKeyId: string;
accessKeySecret: string;
@@ -29,7 +32,7 @@ type OSSFormValues = {
allowUserS3: boolean;
};
type StoragePayload = Pick<AdminOSSSetting, "enabled" | "provider" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "accessKeySecret" | "sessionToken" | "publicBaseUrl" | "pathPrefix" | "s3Preset" | "pathStyle" | "allowUserS3">;
type StoragePayload = Pick<AdminOSSSetting, "enabled" | "provider" | "region" | "endpoint" | "cdnBaseUrl" | "cdnAuthMode" | "requireCDN" | "allowPrivateProxy" | "bucket" | "accessKeyId" | "accessKeySecret" | "sessionToken" | "publicBaseUrl" | "pathPrefix" | "s3Preset" | "pathStyle" | "allowUserS3">;
const STORAGE_MODES: Array<{ mode: StorageMode; label: string; short: string; description: string }> = [
{ mode: "local", label: "服务器本地", short: "本地磁盘", description: "新增资源写入当前部署的数据目录,通过后端签名链接访问。" },
@@ -540,10 +543,29 @@ export default function StorageSettingsPage() {
<Form.Item
name="cdnBaseUrl"
label={draftMode === "qiniu" ? "绑定域名(可选)" : "CDN 加速域名(可选)"}
extra={draftMode === "qiniu" ? "留空时浏览器通过当前后端代理读取七牛私有对象。" : "只填写域名根地址,不包含路径、查询参数或认证信息。"}
extra={draftMode === "qiniu" ? "留空时按下方分发策略处理;不会由浏览器自行决定是否代理。" : "只填写域名根地址,不包含路径、查询参数或认证信息。"}
>
<Input autoComplete="off" inputMode="url" placeholder="https://media.example.com" />
</Form.Item>
<Form.Item
name="cdnAuthMode"
label="CDN 访问鉴权"
extra="public 适用于 CDN 已公开或由 CDN 自行鉴权;qiniu 仅支持七牛私有下载签名。阿里云/腾讯云私有 CDN 暂不自动签名。"
>
<Select
options={[
{ label: "未配置(回源或按兜底策略)", value: "" },
{ label: "公开 CDN", value: "public" },
{ label: "七牛私有下载签名", value: "qiniu" },
]}
/>
</Form.Item>
<Form.Item name="requireCDN" label="必须走 CDN" valuePropName="checked" extra="开启后 CDN 鉴权配置不完整时直接失败,不会静默回源。">
<Switch checkedChildren="严格" unCheckedChildren="允许回源" />
</Form.Item>
<Form.Item name="allowPrivateProxy" label="允许私有源站代理" valuePropName="checked" extra="仅作为显式兜底;开启后源站私有时由平台承担带宽。">
<Switch checkedChildren="允许" unCheckedChildren="禁止" />
</Form.Item>
</div>
</div>
@@ -616,6 +638,9 @@ function formValues(setting: AdminOSSSetting): OSSFormValues {
region: setting.region || "",
endpoint: setting.endpoint || "",
cdnBaseUrl: setting.cdnBaseUrl || "",
cdnAuthMode: setting.cdnAuthMode || "",
requireCDN: setting.requireCDN === true,
allowPrivateProxy: setting.allowPrivateProxy === true,
bucket: setting.bucket || "",
accessKeyId: setting.accessKeyId || "",
accessKeySecret: "",
@@ -633,6 +658,9 @@ function providerDraftValues(mode: Exclude<StorageMode, "local">, setting: Admin
region: setting.region || "",
endpoint: setting.endpoint || "",
cdnBaseUrl: setting.cdnBaseUrl || "",
cdnAuthMode: setting.cdnAuthMode || "",
requireCDN: setting.requireCDN === true,
allowPrivateProxy: setting.allowPrivateProxy === true,
bucket: setting.bucket || "",
accessKeyId: setting.accessKeyId || "",
accessKeySecret: "",
@@ -646,6 +674,9 @@ function providerDraftValues(mode: Exclude<StorageMode, "local">, setting: Admin
region: "",
endpoint: "",
cdnBaseUrl: "",
cdnAuthMode: "",
requireCDN: false,
allowPrivateProxy: false,
bucket: "",
accessKeyId: "",
accessKeySecret: "",
@@ -668,6 +699,9 @@ function normalizeStoragePayload(values: Partial<OSSFormValues>, setting: AdminO
region,
endpoint,
cdnBaseUrl: trimTrailingSlash(values.cdnBaseUrl || ""),
cdnAuthMode: values.cdnAuthMode || "",
requireCDN: values.requireCDN === true,
allowPrivateProxy: values.allowPrivateProxy === true,
bucket: values.bucket?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
@@ -717,7 +751,7 @@ function validatePublicBaseURL(value: string) {
function storageResponseMatches(setting: AdminOSSSetting, expected: StoragePayload) {
const actual = normalizeStoragePayload(formValues(setting), setting);
const fields: Array<keyof StoragePayload> = ["enabled", "provider", "region", "endpoint", "cdnBaseUrl", "bucket", "accessKeyId", "publicBaseUrl", "pathPrefix", "s3Preset", "pathStyle", "allowUserS3"];
const fields: Array<keyof StoragePayload> = ["enabled", "provider", "region", "endpoint", "cdnBaseUrl", "cdnAuthMode", "requireCDN", "allowPrivateProxy", "bucket", "accessKeyId", "publicBaseUrl", "pathPrefix", "s3Preset", "pathStyle", "allowUserS3"];
if (expected.accessKeySecret && !setting.hasAccessKeySecret) return false;
if (expected.sessionToken && !setting.hasSessionToken) return false;
return fields.every((key) => actual[key] === expected[key]);
@@ -733,6 +767,9 @@ function isAdminOSSSetting(value: unknown): value is AdminOSSSetting {
typeof setting.region === "string" &&
typeof setting.endpoint === "string" &&
typeof setting.cdnBaseUrl === "string" &&
typeof setting.cdnAuthMode === "string" &&
typeof setting.requireCDN === "boolean" &&
typeof setting.allowPrivateProxy === "boolean" &&
typeof setting.bucket === "string" &&
typeof setting.accessKeyId === "string" &&
typeof setting.hasAccessKeySecret === "boolean" &&

View File

@@ -11,7 +11,7 @@ import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { uploadMediaFile } from "@/services/file-storage";
import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer";
import { getActiveUserScope } from "@/lib/user-scope";
import { resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
import { uploadImage } from "@/services/image-storage";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
@@ -188,6 +188,11 @@ async function copyImageToSystemClipboard(source: string, storageKey?: string) {
let sourceBlob: Blob | null = null;
if (storageKey) {
sourceBlob = await getCachedResourceBlob(storageKey).catch(() => null);
// A resource-backed image must be read through the resource access
// contract. Do not fall back to fetch(source) here: local/proxy
// deliveries may require the session cookie and a separate API
// origin, while CDN deliveries must omit that cookie.
if (!sourceBlob) throw new Error("图片资源读取失败");
}
if (!sourceBlob) {
const response = await fetch(source);
@@ -1742,16 +1747,22 @@ function InfiniteCanvasPage() {
if (copyingNodeContentRef.current) return;
copyingNodeContentRef.current = true;
releaseCopiedNodesPastePriority();
const content = node?.metadata?.content?.trim();
const resourceId = resourceIdFromStorageKey(node?.metadata?.storageKey);
const copySource = content || (node?.type === CanvasNodeType.Image && resourceId ? resourceFileUrl(resourceId) : "");
if (!node || !copySource) {
if (!node) {
copyingNodeContentRef.current = false;
message.warning("没有可复制的内容");
return;
}
try {
const content = node.metadata?.content?.trim();
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
// Resource-backed media must use the central access contract. This keeps
// copy operations on the configured CDN/OSS URL instead of copying the
// platform file endpoint or a stale URL persisted in canvas metadata.
const copySource = resourceId
? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url)
: content || "";
if (!copySource) throw new Error("没有可复制的内容");
if (node.type === CanvasNodeType.Image) {
try {
await copyImageToSystemClipboard(copySource, node.metadata?.storageKey);
@@ -1791,8 +1802,10 @@ function InfiniteCanvasPage() {
const storageKey = node?.metadata?.storageKey;
const content = node?.metadata?.content?.trim();
const resourceId = resourceIdFromStorageKey(storageKey);
const mediaPath = content && !content.startsWith("data:") && !content.startsWith("blob:") ? content : resourceId ? resourceFileUrl(resourceId) : "";
const mediaURL = mediaPath ? new URL(mediaPath, window.location.href).toString() : "";
const mediaPath = content && !content.startsWith("data:") && !content.startsWith("blob:") ? content : "";
const mediaURL = resourceId
? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url)
: mediaPath ? new URL(mediaPath, window.location.href).toString() : "";
if (!mediaURL) throw new Error("当前媒体只有本地内容,没有可复制的地址");
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(mediaURL);
else if (!(await copyToClipboard(mediaURL))) throw new Error("当前浏览器不支持写入剪贴板");

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;
}