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

@@ -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("当前浏览器不支持写入剪贴板");