升级外部生成为正式任务列表
将 external_generation_job 扩展为含价格、确认时间、退款流水和事件审计的正式生成任务事实源 新增外部生成任务列表与确认 BFF,并让平台入口按后端任务补弹完成或失败提示 保护画布生成队列超时、快照回写和自动保存覆盖场景 将 worker 计费退款流水写入 externalGenerationJobId,并补充冷备份后 worker/controller 恢复守护
This commit is contained in:
@@ -1120,6 +1120,79 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const refreshWalletBalance = vi.fn();
|
||||
const runningState = createQueueState({
|
||||
status: 'running',
|
||||
phaseLabel: '生成中',
|
||||
phaseDetail: '后端任务继续执行中',
|
||||
progress: 0.35,
|
||||
});
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
...createGenerated({
|
||||
imageSrc: '/generated-editor-images/project/image.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
prompt: '队列生成',
|
||||
}),
|
||||
queueState: runningState,
|
||||
});
|
||||
getExternalGenerationJobStatusMock.mockResolvedValue({
|
||||
job: runningState,
|
||||
});
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
projectId="editor-project-1"
|
||||
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
||||
initialDialog={{
|
||||
id: 'dialog-generate',
|
||||
mode: 'generate',
|
||||
prompt: '队列生成',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
imageModel: 'gpt-image-2',
|
||||
placeholder: {
|
||||
x: 200,
|
||||
y: 160,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600);
|
||||
});
|
||||
|
||||
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
||||
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
||||
expect(getExternalGenerationJobStatusMock).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:generating:closed:-:placeholder:-',
|
||||
);
|
||||
expect(screen.getByTestId('dialog').textContent).not.toContain(
|
||||
'生成任务仍在队列中',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'layer-generated-1',
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not add a local layer when project generation returns without a backend snapshot', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
|
||||
@@ -199,12 +199,12 @@ async function runEditorGenerationWithWalletRefresh<T>(
|
||||
|
||||
async function waitForEditorGenerationQueue(
|
||||
queueState: ExternalGenerationJobStatusRecord,
|
||||
) {
|
||||
): Promise<ExternalGenerationJobStatusRecord | null> {
|
||||
const startedAt = Date.now();
|
||||
let current = queueState;
|
||||
while (current.status === 'queued' || current.status === 'running') {
|
||||
if (Date.now() - startedAt > EDITOR_GENERATION_QUEUE_TIMEOUT_MS) {
|
||||
throw new Error('生成任务仍在队列中,请稍后刷新画布查看结果');
|
||||
return null;
|
||||
}
|
||||
await delay(EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS);
|
||||
current = (
|
||||
@@ -227,11 +227,15 @@ async function applyQueuedEditorGenerationProject(
|
||||
if (!queueState) {
|
||||
return false;
|
||||
}
|
||||
let terminalState: ExternalGenerationJobStatusRecord | null = null;
|
||||
try {
|
||||
await waitForEditorGenerationQueue(queueState);
|
||||
terminalState = await waitForEditorGenerationQueue(queueState);
|
||||
} finally {
|
||||
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
|
||||
}
|
||||
if (!terminalState) {
|
||||
return true;
|
||||
}
|
||||
if (projectId && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(await loadEditorProject(projectId));
|
||||
}
|
||||
|
||||
@@ -274,6 +274,67 @@ function ProjectPersistenceHarness({
|
||||
>
|
||||
move generation again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
persistence.applyProjectSnapshot({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-generated',
|
||||
resourceId: 'resource-generated',
|
||||
title: '生成结果',
|
||||
x: 42,
|
||||
y: 56,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 420,
|
||||
originalHeight: 420,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
{
|
||||
itemType: 'generation-dialog',
|
||||
layerId: 'generation-dialog:generation-dialog-1',
|
||||
resourceId: 'generation-dialog:generation-dialog-1',
|
||||
dialog: {
|
||||
id: 'generation-dialog-1',
|
||||
mode: 'generate',
|
||||
prompt: '后端完成生成器',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generatedLayerId: 'layer-generated',
|
||||
placeholder: {
|
||||
x: 42,
|
||||
y: 56,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 420,
|
||||
originalHeight: 420,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-generated',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/generated/result.png',
|
||||
objectKey: 'generated/result.png',
|
||||
assetObjectId: 'asset-object-result',
|
||||
width: 420,
|
||||
height: 420,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T00:00:01.000Z',
|
||||
});
|
||||
}}
|
||||
>
|
||||
apply completed project
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -731,6 +792,62 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('drops stale pending generating saves when a backend completion snapshot is applied', 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',
|
||||
});
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
vi.useFakeTimers();
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'move generation' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'apply completed project' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
|
||||
'generation-dialog-1:后端完成生成器:42',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-generated:resource-generated:-:-:generated/result.png:asset-object-result',
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(451);
|
||||
});
|
||||
|
||||
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('saves generated layers together with their generator snapshots', async () => {
|
||||
const savedDialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-1',
|
||||
|
||||
@@ -260,6 +260,7 @@ export function useImageCanvasProjectPersistence({
|
||||
const pendingProjectLayoutSaveRef =
|
||||
useRef<PendingProjectLayoutSave | null>(null);
|
||||
const isProjectLayoutSaveRunningRef = useRef(false);
|
||||
const skipNextProjectLayoutSaveRef = useRef(false);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const projectTitleRef = useRef('未命名画布');
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
@@ -274,6 +275,14 @@ export function useImageCanvasProjectPersistence({
|
||||
restoreCanvasGenerationDialogs,
|
||||
} = setters;
|
||||
|
||||
const clearPendingProjectLayoutSave = useCallback(() => {
|
||||
pendingProjectLayoutSaveRef.current = null;
|
||||
if (saveTimerRef.current) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runPendingProjectLayoutSave = useCallback(
|
||||
function runPendingProjectLayoutSave() {
|
||||
if (isProjectLayoutSaveRunningRef.current) {
|
||||
@@ -422,6 +431,8 @@ export function useImageCanvasProjectPersistence({
|
||||
|
||||
const applyProjectSnapshot = useCallback(
|
||||
(project: EditorProjectSnapshot) => {
|
||||
clearPendingProjectLayoutSave();
|
||||
skipNextProjectLayoutSaveRef.current = true;
|
||||
projectIdRef.current = project.projectId;
|
||||
setProjectId(project.projectId);
|
||||
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
||||
@@ -471,6 +482,7 @@ export function useImageCanvasProjectPersistence({
|
||||
restoreCanvasGenerationDialogs(generationDialogs);
|
||||
},
|
||||
[
|
||||
clearPendingProjectLayoutSave,
|
||||
refs,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
@@ -558,6 +570,10 @@ export function useImageCanvasProjectPersistence({
|
||||
if (!projectId || !isProjectReady) {
|
||||
return undefined;
|
||||
}
|
||||
if (skipNextProjectLayoutSaveRef.current) {
|
||||
skipNextProjectLayoutSaveRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
queueProjectLayoutSave(
|
||||
projectId,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user