3D 提交体改用端点契约类型而非 Record

- Model3dSubmissionPlan.body 与 submitModel3dGenerationRequest 的 body 收紧为两个端点请求契约
- 提交计划按端点分支分别构造 text-to-model / image-to-model 请求体
- 幂等键入参同步收紧,测试夹具按契约补全 body
This commit is contained in:
2026-09-22 17:04:11 +08:00
parent 4e46202c10
commit caafb8baa3
3 changed files with 61 additions and 42 deletions
@@ -320,7 +320,10 @@ describe('Idempotency-Key', () => {
resolveModel3dRequestKey({
attemptNonce: null,
endpoint: 'text-to-model',
body: { generation: { model: 'v3.1-20260211' } },
body: {
generation: { model: 'v3.1-20260211', prompt: '木椅' },
target: { kind: 'projectResource', projectId: 'project-1' },
},
}),
).toMatch(/^[\x21-\x7e]{1,128}$/);
});
@@ -329,12 +332,18 @@ describe('Idempotency-Key', () => {
const left = resolveModel3dRequestKey({
attemptNonce: 'model3d-fixed',
endpoint: 'text-to-model',
body: { generation: { texture: true, model: 'v3.1-20260211' } },
body: {
generation: { texture: true, model: 'v3.1-20260211', prompt: '木椅' },
target: { kind: 'projectResource', projectId: 'project-1' },
},
});
const right = resolveModel3dRequestKey({
attemptNonce: 'model3d-fixed',
endpoint: 'text-to-model',
body: { generation: { model: 'v3.1-20260211', texture: true } },
body: {
generation: { model: 'v3.1-20260211', texture: true, prompt: '木椅' },
target: { kind: 'projectResource', projectId: 'project-1' },
},
});
expect(right).toBe(left);
});
@@ -1,5 +1,10 @@
import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration';
import type { Model3dGenerationSource } from '../../../../packages/shared/src/contracts/model3d';
import type {
Model3dGenerationSource,
Model3dGenerationTarget,
Model3dImageToModelRequest,
Model3dTextToModelRequest,
} from '../../../../packages/shared/src/contracts/model3d';
import { isAbortError, isTimeoutError } from '../../../services/apiClient';
import type {
EditorCanvasGenerationCompletionInput,
@@ -41,7 +46,8 @@ export type Model3dSubmissionPlan = {
attemptNonce: string;
requestKey: string;
title: string;
body: Record<string, unknown>;
/** 提交体就是端点契约本身:形状错误在编译期暴露,而不是等服务端拒绝。 */
body: Model3dTextToModelRequest | Model3dImageToModelRequest;
};
export function resolveModel3dResultTitle(dialog: GenerateDialogState): string {
@@ -106,43 +112,28 @@ export function buildModel3dSubmissionPlan({
return { ok: false, message: paramsResult.reason };
}
const params = paramsResult.params;
const prompt = dialog.prompt.trim();
const source =
dialog.mode === 'model3d-image-to-model'
? resolveModel3dImageSource(dialog)
: null;
if (dialog.mode === 'model3d-text-to-model' && !prompt) {
return { ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE };
}
if (dialog.mode === 'model3d-image-to-model' && !source) {
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 的隐式默认值会直接改变价格,
// 唯一例外是 texture=false 档位不带 textureQuality(后端显式拒绝这个组合)。
generation: {
model: DEFAULT_MODEL3D_MODEL_VERSION,
...(dialog.mode === 'model3d-text-to-model' ? { prompt } : {}),
texture: params.texture,
pbr: params.pbr,
...(params.textureQuality
? { textureQuality: params.textureQuality }
: {}),
geometryQuality: params.geometryQuality,
quad: params.quad,
smartLowPoly: params.smartLowPoly,
generateParts: params.generateParts,
},
target: {
kind: 'projectResource',
projectId: trimmedProjectId,
...(canvasCompletion ? { canvasCompletion } : {}),
},
// 价格相关参数永远全量给出:provider 的隐式默认值会直接改变价格,
// 唯一例外是 texture=false 档位不带 textureQuality(后端显式拒绝这个组合)。
const generation = {
model: DEFAULT_MODEL3D_MODEL_VERSION,
texture: params.texture,
pbr: params.pbr,
...(params.textureQuality ? { textureQuality: params.textureQuality } : {}),
geometryQuality: params.geometryQuality,
quad: params.quad,
smartLowPoly: params.smartLowPoly,
generateParts: params.generateParts,
};
return {
const target: Model3dGenerationTarget = {
kind: 'projectResource',
projectId: trimmedProjectId,
...(canvasCompletion ? { canvasCompletion } : {}),
};
const finish = (
body: Model3dTextToModelRequest | Model3dImageToModelRequest,
): { ok: true; plan: Model3dSubmissionPlan } => ({
ok: true,
plan: {
endpoint,
@@ -155,7 +146,21 @@ export function buildModel3dSubmissionPlan({
title: resolveModel3dResultTitle(dialog),
body,
},
};
});
if (dialog.mode === 'model3d-image-to-model') {
const source = resolveModel3dImageSource(dialog);
if (!source) {
return { ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE };
}
return finish({ source, generation, target });
}
const prompt = dialog.prompt.trim();
if (!prompt) {
return { ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE };
}
return finish({ generation: { ...generation, prompt }, target });
}
/**
@@ -174,7 +179,7 @@ export function resolveModel3dRequestKey({
}: {
attemptNonce?: string | null;
endpoint: Model3dPricingEndpoint;
body: Record<string, unknown>;
body: Model3dTextToModelRequest | Model3dImageToModelRequest;
}): string {
const nonce = resolveModel3dAttemptNonce(attemptNonce);
return `${nonce}-${fingerprintModel3dRequest(endpoint, body)}`;
@@ -10,6 +10,10 @@ import {
} from '../../../packages/shared/src/contracts/editorAudio';
import type { EditorSceneGenerationRequest } from '../../../packages/shared/src/contracts/editorScene';
import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import type {
Model3dImageToModelRequest,
Model3dTextToModelRequest,
} from '../../../packages/shared/src/contracts/model3d';
import { requestJson } from '../apiClient';
import { EDITOR_GENERATION_REQUEST_RETRY_OPTIONS } from './editorRetryOptions';
@@ -1742,7 +1746,8 @@ export async function submitModel3dGenerationRequest({
idempotencyKey,
}: {
endpoint: Model3dPricingEndpoint;
body: Record<string, unknown>;
// 提交体直接用端点契约:形状不符(缺 target、generation 结构错)在编译期就会暴露。
body: Model3dTextToModelRequest | Model3dImageToModelRequest;
idempotencyKey: string;
}) {
return requestJson<Model3dGenerationSubmissionResponse>(