升级外部生成为正式任务列表

将 external_generation_job 扩展为含价格、确认时间、退款流水和事件审计的正式生成任务事实源

新增外部生成任务列表与确认 BFF,并让平台入口按后端任务补弹完成或失败提示

保护画布生成队列超时、快照回写和自动保存覆盖场景

将 worker 计费退款流水写入 externalGenerationJobId,并补充冷备份后 worker/controller 恢复守护
This commit is contained in:
2026-06-24 15:17:12 +08:00
parent ed6b17442a
commit 73a17193d4
66 changed files with 2416 additions and 122 deletions
@@ -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,
{