From 00eed191c5adac2415548154b820b0c09ed6dec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 16:59:18 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E6=8F=90=E4=BA=A4=E6=8A=8A=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E9=93=B8=E7=9A=84=E5=B0=9D=E8=AF=95=E4=BB=A3=E6=AC=A1?= =?UTF-8?q?=E5=86=99=E5=9B=9E=E5=AF=B9=E8=AF=9D=E6=A1=86=E7=8A=B6=E6=80=81?= =?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=20resolveModel3dAttemptNonce=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E8=AE=A1=E5=88=92=E5=B8=A6=E5=87=BA=E6=9C=AC?= =?UTF-8?q?=E6=AC=A1=E5=AE=9E=E9=99=85=E4=BD=BF=E7=94=A8=E7=9A=84=E4=BB=A3?= =?UTF-8?q?=E6=AC=A1=20-=20=E6=8F=90=E4=BA=A4=E7=BC=96=E6=8E=92=E5=9C=A8?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=20generating=20=E6=97=B6=E6=8A=8A=E8=AF=A5?= =?UTF-8?q?=E4=BB=A3=E6=AC=A1=E5=86=99=E5=9B=9E=20dialog=EF=BC=8C=E7=BC=BA?= =?UTF-8?q?=E4=BB=A3=E6=AC=A1=E7=9A=84=E8=80=81=E5=BF=AB=E7=85=A7=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=AF=8F=E6=AC=A1=E6=8D=A2=E9=94=AE=20-=20=E8=A1=A5?= =?UTF-8?q?=E8=80=81=E5=BF=AB=E7=85=A7=E5=9C=BA=E6=99=AF=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=EF=BC=8C=E6=96=AD=E8=A8=80=E5=B9=82=E7=AD=89=E9=94=AE=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E4=B8=8E=E5=86=99=E5=9B=9E=E7=9A=84=E4=BB=A3=E6=AC=A1?= =?UTF-8?q?=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Model3dGenerationSubmission.ts | 20 +++++++++++++++++-- .../useModel3dGenerationTask.test.tsx | 20 +++++++++++++++++++ .../useModel3dGenerationTask.ts | 2 ++ 3 files changed, 40 insertions(+), 2 deletions(-) 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,