From 4eb7bca536c3723af28f008ddad511c9deb9c698 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 24 Jun 2026 21:14:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=AF=BC=E5=87=BA=E8=B7=A8=E5=9F=9F=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导出读取改走同源 read-bytes 接口 readAssetBytes 支持 objectKey 直读 补充对象素材导出不触发 OSS 跨域请求的回归测试 更新画布素材导出文档的 CORS 边界 --- ...端架构】图片画布素材导出方案-2026-06-15.md | 1 + .../ImageCanvasExportModel.test.ts | 52 ++++--------------- .../image-editor/ImageCanvasExportModel.ts | 16 +----- ...useImageCanvasAssetExportWorkflow.test.tsx | 28 +++------- src/services/assetReadUrlService.test.ts | 28 ++++++++++ src/services/assetReadUrlService.ts | 9 ++-- 6 files changed, 53 insertions(+), 81 deletions(-) diff --git a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md index 395aae8ca..bac119faa 100644 --- a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md +++ b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md @@ -132,6 +132,7 @@ assetObjectId > objectKey > sourceAssetId > src 4. 对每个素材源读取 Blob: - `data:image/...` 直接转换为 Blob。 - 同源或可访问 URL 使用 `fetch` 拉取 Blob。 + - 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。 - `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。 - 拉取失败时记录失败项,不中断整个导出。 5. 使用 `JSZip` 写入 `images/`、`metadata.json` 和 `manifest.txt`。 diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 9d523b5c5..6479edb40 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -169,26 +169,10 @@ describe('ImageCanvasExportModel', () => { }); }); - it('reads private object-key assets through signed URLs before exporting', async () => { + it('reads private object-key assets through same-origin bytes before exporting', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { - if (url.startsWith('/api/assets/read-url?')) { - return new Response( - JSON.stringify({ - ok: true, - data: { - read: { - objectKey: 'generated/video.mp4', - signedUrl: 'https://oss.example.com/generated/video.mp4?x-oss-signature=1', - expiresAt: '2026-06-20T00:00:00.000Z', - }, - }, - error: null, - meta: { apiVersion: '2026-06-16' }, - }), - ); - } - if (url === 'https://oss.example.com/generated/video.mp4?x-oss-signature=1') { + if (url.startsWith('/api/assets/read-bytes?')) { return new Response(new Blob(['video'], { type: 'video/mp4' })); } return new Response(null, { status: 404 }); @@ -206,40 +190,22 @@ describe('ImageCanvasExportModel', () => { expect(blob.type).toBe('video/mp4'); expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/api/assets/read-url?objectKey=generated%2Fvideo.mp4'), + expect.stringContaining('/api/assets/read-bytes?objectKey=generated%2Fvideo.mp4'), expect.any(Object), ); - expect(fetchMock).toHaveBeenCalledWith( - 'https://oss.example.com/generated/video.mp4?x-oss-signature=1', + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining('x-oss-signature'), + expect.anything(), ); } finally { globalThis.fetch = originalFetch; } }); - it('reads private image-sequence frame object keys through signed URLs', async () => { + it('reads private image-sequence frame object keys through same-origin bytes', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { - if (url.startsWith('/api/assets/read-url?')) { - return new Response( - JSON.stringify({ - ok: true, - data: { - read: { - objectKey: 'generated/frame-1.png', - signedUrl: - 'https://oss.example.com/generated/frame-1.png?x-oss-signature=1', - expiresAt: '2026-06-20T00:00:00.000Z', - }, - }, - error: null, - meta: { apiVersion: '2026-06-16' }, - }), - ); - } - if ( - url === 'https://oss.example.com/generated/frame-1.png?x-oss-signature=1' - ) { + if (url.startsWith('/api/assets/read-bytes?')) { return new Response(new Blob(['frame'], { type: 'image/png' })); } return new Response(null, { status: 404 }); @@ -272,7 +238,7 @@ describe('ImageCanvasExportModel', () => { expect(blob).toBeTruthy(); expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining( - '/api/assets/read-url?objectKey=generated%2Fframe-1.png', + '/api/assets/read-bytes?objectKey=generated%2Fframe-1.png', ), expect.any(Object), ); diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 7da3b3a1e..320878a48 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -1,3 +1,4 @@ +import { readAssetBytes } from '../../services/assetReadUrlService'; import type { CanvasAssetExportMetadata, CanvasGenerationInputField, @@ -11,10 +12,6 @@ import { UI_DESIGN_ASSET_EXTRACTION_PROMPT, } from './ImageCanvasGenerationModel'; import { formatCanvasDurationMetric } from './ImageCanvasMediaModel'; -import { - getSignedAssetReadUrl, - resolveAssetReadUrl, -} from '../../services/assetReadUrlService'; export function sanitizeExportFilePart(value: string, fallback: string) { const safeValue = value @@ -153,7 +150,6 @@ export function dataUrlToBlob(dataUrl: string) { export async function readAssetSourceBlob({ source, objectKey, - refreshKey, }: { source: string; objectKey?: string | null; @@ -162,15 +158,7 @@ export async function readAssetSourceBlob({ if (source.startsWith('data:')) { return dataUrlToBlob(source); } - const resolvedSource = objectKey - ? await getSignedAssetReadUrl({ objectKey }, undefined, { - cacheVersion: refreshKey, - }) - : await resolveAssetReadUrl(source, { refreshKey }); - const response = await fetch(resolvedSource); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } + const response = await readAssetBytes(source, { objectKey }); return response.blob(); } diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index a32b73de4..41325c9d0 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -302,26 +302,7 @@ describe('useImageCanvasAssetExportWorkflow', () => { it('reports empty exports and supports direct layer image downloads', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { - if (url.startsWith('/api/assets/read-url?')) { - return new Response( - JSON.stringify({ - ok: true, - data: { - read: { - objectKey: 'generated/private.png', - signedUrl: - 'https://oss.example.com/generated/private.png?x-oss-signature=1', - expiresAt: '2026-06-20T00:00:00.000Z', - }, - }, - error: null, - meta: { apiVersion: '2026-06-16' }, - }), - ); - } - if ( - url === 'https://oss.example.com/generated/private.png?x-oss-signature=1' - ) { + if (url.startsWith('/api/assets/read-bytes?')) { return new Response(new Blob(['private'], { type: 'image/png' })); } if (url.startsWith('/generated/video.webm?token=1')) { @@ -373,10 +354,15 @@ describe('useImageCanvasAssetExportWorkflow', () => { expect(downloadedBlob).toBeTruthy(); expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining( - '/api/assets/read-url?objectKey=generated%2Fprivate.png', + '/api/assets/read-bytes?objectKey=generated%2Fprivate.png', ), expect.any(Object), ); + expect( + fetchMock.mock.calls.some(([url]) => + String(url).includes('x-oss-signature'), + ), + ).toBe(false); render( { ); }); + test('readAssetBytes reads object-key resources through same-origin bytes endpoint', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { + 'Content-Type': 'image/png', + }, + }), + ); + + const response = await readAssetBytes('/generated-editor-images/image.png', { + objectKey: 'generated-editor-images/project/image.png', + expireSeconds: 300, + }); + const bytes = new Uint8Array(await response.arrayBuffer()); + + expect(Array.from(bytes)).toEqual([1, 2, 3]); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( + '/api/assets/read-bytes?', + ); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( + 'objectKey=generated-editor-images%2Fproject%2Fimage.png', + ); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).not.toContain( + 'legacyPublicPath=', + ); + }); + test('readAssetBytes normalizes full OSS generated urls through bytes endpoint', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(new Uint8Array([1, 2, 3]), { diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index 1507a35aa..839baafb1 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -31,6 +31,7 @@ type AssetReadUrlResolveOptions = { type AssetReadBytesOptions = { signal?: AbortSignal; expireSeconds?: number; + objectKey?: string | null; }; export type AssetReadUrlResponse = { @@ -534,21 +535,23 @@ export async function readAssetBytes( throw new Error('资源路径不能为空'); } + const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? ''; const legacyPath = isGeneratedLegacyPath(value) ? value : resolveGeneratedLegacyPathFromUrl(value); - if (!legacyPath) { + if (!objectKey && !legacyPath) { const response = await fetch(value, { signal: options.signal }); if (!response.ok) { - throw new Error('读取资源内容失败'); + throw new Error(`读取资源内容失败(HTTP ${response.status})`); } return response; } // 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。 const searchParams = buildAssetReadSearchParams({ - legacyPublicPath: legacyPath, + objectKey, + legacyPublicPath: objectKey ? undefined : legacyPath, expireSeconds: options.expireSeconds, }); const response = await fetchWithApiAuth(