3D 提交把兜底铸的尝试代次写回对话框状态

- 新增 resolveModel3dAttemptNonce,提交计划带出本次实际使用的代次
- 提交编排在进入 generating 时把该代次写回 dialog,缺代次的老快照不再每次换键
- 补老快照场景用例,断言幂等键前缀与写回的代次一致
This commit is contained in:
2026-09-22 16:59:18 +08:00
parent 62f1ca4537
commit 00eed191c5
3 changed files with 40 additions and 2 deletions
@@ -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<string, unknown>;
@@ -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<string, unknown> = {
...(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, unknown>;
}): 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],
@@ -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',
@@ -93,6 +93,8 @@ export function useModel3dGenerationTask({
}
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
// 兜底铸的代次要写回状态:否则老快照每次提交都换幂等键,去重保护失效。
model3dAttemptNonce: planResult.plan.attemptNonce,
status: 'generating',
composerOpen: false,
errorMessage: undefined,