diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cf7d200cd..be82d798d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -143,6 +143,14 @@ - 验证:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml` 覆盖后端完成态写 layout;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 覆盖前端提交 `canvasCompletion`、应用后端快照、项目加载不推断完成态和 `objectKey` 资源创建不提交大 URL。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +## 图片画布发布入口 429 先查自动保存 PATCH 并发 + +- 现象:发布域名访问画板时出现短时间密集 `429`,Nginx access log 中 `PATCH /api/editor/projects/`、生成接口和资料接口混杂,429 行常见 `request_time=0.000`、`upstream_status=-`,error log 写 `limiting connections by zone "genarrative_api_conn"`。 +- 原因:这类 429 是入口 Nginx `limit_conn` 在转发前拒绝,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。画布自动保存如果只有防抖、没有 in-flight 串行保护,慢 `PATCH /api/editor/projects/{projectId}` 未完成时,拖拽生成器、资源回填和后续状态变化会继续发起新的保存请求,同一客户端连接数被长请求撑满后触发入口连接限流。 +- 处理:不要先放大 Nginx 限流或把错误归给生成 provider;先看 access log 的 `upstream_status` / `request_time` 和 error log 的 `limit_conn` zone,再查前端保存路径。`useImageCanvasProjectPersistence` 中自动保存和资源创建后的布局保存必须共用串行队列:同一时刻只允许一个 `saveEditorProjectLayout` in-flight,期间新快照覆盖旧待保存快照,当前保存结束后只发送最新一次。 +- 验证:`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`。 + ## 图片编辑器 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 01020cf68..163129d12 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -35,7 +35,7 @@ - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 - 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。 -- 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。 +- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 9d732d67a..82cab7936 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -232,6 +232,26 @@ function ProjectPersistenceHarness({ > move generation + ); } @@ -528,6 +548,113 @@ describe('useImageCanvasProjectPersistence', () => { vi.useRealTimers(); }); + it('serializes project layout saves and only sends the latest pending snapshot while a save is running', async () => { + const savedDialog: CanvasGenerationDialogState = { + id: 'generation-dialog-1', + mode: 'generate', + prompt: '刷新后继续生成', + status: 'generating', + composerOpen: false, + placeholder: { + x: 42, + y: 56, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, + }, + }; + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '空画布项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-1', + resourceId: 'generation-dialog:generation-dialog-1', + dialog: savedDialog, + }, + ], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + const saveResolvers: Array<(value: unknown) => void> = []; + saveEditorProjectLayoutMock.mockImplementation( + () => + new Promise((resolve) => { + saveResolvers.push(resolve); + }), + ); + + render(); + + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'move generation' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + expect(saveEditorProjectLayoutMock).toHaveBeenLastCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + itemType: 'generation-dialog', + dialog: expect.objectContaining({ + id: 'generation-dialog-1', + prompt: '移动后的生成器', + placeholder: expect.objectContaining({ x: 88 }), + }), + }), + ]), + }), + ); + + act(() => { + screen.getByRole('button', { name: 'move generation again' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + + await act(async () => { + saveResolvers[0]?.({ + projectId: 'editor-project-default', + title: '空画布项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:01.000Z', + }); + await Promise.resolve(); + }); + + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(saveEditorProjectLayoutMock).toHaveBeenLastCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + itemType: 'generation-dialog', + dialog: expect.objectContaining({ + id: 'generation-dialog-1', + prompt: '第二次移动后的生成器', + placeholder: expect.objectContaining({ x: 120 }), + }), + }), + ]), + }), + ); + vi.useRealTimers(); + }); + it('saves generated layers together with their generator snapshots', async () => { const savedDialog: CanvasGenerationDialogState = { id: 'generation-dialog-1', diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 9ee877a9d..2a5e23433 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -32,6 +32,11 @@ type PendingProjectResourceLayer = { options: ProjectResourceOptions; }; +type PendingProjectLayoutSave = { + projectId: string; + input: Parameters[1]; +}; + type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; @@ -90,6 +95,9 @@ export function useImageCanvasProjectPersistence({ const pendingProjectResourceLayersRef = useRef( [], ); + const pendingProjectLayoutSaveRef = + useRef(null); + const isProjectLayoutSaveRunningRef = useRef(false); const saveTimerRef = useRef(null); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); @@ -103,6 +111,68 @@ export function useImageCanvasProjectPersistence({ restoreCanvasGenerationDialogs, } = setters; + const runPendingProjectLayoutSave = useCallback( + function runPendingProjectLayoutSave() { + if (isProjectLayoutSaveRunningRef.current) { + return; + } + const pendingSave = pendingProjectLayoutSaveRef.current; + if (!pendingSave) { + return; + } + + pendingProjectLayoutSaveRef.current = null; + isProjectLayoutSaveRunningRef.current = true; + Promise.resolve( + saveEditorProjectLayout(pendingSave.projectId, pendingSave.input), + ) + .catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }) + .finally(() => { + isProjectLayoutSaveRunningRef.current = false; + if ( + pendingProjectLayoutSaveRef.current && + saveTimerRef.current === null + ) { + runPendingProjectLayoutSave(); + } + }); + }, + [openEditorLoginModal], + ); + + const queueProjectLayoutSave = useCallback( + ( + nextProjectId: string, + input: Parameters[1], + options: { delayMs?: number } = {}, + ) => { + pendingProjectLayoutSaveRef.current = { + projectId: nextProjectId, + input, + }; + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + + const delayMs = options.delayMs ?? 0; + if (delayMs > 0) { + saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = null; + runPendingProjectLayoutSave(); + }, delayMs); + return; + } + + runPendingProjectLayoutSave(); + }, + [runPendingProjectLayoutSave], + ); + const createProjectResourceForLayer = useCallback( (layer: CanvasLayer, options: ProjectResourceOptions = {}) => { const readyProjectId = projectIdRef.current; @@ -154,17 +224,13 @@ export function useImageCanvasProjectPersistence({ refs.layersRef.current = nextLayers; setLayers(nextLayers); if (nextLayers.length) { - void saveEditorProjectLayout(readyProjectId, { + queueProjectLayoutSave(readyProjectId, { viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current), layers: serializeCanvasLayout({ layers: nextLayers, canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current, }), - }).catch((error: unknown) => { - if (isEditorAuthError(error)) { - openEditorLoginModal(); - } }); } }) @@ -174,7 +240,7 @@ export function useImageCanvasProjectPersistence({ } }); }, - [openEditorLoginModal, refs, setLayers], + [openEditorLoginModal, queueProjectLayoutSave, refs, setLayers], ); const applyProjectSnapshot = useCallback( @@ -309,35 +375,30 @@ export function useImageCanvasProjectPersistence({ if (!projectId || !isProjectReady) { return undefined; } - if (saveTimerRef.current) { - window.clearTimeout(saveTimerRef.current); - } - - saveTimerRef.current = window.setTimeout(() => { - saveEditorProjectLayout(projectId, { + queueProjectLayoutSave( + projectId, + { viewport: viewportToCanvasDisplayViewport(viewport), layers: serializeCanvasLayout({ layers, canvasGenerationDialogs, }), - }).catch((error: unknown) => { - if (isEditorAuthError(error)) { - openEditorLoginModal(); - } - }); - }, 450); + }, + { delayMs: 450 }, + ); return () => { if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; } }; }, [ isProjectReady, canvasGenerationDialogs, layers, - openEditorLoginModal, projectId, + queueProjectLayoutSave, refs, viewport, ]);