串行化画板布局自动保存
让画板布局保存共用单飞队列避免慢 PATCH 并发 覆盖慢保存期间只发送最新待保存快照 补充发布入口 429 排查与自动保存文档
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user