diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1864502e1..6550e326b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -159,6 +159,14 @@ - 验证:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx -t "serializes project layout saves" --reporter verbose` 应覆盖慢保存期间不启动第二个 PATCH,首个保存完成后只发送最新待保存快照;排查发布现场时 429 行应从 `upstream_status=-` / Nginx `limit_conn` 收敛。 - 关联:`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.test.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +## 图片画布发布入口 429 也要查 read-url 换签爆发 + +- 现象:发布域名刚上线或刷新画板后出现短时间 `429`,Nginx access log 中集中为同一 IP / 同一 `editor/canvas?projectid=...` referrer 的 `GET /api/assets/read-url?objectKey=generated-character-drafts/editor/ui-design-assets/.../asset-001.png` 到几十上百个 UI 设计切片;429 行常见 `request_time=0.000`、`upstream_status=-`,error log 写 `limiting requests ... zone "genarrative_api_rps"`。 +- 原因:这类 429 是入口 Nginx `limit_req` 在转发前按 RPS burst 快拒,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。UI 设计提取、角色动画帧或大量私有素材恢复会让多个 `ResolvedAssetImage` 同时挂载;如果 `/api/assets/read-url` 只有同 key pending 去重和缓存,没有跨 objectKey 节流,一个页面能在同一秒内发出数百个不同 objectKey 换签请求并打满 `genarrative_api_rps` burst。 +- 处理:不要先放大 Nginx 通用 API 限流;先按 access log 聚合 `read-url` 数量、状态和 referrer,确认是否同一画板页面触发。`assetReadUrlService` 必须统一承接私有 generated 资源换签,并在真实请求前做跨组件轻量节流;画板、素材库、运行态和结果页不得直接绕过该服务调用 `/api/assets/read-url`。 +- 验证:`npm run test -- src/services/assetReadUrlService.test.ts --reporter verbose` 应覆盖大量不同 objectKey 同时换签时首批限量放行、后续按间隔派发;发布现场同类页面刷新时,Nginx `GET /api/assets/read-url` 429 应从 `upstream_status=-` / `genarrative_api_rps` 收敛。 +- 关联:`src/services/assetReadUrlService.ts`、`src/hooks/useResolvedAssetReadUrl.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL - 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 9d3af0f74..87ae87f6f 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -46,7 +46,7 @@ - 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。 - 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 -- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。 +- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。视频结果当前只保存为画布视频资源,不进入图片素材库。 diff --git a/src/services/assetReadUrlService.test.ts b/src/services/assetReadUrlService.test.ts index 230cdfc5c..976d8dd1a 100644 --- a/src/services/assetReadUrlService.test.ts +++ b/src/services/assetReadUrlService.test.ts @@ -362,6 +362,62 @@ describe('assetReadUrlService', () => { expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); + test('getSignedAssetReadUrl throttles distinct signed url bursts', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input) => { + const url = new URL( + String(input), + globalThis.location?.origin ?? 'https://www.genarrative.world', + ); + const objectKey = url.searchParams.get('objectKey') ?? 'missing.png'; + return new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey, + signedUrl: `https://signed.example.com/${encodeURIComponent(objectKey)}`, + 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', + }, + }, + ); + }, + ); + + const requests = Array.from({ length: 28 }, (_, index) => + getSignedAssetReadUrl({ + objectKey: `generated-character-drafts/editor/ui-design-assets/result/asset-${String(index + 1).padStart(3, '0')}.png`, + }), + ); + + await vi.advanceTimersByTimeAsync(0); + expect(fetchSpy).toHaveBeenCalledTimes(24); + + await vi.advanceTimersByTimeAsync(16); + expect(fetchSpy).toHaveBeenCalledTimes(25); + + await vi.advanceTimersByTimeAsync(48); + expect(fetchSpy).toHaveBeenCalledTimes(28); + await expect(Promise.all(requests)).resolves.toHaveLength(28); + }); + test('getSignedAssetReadUrl caches not-found failures for the same legacy path', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index 6f44fb624..1507a35aa 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -61,9 +61,13 @@ const ASSET_READ_URL_BACKGROUND_OPTIONS = BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions; const SIGNED_READ_URL_SESSION_CACHE_PREFIX = 'genarrative.assetReadUrlCache.v1:'; +const SIGNED_READ_URL_INITIAL_DISPATCH_BURST = 24; +const SIGNED_READ_URL_DISPATCH_SPACING_MS = 16; const signedReadUrlCache = new Map(); const signedReadUrlFailureCache = new Map(); const pendingSignedReadUrlRequests = new Map>(); +let signedReadUrlDispatchBurstUsed = 0; +let signedReadUrlNextDispatchAtMs = 0; export function isGeneratedLegacyPath(value: string) { return /^\/?generated-[^/?#]+\/.+/u.test(value.trim()); @@ -261,6 +265,64 @@ function writeSignedUrlSessionCache( } } +function resetSignedReadUrlDispatchLimiter() { + signedReadUrlDispatchBurstUsed = 0; + signedReadUrlNextDispatchAtMs = 0; +} + +function reserveSignedReadUrlDispatchDelayMs(nowMs = Date.now()) { + if (signedReadUrlNextDispatchAtMs < nowMs) { + signedReadUrlDispatchBurstUsed = 0; + signedReadUrlNextDispatchAtMs = nowMs; + } + + if ( + signedReadUrlDispatchBurstUsed < SIGNED_READ_URL_INITIAL_DISPATCH_BURST + ) { + signedReadUrlDispatchBurstUsed += 1; + return 0; + } + + const dispatchAtMs = Math.max( + signedReadUrlNextDispatchAtMs + SIGNED_READ_URL_DISPATCH_SPACING_MS, + nowMs + SIGNED_READ_URL_DISPATCH_SPACING_MS, + ); + signedReadUrlNextDispatchAtMs = dispatchAtMs; + return Math.max(0, dispatchAtMs - nowMs); +} + +function createSignedReadUrlAbortError() { + if (typeof DOMException === 'function') { + return new DOMException('The operation was aborted.', 'AbortError'); + } + return new Error('The operation was aborted.'); +} + +async function waitForSignedReadUrlDispatch(signal?: AbortSignal) { + if (signal?.aborted) { + throw createSignedReadUrlAbortError(); + } + + const delayMs = reserveSignedReadUrlDispatchDelayMs(); + if (delayMs <= 0) { + return; + } + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', handleAbort); + resolve(); + }, delayMs); + + function handleAbort() { + clearTimeout(timer); + reject(createSignedReadUrlAbortError()); + } + + signal?.addEventListener('abort', handleAbort, { once: true }); + }); +} + function clearSignedUrlSessionCache() { const storage = getSignedReadUrlSessionStorage(); if (!storage) { @@ -323,6 +385,7 @@ export async function getSignedAssetReadUrl( const searchParams = buildAssetReadSearchParams(request); try { + await waitForSignedReadUrlDispatch(signal); const response = await requestJson( `${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`, { @@ -518,5 +581,6 @@ export function clearSignedAssetReadUrlCache() { signedReadUrlCache.clear(); signedReadUrlFailureCache.clear(); pendingSignedReadUrlRequests.clear(); + resetSignedReadUrlDispatchLimiter(); clearSignedUrlSessionCache(); }