refactor(media): 资源分发 - 统一资源访问策略与云存储重定向
This commit is contained in:
@@ -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: "",
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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("当前浏览器不支持写入剪贴板");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;请先把素材保存到对象存储");
|
||||
}
|
||||
|
||||
@@ -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;请先把素材保存到对象存储");
|
||||
}
|
||||
|
||||
@@ -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;请先将本地素材保存到对象存储");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user