3D 产物 content type 只认字节或槽位白名单,去掉兜底

- 删除 fallback_extension 与「未知声明原样放行 + 扩展名兜底」的归一逻辑
- 槽位白名单收敛为唯一映射表(content type ↔ 对象键扩展名),跨槽位类型返回 None
- resolve_artifact_content_type 改为返回 Result:字节认不出且声明不在白名单时按 502 失败,
  不再把 text/plain 之类的声明连同拼出来的 .glb 扩展名落进 OSS 与 asset_object
- 错误体只回显截断到 64 字符的声明值,避免把任意长度的 provider 文本带进响应
- 重写 storage 测试:新增白名单外声明、跨槽位类型与字节认不出三类用例
- 同步技术方案:说明白名单范围与「不做任何兜底」的口径
This commit is contained in:
2026-09-23 16:39:15 +08:00
parent 6a7cf294a0
commit a4132616d8
9 changed files with 227 additions and 130 deletions
@@ -14,7 +14,7 @@ import {
MODEL3D_GENERATION_FAILURE_MESSAGE,
MODEL3D_GENERATION_TIMEOUT_MESSAGE,
MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE,
MODEL3D_PROJECT_REQUIRED_MESSAGE,
MODEL3D_TARGET_REQUIRED_MESSAGE,
MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE,
resolveModel3dGenerationErrorMessage,
resolveModel3dImageSource,
@@ -175,6 +175,26 @@ describe('buildModel3dSubmissionPlan', () => {
});
});
it('没有画布工程时只给素材夹:不带 projectId,也不带 canvasCompletion', () => {
const result = buildModel3dSubmissionPlan({
dialog: createDialog('model3d-text-to-model', { prompt: '木椅' }),
projectId: ' ',
canvasCompletion: CANVAS_COMPLETION,
assetFolderId: 'project',
pricing: PRICING,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.plan.body).not.toHaveProperty('projectId');
expect(result.plan.body).not.toHaveProperty('canvasCompletion');
expect(result.plan.body).toMatchObject({
assetFolderId: 'project',
assetLabel: '木椅',
});
});
it('素材夹落点只 trim,素材名缺省回落本次结果标题', () => {
const result = buildModel3dSubmissionPlan({
dialog: createDialog('model3d-text-to-model', { prompt: '木椅' }),
@@ -227,15 +247,16 @@ describe('buildModel3dSubmissionPlan', () => {
).not.toHaveProperty('textureQuality');
});
it('缺工程、缺输入、缺定价都不组装请求', () => {
it('缺落点、缺输入、缺定价都不组装请求', () => {
const textDialog = createDialog('model3d-text-to-model');
expect(
buildModel3dSubmissionPlan({
dialog: { ...textDialog, prompt: '木椅' },
projectId: ' ',
assetFolderId: ' ',
pricing: PRICING,
}),
).toEqual({ ok: false, message: MODEL3D_PROJECT_REQUIRED_MESSAGE });
).toEqual({ ok: false, message: MODEL3D_TARGET_REQUIRED_MESSAGE });
expect(
buildModel3dSubmissionPlan({
dialog: textDialog,
@@ -25,8 +25,8 @@ import {
export const MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE = '请填写 3D 模型描述';
export const MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE =
'请先选择一张画布图片或素材库图片';
export const MODEL3D_PROJECT_REQUIRED_MESSAGE =
'请先保存当前画布工程,再生成 3D 模型';
export const MODEL3D_TARGET_REQUIRED_MESSAGE =
'请先保存当前画布工程或打开素材库,再生成 3D 模型';
export const MODEL3D_GENERATION_FAILURE_MESSAGE =
'生成 3D 模型失败,请稍后重试。';
export const MODEL3D_GENERATION_TIMEOUT_MESSAGE =
@@ -92,7 +92,7 @@ export function buildModel3dSubmissionPlan({
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
/**
* 素材库落点:与其它画布生成工具一样,画布链路会把当前素材夹一起发出去,
* 结果因此同时落画布与素材库。缺省或空串表示本次不落素材库。
* 结果因此同时落画布与素材库;没有画布工程时它是唯一落点。缺省或空串表示本次不落素材库。
*/
assetFolderId?: string | null;
/** 素材库里的展示名;缺省用本次结果的标题。 */
@@ -102,9 +102,12 @@ export function buildModel3dSubmissionPlan({
if (!isModel3dGenerationMode(dialog.mode)) {
return { ok: false, message: MODEL3D_MODE_UNSUPPORTED_REASON };
}
// 与其它画布生成工具同一条门禁:落点至少给一个(画布工程或素材夹)。
// 没有画布工程时仍然可以生成,结果只落素材库 —— 3D 不再要求「必须先保存工程」。
const trimmedProjectId = projectId?.trim();
if (!trimmedProjectId) {
return { ok: false, message: MODEL3D_PROJECT_REQUIRED_MESSAGE };
const trimmedAssetFolderId = assetFolderId?.trim();
if (!trimmedProjectId && !trimmedAssetFolderId) {
return { ok: false, message: MODEL3D_TARGET_REQUIRED_MESSAGE };
}
const quote = resolveModel3dGenerationQuote(dialog, pricing);
if (!quote.ok) {
@@ -138,16 +141,16 @@ export function buildModel3dSubmissionPlan({
// 服务端按「至少一个落点」校验,项目资源与素材库可以同时给 —— 其它画布生成工具
// 就是「projectId + assetFolderId + assetLabel」一起发,结果两边都落;3D 跟随同一口径,
// 否则同一张画布生成出来的东西,只有 3D 不会出现在素材库。
const trimmedAssetFolderId = assetFolderId?.trim();
const placement = {
projectId: trimmedProjectId,
...(trimmedProjectId ? { projectId: trimmedProjectId } : {}),
...(trimmedAssetFolderId
? {
assetFolderId: trimmedAssetFolderId,
assetLabel: assetLabel?.trim() || resolveModel3dResultTitle(dialog),
}
: {}),
...(canvasCompletion ? { canvasCompletion } : {}),
// 画布占位框是项目资源落点的回填载荷:没有工程就没有画布可以回填。
...(trimmedProjectId && canvasCompletion ? { canvasCompletion } : {}),
};
const finish = (
body: Model3dTextToModelRequest | Model3dImageToModelRequest,
@@ -284,7 +284,7 @@ describe('useModel3dGenerationTask', () => {
);
});
it('画布工程还没保存时不发请求', async () => {
it('没有任何落点(既没有工程也没有素材夹)时不发请求', async () => {
const { result } = renderTask({ dialog: createDialog(), projectId: null });
await act(async () => {
@@ -296,6 +296,31 @@ describe('useModel3dGenerationTask', () => {
expect(result.current.dialog.errorMessage).toContain('保存当前画布工程');
});
it('没有画布工程时照样提交:只发素材夹,不发 projectId 与 canvasCompletion', async () => {
const submitResponse: Model3dGenerationSubmissionResponse = {
operationId: 'task-2',
jobKind: 'model3d_text_to_model',
status: QUEUED_STATUS,
statusUrl: '/api/runtime/external-generation/jobs/task-2',
};
submitModel3dGenerationRequestMock.mockResolvedValue(submitResponse);
const { result } = renderTask({
dialog: createDialog(),
projectId: null,
assetFolderId: 'project',
});
await act(async () => {
await result.current.submitModel3dGeneration(createDialog());
});
expect(submitModel3dGenerationRequestMock).toHaveBeenCalledTimes(1);
const request = submitModel3dGenerationRequestMock.mock.calls[0][0];
expect(request.body).not.toHaveProperty('projectId');
expect(request.body).not.toHaveProperty('canvasCompletion');
expect(request.body).toMatchObject({ assetFolderId: 'project' });
});
it('非 3D 对话框不做任何事', async () => {
const { result } = renderTask({
dialog: createDialog({ mode: 'generate' }),