diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts index 01f579827..21f2c59c3 100644 --- a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts @@ -30,6 +30,13 @@ export const MODEL3D_GENERATION_TIMEOUT_MESSAGE = export type Model3dSubmissionPlan = { endpoint: Model3dPricingEndpoint; + /** + * 本次提交使用的尝试代次;调用方必须把它写回 dialog 状态。 + * + * 老快照恢复出来的 dialog 可能没有代次,这里兜底铸一个;如果调用方不写回, + * 每次提交都会换键,双击去重与重复计费保护就静默失效。 + */ + attemptNonce: string; requestKey: string; title: string; body: Record; @@ -109,6 +116,7 @@ export function buildModel3dSubmissionPlan({ return { ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE }; } const endpoint = resolveModel3dEndpoint(dialog.mode); + const attemptNonce = resolveModel3dAttemptNonce(dialog.model3dAttemptNonce); const body: Record = { ...(source ? { source } : {}), // 价格相关参数永远全量给出:provider 的隐式默认值会直接改变价格, @@ -136,8 +144,9 @@ export function buildModel3dSubmissionPlan({ ok: true, plan: { endpoint, + attemptNonce, requestKey: resolveModel3dRequestKey({ - attemptNonce: dialog.model3dAttemptNonce, + attemptNonce, endpoint, body, }), @@ -165,10 +174,17 @@ export function resolveModel3dRequestKey({ endpoint: Model3dPricingEndpoint; body: Record; }): string { - const nonce = attemptNonce?.trim() || mintModel3dAttemptNonce(); + const nonce = resolveModel3dAttemptNonce(attemptNonce); return `${nonce}-${fingerprintModel3dRequest(endpoint, body)}`; } +/** 取本次提交使用的代次:已有代次直接用,缺失(例如老快照)才现场铸造。 */ +export function resolveModel3dAttemptNonce( + attemptNonce?: string | null, +): string { + return attemptNonce?.trim() || mintModel3dAttemptNonce(); +} + /** 键排序只求稳定,不承载任何业务语义:同一份内容必须算出同一个键。 */ function compareModel3dRequestEntryKeys( [left]: [string, unknown], diff --git a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx index d251ece37..a67b977f9 100644 --- a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx +++ b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx @@ -178,6 +178,26 @@ describe('useModel3dGenerationTask', () => { expect(result.current.dialog.composerOpen).toBe(false); }); + it('缺代次的老快照兜底铸一个并写回,幂等键跟着这个代次走', async () => { + submitModel3dGenerationRequestMock.mockResolvedValue({ + operationId: 'task-1', + jobKind: 'model3d_text_to_model', + status: QUEUED_STATUS, + statusUrl: '/api/runtime/external-generation/jobs/task-1', + }); + const legacyDialog = createDialog({ model3dAttemptNonce: undefined }); + const { result } = renderTask({ dialog: legacyDialog }); + + await act(async () => { + await result.current.submitModel3dGeneration(legacyDialog); + }); + + const request = submitModel3dGenerationRequestMock.mock.calls[0][0]; + const persistedNonce = result.current.dialog.model3dAttemptNonce; + expect(persistedNonce).toBeTruthy(); + expect(request.idempotencyKey.startsWith(`${persistedNonce}-`)).toBe(true); + }); + it('排队链路没接手时回到可再次提交的空闲态', async () => { submitModel3dGenerationRequestMock.mockResolvedValue({ operationId: 'task-1', diff --git a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts index 609cb97a1..d19517121 100644 --- a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts +++ b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts @@ -93,6 +93,8 @@ export function useModel3dGenerationTask({ } updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ ...currentDialog, + // 兜底铸的代次要写回状态:否则老快照每次提交都换幂等键,去重保护失效。 + model3dAttemptNonce: planResult.plan.attemptNonce, status: 'generating', composerOpen: false, errorMessage: undefined,