From c5295a619c67c7464229442e4372eb5259e44226 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 25 Jun 2026 23:01:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E7=94=9F=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=BD=AE=E8=AF=A2=E4=B8=8E=E8=B5=84=E4=BA=A7=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空生成任务列表不再保持四秒轮询 读取资源字节优先通过read-url直连OSS 保留read-bytes作为OSS读取失败兜底 更新资产导出与项目记忆文档口径 --- .../shared-memory/decision-log.md | 2 + docs/project-memory/shared-memory/pitfalls.md | 2 +- ...端架构】图片画布素材导出方案-2026-06-15.md | 2 +- .../PlatformEntryFlowShellImpl.test.ts | 47 ++++ .../PlatformEntryFlowShellImpl.tsx | 58 ++++- ...tformExternalGenerationQueueStatusModel.ts | 42 ++++ src/services/assetReadUrlService.test.ts | 212 +++++++++++++++--- src/services/assetReadUrlService.ts | 30 ++- .../match3dGeneratedModelCache.test.ts | 39 +++- 9 files changed, 387 insertions(+), 47 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 64db74d57..90b0f6654 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,6 +20,7 @@ - 背景:外部生成队列已经承载画板和玩法的付费生成,但前端只展示排队概览,缺少可追溯任务列表、后端确认状态、完成提示补弹和退款记录到任务的追踪关系。 - 决策:`external_generation_job` 同时作为正式生成任务列表事实源,保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`;新增 `external_generation_job_event` 追加状态转换审计。BFF 新增当前账号任务列表和 acknowledge 接口;前端只展示后端任务状态,完成 / 失败提示关闭时由后端写确认时间,未确认终态任务在下次登录后按列表集中弹出。任务触发的钱包扣费 / 退款流水 metadata 必须写 `externalGenerationJobId`,本机退款 outbox 重放也保留该任务 ID。 +- 2026-06-25 追加:平台壳的当前账号任务列表只在登录、网络恢复、页面回到前台或已有 queued/running/未确认终态任务时刷新;空队列刷新一次后不保持 4 秒轮询,避免 `/api/runtime/external-generation/jobs` 在无任务时持续请求。 - 影响范围:`spacetime-module` 外部生成 schema / procedure、`spacetime-client` bindings/facade、`api-server` 外部生成 BFF、worker 失败回写和资产计费退款链路、平台入口“我的”页任务卡和完成提示弹窗。 - 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wallet_refund_outbox --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -1030,6 +1031,7 @@ - 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。 - 决策:OSS 继续作为 generated 私有资产源站,api-server 只签发短期读 URL,不做本地磁盘静态资源兜底。前端收到同 bucket 的 `https://*.oss-*.aliyuncs.com/generated-*` 地址时,必须先归一为 legacy public path,再复用 `/api/assets/read-url` 和本地 signed URL 缓存。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`,缓存职责交给 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN。 +- 2026-06-25 追加:前端需要读取 generated/private 资源字节时,也应先通过 `/api/assets/read-url` 获取 signed OSS URL 并由浏览器直接下载字节;`/api/assets/read-bytes` 只作为换签或 OSS 读取失败后的 fallback,不作为默认文件代理路径。 - 影响范围:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss`、`shared-contracts` direct upload form fields、`api-server` assets DTO 映射、后端契约文档和开发运维排障口径。 - 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径、同一 `refreshKey` 版本且未临近过期时复用本地 signed URL;`platform-oss` 的 `PostObject` policy / form fields 和 `PutObject` 请求头都应包含 immutable `Cache-Control`,且 `PutObject` V4 签名的 `AdditionalHeaders` 包含该普通请求头。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/platform-oss/README.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a04a9bb5d..26a9f5c48 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -147,7 +147,7 @@ - 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`。 - 原因:快速编辑直接把图层 `src` 塞进 `/api/editor/images/generations` 的 `referenceImageSrcs`;默认示例图和部分持久化图层的 `src` 是 `/creation-type-references/*.webp`、`/generated-*` 或 OSS URL,而 `api-server` 的编辑参考图解析只接收 `data:image/*;base64,...`。 -- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 走 `/api/assets/read-bytes` 避免 CORS,普通 public 路径直接 fetch。 +- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 先走 `/api/assets/read-url` 换签后由浏览器直读 OSS,直读失败时才 fallback 到 `/api/assets/read-bytes`,普通 public 路径直接 fetch。 - 验证:`npm run test -- src/services/image-editor/editorImageReference.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx -t "editorImageReference|converts non-data-url quick edit source images before submitting references"`。 - 关联:`src/services/image-editor/editorImageReference.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md index ba0fb6d88..d9d3a8e3f 100644 --- a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md +++ b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md @@ -166,7 +166,7 @@ assetObjectId > objectKey > sourceAssetId > src 4. 对每个素材源读取 Blob: - `data:image/...` 直接转换为 Blob。 - 同源或可访问 URL 使用 `fetch` 拉取 Blob。 - - 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。 + - 私有 generated / OSS 素材先走 `/api/assets/read-url` 换签并由浏览器直接 `fetch` OSS 签名 URL;只有换签或 OSS 字节读取失败时,才 fallback 到同源 `/api/assets/read-bytes`。 - `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。 - 画布素材 ZIP 内的 `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。 - 单图层普通序列帧导出写入 `preview.gif`,由前端基于成功读取的帧生成动画预览,不依赖压缩包读取时再临时播放 PNG。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts index d59d0d094..1f6943ef1 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts @@ -4,6 +4,7 @@ import { createMiniGameDraftGenerationState } from '../../services/miniGameDraft import { buildExternalGenerationQueuePresentation, buildExternalGenerationQueueStatus, + shouldUseFastExternalGenerationTaskPolling, } from './platformExternalGenerationQueueStatusModel'; import { resolveFinishedMiniGameDraftGenerationState, @@ -129,4 +130,50 @@ describe('buildExternalGenerationQueueStatus', () => { }).shouldShow, ).toBe(false); }); + + test('只在队列活跃或存在未确认终态任务时使用快速轮询', () => { + expect( + shouldUseFastExternalGenerationTaskPolling({ + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + tasks: [], + }), + ).toBe(false); + + expect( + shouldUseFastExternalGenerationTaskPolling({ + currentStatus: 'queued', + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + tasks: [], + }), + ).toBe(true); + + expect( + shouldUseFastExternalGenerationTaskPolling({ + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + tasks: [ + { + jobId: 'extgen-completed', + jobKind: 'editor_image_generation', + sourceModule: 'editor', + sourceEntityId: 'project-1', + requestLabel: '图片生成', + status: 'completed', + phaseLabel: '已完成', + phaseDetail: '已完成', + progress: 100, + priceMudPoints: 1, + createdAt: '2026-06-25T00:00:00.000Z', + updatedAt: '2026-06-25T00:00:00.000Z', + updatedAtMicros: 1_782_348_800_000_000, + }, + ], + }), + ).toBe(true); + }); }); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 75a224df3..4efb76616 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -544,7 +544,10 @@ import type { } from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; import { PlatformErrorDialog } from './PlatformErrorDialog'; -import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel'; +import { + buildExternalGenerationQueueStatus, + shouldUseFastExternalGenerationTaskPolling, +} from './platformExternalGenerationQueueStatusModel'; import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel'; import { sendPlatformHostDraftNotificationToHost, @@ -707,6 +710,7 @@ type PuzzleBackgroundCompileTask = { generationState: MiniGameDraftGenerationState; error: string | null; }; +const EXTERNAL_GENERATION_TASK_FAST_POLL_INTERVAL_MS = 4_000; function isUnacknowledgedExternalGenerationTerminalTask( task: ExternalGenerationTaskRecord, @@ -4967,6 +4971,15 @@ export function PlatformEntryFlowShellImpl({ woodenFishGenerationState, ); const isHostNetworkOnline = useHostNetworkOnline(); + const shouldFastPollKnownExternalGenerationTask = + puzzleOperation?.queueState?.status === 'queued' || + puzzleOperation?.queueState?.status === 'running' || + jumpHopQueueState?.status === 'queued' || + jumpHopQueueState?.status === 'running' || + puzzleClearQueueState?.status === 'queued' || + puzzleClearQueueState?.status === 'running' || + woodenFishQueueState?.status === 'queued' || + woodenFishQueueState?.status === 'running'; const shouldPollExternalGenerationTasks = platformBootstrap.canReadProtectedData && Boolean(authUi?.user?.id); const externalGenerationQueueOwnerKey = @@ -4991,6 +5004,21 @@ export function PlatformEntryFlowShellImpl({ let disposed = false; let controller: AbortController | null = null; + let timerId: number | null = null; + + const scheduleNextRefresh = (delayMs: number) => { + if (disposed) { + return; + } + if (timerId != null) { + window.clearTimeout(timerId); + timerId = null; + } + if (delayMs <= 0) { + return; + } + timerId = window.setTimeout(refreshTaskList, delayMs); + }; const refreshTaskList = () => { controller?.abort(); @@ -5010,6 +5038,18 @@ export function PlatformEntryFlowShellImpl({ ), ); setExternalGenerationTaskOverview(response.overview); + scheduleNextRefresh( + shouldFastPollKnownExternalGenerationTask || + shouldUseFastExternalGenerationTaskPolling( + buildExternalGenerationQueueStatus( + response.overview, + null, + response.tasks, + ), + ) + ? EXTERNAL_GENERATION_TASK_FAST_POLL_INTERVAL_MS + : 0, + ); } }) .catch(() => { @@ -5021,16 +5061,28 @@ export function PlatformEntryFlowShellImpl({ }; refreshTaskList(); - const intervalId = window.setInterval(refreshTaskList, 4000); + const handleResume = () => { + if (document.visibilityState === 'hidden') { + return; + } + refreshTaskList(); + }; + window.addEventListener('focus', handleResume); + document.addEventListener('visibilitychange', handleResume); return () => { disposed = true; controller?.abort(); - window.clearInterval(intervalId); + if (timerId != null) { + window.clearTimeout(timerId); + } + window.removeEventListener('focus', handleResume); + document.removeEventListener('visibilitychange', handleResume); }; }, [ externalGenerationQueueOwnerKey, isHostNetworkOnline, + shouldFastPollKnownExternalGenerationTask, shouldPollExternalGenerationTasks, ]); const activeExternalGenerationJobState = useMemo(() => { diff --git a/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts index 3cbcc8e58..fede09ff4 100644 --- a/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts +++ b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts @@ -55,6 +55,48 @@ export function buildExternalGenerationQueueStatus( }; } +function isActiveExternalGenerationStatus( + status: ExternalGenerationQueueStatus['currentStatus'], +) { + return status === 'queued' || status === 'running'; +} + +function isUnacknowledgedExternalGenerationTerminalTask( + task: ExternalGenerationTaskRecord, +) { + return ( + (task.status === 'completed' || task.status === 'failed') && + !task.notificationAcknowledgedAt + ); +} + +export function shouldUseFastExternalGenerationTaskPolling( + status: ExternalGenerationQueueStatus | null | undefined, +) { + const pendingCount = normalizeExternalGenerationQueueCount( + status?.pendingCount, + ); + const runningCount = normalizeExternalGenerationQueueCount( + status?.runningCount, + ); + const unacknowledgedTerminalCount = normalizeExternalGenerationQueueCount( + status?.unacknowledgedTerminalCount, + ); + + return Boolean( + isActiveExternalGenerationStatus(status?.currentStatus ?? null) || + pendingCount > 0 || + runningCount > 0 || + unacknowledgedTerminalCount > 0 || + (status?.tasks ?? []).some( + (task) => + task.status === 'queued' || + task.status === 'running' || + isUnacknowledgedExternalGenerationTerminalTask(task), + ), + ); +} + export function resolveExternalGenerationQueueStatusLabel( status: ExternalGenerationQueueStatus['currentStatus'], ) { diff --git a/src/services/assetReadUrlService.test.ts b/src/services/assetReadUrlService.test.ts index 8e557e15d..48a794928 100644 --- a/src/services/assetReadUrlService.test.ts +++ b/src/services/assetReadUrlService.test.ts @@ -496,15 +496,44 @@ describe('assetReadUrlService', () => { expect(window.dispatchEvent).not.toHaveBeenCalled(); }); - test('readAssetBytes reads generated resources through same-origin bytes endpoint', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(new Uint8Array([104, 101, 108, 108, 111]), { - status: 200, - headers: { - 'Content-Type': 'image/png', - }, - }), - ); + test('readAssetBytes reads generated resources through signed OSS url first', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey: + 'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png', + signedUrl: 'https://signed.example.com/match3d-item.png', + expiresAt: '2099-01-01T00:10:00Z', + }, + }, + error: null, + meta: { + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', + latencyMs: 1, + timestamp: '2099-01-01T00:00:00Z', + }, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ), + ) + .mockResolvedValueOnce( + new Response(new Uint8Array([104, 101, 108, 108, 111]), { + status: 200, + headers: { + 'Content-Type': 'image/png', + }, + }), + ); const response = await readAssetBytes( '/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png', @@ -515,22 +544,53 @@ describe('assetReadUrlService', () => { expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]); expect(response.headers.get('content-type')).toBe('image/png'); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( - '/api/assets/read-bytes?', + '/api/assets/read-url?', ); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( 'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png', ); + expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe( + 'https://signed.example.com/match3d-item.png', + ); }); - 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', - }, - }), - ); + test('readAssetBytes reads object-key resources through signed OSS url first', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey: 'generated-editor-images/project/image.png', + signedUrl: 'https://signed.example.com/editor-image.png', + expiresAt: '2099-01-01T00:10:00Z', + }, + }, + error: null, + meta: { + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', + latencyMs: 1, + timestamp: '2099-01-01T00:00:00Z', + }, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ), + ) + .mockResolvedValueOnce( + 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', @@ -540,7 +600,7 @@ describe('assetReadUrlService', () => { expect(Array.from(bytes)).toEqual([1, 2, 3]); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( - '/api/assets/read-bytes?', + '/api/assets/read-url?', ); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( 'objectKey=generated-editor-images%2Fproject%2Fimage.png', @@ -548,17 +608,49 @@ describe('assetReadUrlService', () => { expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).not.toContain( 'legacyPublicPath=', ); + expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe( + 'https://signed.example.com/editor-image.png', + ); }); - test('readAssetBytes normalizes full OSS generated urls through bytes endpoint', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(new Uint8Array([1, 2, 3]), { - status: 200, - headers: { - 'Content-Type': 'image/png', - }, - }), - ); + test('readAssetBytes normalizes full OSS generated urls through signed read url', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey: + 'generated-puzzle-assets/session/profile/covers/main.png', + signedUrl: 'https://signed.example.com/main.png', + expiresAt: '2099-01-01T00:10:00Z', + }, + }, + error: null, + meta: { + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', + latencyMs: 1, + timestamp: '2099-01-01T00:00:00Z', + }, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ), + ) + .mockResolvedValueOnce( + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { + 'Content-Type': 'image/png', + }, + }), + ); const response = await readAssetBytes( 'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-puzzle-assets/session/profile/covers/main.png?x-oss-signature=abc', @@ -567,10 +659,70 @@ describe('assetReadUrlService', () => { expect(response.headers.get('content-type')).toBe('image/png'); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( - '/api/assets/read-bytes?', + '/api/assets/read-url?', ); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( 'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fsession%2Fprofile%2Fcovers%2Fmain.png', ); + expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe( + 'https://signed.example.com/main.png', + ); + }); + + test('readAssetBytes falls back to same-origin bytes endpoint when OSS read fails', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey: 'generated-editor-images/project/image.png', + signedUrl: 'https://signed.example.com/editor-image.png', + expiresAt: '2099-01-01T00:10:00Z', + }, + }, + error: null, + meta: { + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', + latencyMs: 1, + timestamp: '2099-01-01T00:00:00Z', + }, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ), + ) + .mockResolvedValueOnce(new Response('', { status: 403 })) + .mockResolvedValueOnce( + new Response(new Uint8Array([9, 8, 7]), { + 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([9, 8, 7]); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( + '/api/assets/read-url?', + ); + expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe( + 'https://signed.example.com/editor-image.png', + ); + expect(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[0])).toContain( + '/api/assets/read-bytes?', + ); }); }); diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index 839baafb1..85feb9380 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -548,17 +548,41 @@ export async function readAssetBytes( return response; } - // 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。 - const searchParams = buildAssetReadSearchParams({ + const readRequest = { objectKey, legacyPublicPath: objectKey ? undefined : legacyPath, expireSeconds: options.expireSeconds, + }; + try { + const signedUrl = await getSignedAssetReadUrl(readRequest, options.signal); + const response = await fetch(signedUrl, { signal: options.signal }); + if (response.ok) { + return response; + } + } catch { + if (options.signal?.aborted) { + throw createSignedReadUrlAbortError(); + } + // 中文注释:浏览器直读 OSS 失败时再走同源字节代理兜底。 + } + + return readAssetBytesViaFallbackApi(readRequest, options.signal); +} + +async function readAssetBytesViaFallbackApi( + request: AssetReadUrlRequest, + signal?: AbortSignal, +) { + const searchParams = buildAssetReadSearchParams({ + objectKey: request.objectKey, + legacyPublicPath: request.legacyPublicPath, + expireSeconds: request.expireSeconds, }); const response = await fetchWithApiAuth( `${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`, { method: 'GET', - signal: options.signal, + signal, }, { ...ASSET_READ_URL_BACKGROUND_OPTIONS, diff --git a/src/services/match3dGeneratedModelCache.test.ts b/src/services/match3dGeneratedModelCache.test.ts index b20ca8aa8..598d03a6a 100644 --- a/src/services/match3dGeneratedModelCache.test.ts +++ b/src/services/match3dGeneratedModelCache.test.ts @@ -27,14 +27,29 @@ describe('match3dGeneratedModelCache', () => { test('预加载生成模型字节并复用本地缓存', async () => { setStoredAccessToken('test-access-token', { emit: false }); - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(new Uint8Array([103, 108, 84, 70]), { - status: 200, - headers: { - 'Content-Type': 'model/gltf-binary', - }, - }), - ); + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + read: { + signedUrl: 'https://oss.example.com/model.glb', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + .mockResolvedValueOnce( + new Response(new Uint8Array([103, 108, 84, 70]), { + status: 200, + headers: { + 'Content-Type': 'model/gltf-binary', + }, + }), + ); await preloadMatch3DGeneratedModelAssets( [ @@ -61,7 +76,13 @@ describe('match3dGeneratedModelCache', () => { ); expect(Array.from(new Uint8Array(bytes))).toEqual([103, 108, 84, 70]); - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( + '/api/assets/read-url', + ); + expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe( + 'https://oss.example.com/model.glb', + ); }); test('模型源列表会去重并兼容 modelObjectKey', () => {