串行化画板布局自动保存

让画板布局保存共用单飞队列避免慢 PATCH 并发

覆盖慢保存期间只发送最新待保存快照

补充发布入口 429 排查与自动保存文档
This commit is contained in:
2026-06-23 15:16:50 +08:00
parent 0579741f64
commit 47bdc73b93
4 changed files with 216 additions and 20 deletions
@@ -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/<projectId>`、生成接口和资料接口混杂,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,...`,后续实现很容易照抄。
@@ -35,7 +35,7 @@
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
- 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。
- 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。
- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
- 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。
@@ -232,6 +232,26 @@ function ProjectPersistenceHarness({
>
move generation
</button>
<button
type="button"
onClick={() => {
setGenerationDialogs((currentDialogs) =>
currentDialogs.map((dialog) =>
dialog.id === 'generation-dialog-1'
? {
...dialog,
prompt: '第二次移动后的生成器',
placeholder: dialog.placeholder
? { ...dialog.placeholder, x: 120 }
: dialog.placeholder,
}
: dialog,
),
);
}}
>
move generation again
</button>
</div>
);
}
@@ -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(<ProjectPersistenceHarness />);
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',
@@ -32,6 +32,11 @@ type PendingProjectResourceLayer = {
options: ProjectResourceOptions;
};
type PendingProjectLayoutSave = {
projectId: string;
input: Parameters<typeof saveEditorProjectLayout>[1];
};
type ImageCanvasProjectPersistenceRefs = {
layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
@@ -90,6 +95,9 @@ export function useImageCanvasProjectPersistence({
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
[],
);
const pendingProjectLayoutSaveRef =
useRef<PendingProjectLayoutSave | null>(null);
const isProjectLayoutSaveRunningRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const [projectId, setProjectId] = useState<string | null>(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<typeof saveEditorProjectLayout>[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,
]);