From a4132616d8a2c561c94ee1f094575dc24b82e5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 16:39:15 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E4=BA=A7=E7=89=A9=20content=20type=20?= =?UTF-8?q?=E5=8F=AA=E8=AE=A4=E5=AD=97=E8=8A=82=E6=88=96=E6=A7=BD=E4=BD=8D?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=EF=BC=8C=E5=8E=BB=E6=8E=89=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 fallback_extension 与「未知声明原样放行 + 扩展名兜底」的归一逻辑 - 槽位白名单收敛为唯一映射表(content type ↔ 对象键扩展名),跨槽位类型返回 None - resolve_artifact_content_type 改为返回 Result:字节认不出且声明不在白名单时按 502 失败, 不再把 text/plain 之类的声明连同拼出来的 .glb 扩展名落进 OSS 与 asset_object - 错误体只回显截断到 64 字符的声明值,避免把任意长度的 provider 文本带进响应 - 重写 storage 测试:新增白名单外声明、跨槽位类型与字节认不出三类用例 - 同步技术方案:说明白名单范围与「不做任何兜底」的口径 --- ...技术方案】Tripo 3D生成API集成-2026-09-21.md | 2 +- .../Model3dImageToModelRequest.ts | 6 +- .../Model3dTextToModelRequest.ts | 7 +- .../crates/api-server/src/tripo3d/storage.rs | 256 +++++++++++------- .../src/model3d/image_to_model/request.rs | 6 +- .../src/model3d/text_to_model/request.rs | 7 +- .../Model3dGenerationSubmission.test.ts | 27 +- .../Model3dGenerationSubmission.ts | 19 +- .../useModel3dGenerationTask.test.tsx | 27 +- 9 files changed, 227 insertions(+), 130 deletions(-) diff --git a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md index ab8d116d3..128f99d70 100644 --- a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md +++ b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md @@ -161,7 +161,7 @@ thumbnailSrc = 预览图 width/height = 预览图像素尺寸 ``` -模型格式与体积由 `AssetObject` 承担(`content_type`、`content_length`、`content_hash`),因此不新增 `model_format`、`size_bytes`、`poly_count` 等列。两个槽位的 `content_type` 都在写入前按字节识别真实产物:模型按魔数认 GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头与 glTF 的 JSON 正文,预览按魔数认 PNG / JPEG / WebP;不直接采信 provider 返回或下载响应头声明的类型,对象键扩展名由该内容类型派生。只有字节判不出来时才回落到声明值(预览最终回落 `image/webp`)。客户端不复制这份格式清单:画布只按「`assetKind=model3d` 且 `objectKey` 非空」放行,能否交给 3D 查看器由 `packages/model3d-viewer` 判定,判据依次是读接口声明的 `Content-Type`、字节魔数、地址扩展名,全判不出来即按 `unsupported-format` 报错。 +模型格式与体积由 `AssetObject` 承担(`content_type`、`content_length`、`content_hash`),因此不新增 `model_format`、`size_bytes`、`poly_count` 等列。两个槽位的 `content_type` 都在写入前按字节识别真实产物:模型按魔数认 GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头与 glTF 的 JSON 正文,预览按魔数认 PNG / JPEG / WebP;不直接采信 provider 返回或下载响应头声明的类型,对象键扩展名由该内容类型派生。只有字节判不出来时才采信声明值,且声明值必须落在该槽位的白名单内(模型:`model/gltf-binary`、`model/gltf+json`、`model/fbx`、`application/x-fbx`;预览:`image/png`、`image/jpeg`、`image/jpg`、`image/webp`);两边都对不上就直接按上游内容不合法失败并退款,不做任何兜底 —— 放行 `text/plain` 这类未知类型只会落成「错的 content type + 拼出来的扩展名」。客户端不复制这份格式清单:画布只按「`assetKind=model3d` 且 `objectKey` 非空」放行,能否交给 3D 查看器由 `packages/model3d-viewer` 判定,判据依次是读接口声明的 `Content-Type`、字节魔数、地址扩展名,全判不出来即按 `unsupported-format` 报错。 `external_generation_job.phase` 的取值集合不变,仍只允许现有两种执行阶段;阶段文案由 api-server 映射,不扩展 schema 常量。 diff --git a/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelRequest.ts b/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelRequest.ts index 15d1e671a..a5987a0f2 100644 --- a/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelRequest.ts +++ b/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelRequest.ts @@ -7,13 +7,13 @@ import type { Model3dImageToModelParams } from './Model3dImageToModelParams'; * image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。 * * 结果落点与 text-to-model 同形:平坦的可选 `projectId` / `canvasCompletion` / - * `assetFolderId` / `assetLabel`,二选一,不用 tagged enum。 + * `assetFolderId` / `assetLabel`,至少给一个,不用 tagged enum。 */ export type Model3dImageToModelRequest = { source: Model3dGenerationSource; generation: Model3dImageToModelParams; /** - * 项目资源落点;与 `assetFolderId` 二选一。 + * 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。 */ projectId?: string | null; /** @@ -21,7 +21,7 @@ export type Model3dImageToModelRequest = { */ canvasCompletion?: EditorCanvasGenerationCompletionPayload | null; /** - * 素材库落点;与 `projectId` 二选一。 + * 素材库落点;与 `projectId` 至少给一个,两个都给合法。 */ assetFolderId?: string | null; /** diff --git a/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelRequest.ts b/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelRequest.ts index e37685d1a..26eda1340 100644 --- a/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelRequest.ts +++ b/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelRequest.ts @@ -10,12 +10,13 @@ import type { Model3dTextToModelParams } from './Model3dTextToModelParams'; * * 结果落点与其它生成接口同形:平坦的可选 `projectId` / `canvasCompletion` / * `assetFolderId` / `assetLabel`,不用 tagged enum —— 客户端不必为「落项目还是落素材库」 - * 多拼一层判别结构。服务端按「二选一」校验:两个都给或都不给都拒绝。 + * 多拼一层判别结构。服务端按「至少一个落点」校验:两个都不给拒绝,两个都给合法(两条落点都落); + * 没有 `projectId` 时结果只落素材库,与其它画布生成工具一致。 */ export type Model3dTextToModelRequest = { generation: Model3dTextToModelParams; /** - * 项目资源落点;与 `assetFolderId` 二选一。 + * 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。 */ projectId?: string | null; /** @@ -23,7 +24,7 @@ export type Model3dTextToModelRequest = { */ canvasCompletion?: EditorCanvasGenerationCompletionPayload | null; /** - * 素材库落点;与 `projectId` 二选一。 + * 素材库落点;与 `projectId` 至少给一个,两个都给合法。 */ assetFolderId?: string | null; /** diff --git a/server-rs/crates/api-server/src/tripo3d/storage.rs b/server-rs/crates/api-server/src/tripo3d/storage.rs index d50ffa118..98393d8a4 100644 --- a/server-rs/crates/api-server/src/tripo3d/storage.rs +++ b/server-rs/crates/api-server/src/tripo3d/storage.rs @@ -52,13 +52,36 @@ impl Model3dArtifactSlot { } } - /// provider 未给出可用 content type 时的兜底扩展名。 - const fn fallback_extension(self) -> &'static str { + /// 本槽位接受的全部 content type。表外的取值一律拒绝:不做归一兜底,也不替 + /// provider 猜一个类型,因为猜错的类型会连同扩展名一起写进 OSS metadata 与 + /// `asset_object.content_type`,比失败更难发现。 + const fn declared_content_types(self) -> &'static [&'static str] { match self { - Self::Model => "glb", - Self::Preview => "webp", + Self::Model => &[ + "model/gltf-binary", + "model/gltf+json", + "model/fbx", + "application/x-fbx", + ], + Self::Preview => &["image/png", "image/jpeg", "image/jpg", "image/webp"], } } + + /// 已知 content type 对应的对象键扩展名;表外返回 `None`。 + fn artifact_extension(self, content_type: &str) -> Option<&'static str> { + if !self.declared_content_types().contains(&content_type) { + return None; + } + Some(match content_type { + "model/gltf-binary" => "glb", + "model/gltf+json" => "gltf", + "model/fbx" | "application/x-fbx" => "fbx", + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/webp" => "webp", + _ => return None, + }) + } } /// 已落地的产物:对象键与对象元数据来自 HEAD 复核,不是 PUT 的入参原样回填。 @@ -89,13 +112,12 @@ pub(crate) async fn store_model3d_artifact( .oss_client() .ok_or_else(|| oss_unavailable("OSS 未完成环境变量配置,无法写入 3D 产物。"))?; let http_client = state.editor_oss_http_client(); - let content_type = resolve_artifact_content_type(content_type, slot, bytes.as_slice()); + let content_type = resolve_artifact_content_type(content_type, slot, bytes.as_slice())?; let sha256 = sha256_hex(bytes.as_slice()); - let file_name = format!( - "{}.{}", - slot.file_stem(), - artifact_extension(content_type.as_str(), slot) - ); + let extension = slot + .artifact_extension(content_type.as_str()) + .ok_or_else(|| unsupported_artifact_content_type(content_type.as_str(), slot))?; + let file_name = format!("{}.{}", slot.file_stem(), extension); let put_result = oss_client .put_object( http_client, @@ -167,20 +189,19 @@ pub(crate) async fn store_model3d_artifact( }) } -fn normalize_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> String { +/// provider 声明的 content type 去参数、去空白、转小写后与槽位白名单比对; +/// 不在表里(含空值、`application/octet-stream`)返回 `None`。 +fn declared_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> Option<&'static str> { let normalized = raw .split(';') .next() .map(str::trim) .unwrap_or_default() .to_ascii_lowercase(); - if normalized.is_empty() || normalized == "application/octet-stream" { - return match slot { - Model3dArtifactSlot::Model => "model/gltf-binary".to_string(), - Model3dArtifactSlot::Preview => "image/webp".to_string(), - }; - } - normalized + slot.declared_content_types() + .iter() + .copied() + .find(|known| *known == normalized) } /// glb 的容器魔数(小端 `glTF`);`model/gltf+json` 的判据是首个非空白字节为 `{`。 @@ -225,43 +246,52 @@ fn sniff_preview_content_type(bytes: &[u8]) -> Option<&'static str> { } } -/// 产物 content type:两个槽位都先嗅探字节,嗅不出来才信 provider 声明。 +/// 产物 content type:两个槽位都先嗅探字节,嗅不出来才信 provider 声明;两者都不成立就失败。 /// -/// provider 对二进制产物常给 `application/octet-stream`,归一后会被写成 -/// `model/gltf-binary` + `.glb`;客户端按这份 content type 判定能不能预览,说错就是 -/// 拿 GLTFLoader 去解 FBX。预览图同理:provider / CDN 常把 PNG 渲染图标成 -/// `application/octet-stream` 或干脆不写 `Content-Type`,照抄声明就会把 `image/webp` -/// 写进 OSS metadata 与 `asset_object.content_type`,素材库下载命名与后台等非浏览器消费方 -/// 都会拿到错的类型。浏览器 `` 自己能按魔数嗅探,但那不是让记录值继续说谎的理由。 +/// provider 对二进制产物常给 `application/octet-stream`,因此「字节优先」是这条链路的前提: +/// 客户端按这份 content type 判定能不能预览,说错就是拿 GLTFLoader 去解 FBX。预览图同理: +/// provider / CDN 常把 PNG 渲染图标成 `application/octet-stream` 或干脆不写 `Content-Type`, +/// 照抄声明就会把 `image/webp` 写进 OSS metadata 与 `asset_object.content_type`,素材库下载 +/// 命名与后台等非浏览器消费方都会拿到错的类型。浏览器 `` 自己能按魔数嗅探, +/// 但那不是让记录值继续说谎的理由。 +/// +/// 声明值只在槽位白名单内才被采信:白名单外的取值会让类型与扩展名互相矛盾 +/// (provider 说 `text/plain`、对象键却只能拼出 `.glb`),并且把 provider 的任意文本 +/// 原样写进 OSS metadata 与 `asset_object.content_type`。字节认不出、声明也不在白名单 +/// 时直接失败,让一次生成失败退款,而不是落一条自相矛盾的元数据。 /// /// 这里不把 worker 的 `preview_dimensions` 猜出的格式传下来:那一步是「必须能解码」的正交校验, /// 与写库时的类型归一各管一件事,重复的只是读文件头这一下,换来判定点只有一个。 -fn resolve_artifact_content_type(raw: &str, slot: Model3dArtifactSlot, bytes: &[u8]) -> String { - match slot { - Model3dArtifactSlot::Model => { - if let Some(sniffed) = sniff_model_content_type(bytes) { - return sniffed.to_string(); - } - } - Model3dArtifactSlot::Preview => { - if let Some(sniffed) = sniff_preview_content_type(bytes) { - return sniffed.to_string(); - } - } +fn resolve_artifact_content_type( + raw: &str, + slot: Model3dArtifactSlot, + bytes: &[u8], +) -> Result { + let sniffed = match slot { + Model3dArtifactSlot::Model => sniff_model_content_type(bytes), + Model3dArtifactSlot::Preview => sniff_preview_content_type(bytes), + }; + if let Some(sniffed) = sniffed { + return Ok(sniffed.to_string()); } - normalize_artifact_content_type(raw, slot) + declared_artifact_content_type(raw, slot) + .map(str::to_string) + .ok_or_else(|| unsupported_artifact_content_type(raw, slot)) } -fn artifact_extension(content_type: &str, slot: Model3dArtifactSlot) -> &'static str { - match content_type { - "model/gltf-binary" => "glb", - "model/gltf+json" => "gltf", - "model/fbx" | "application/x-fbx" => "fbx", - "image/png" => "png", - "image/jpeg" | "image/jpg" => "jpg", - "image/webp" => "webp", - _ => slot.fallback_extension(), - } +/// 声明类型不在槽位白名单里:不落库,也不猜一个类型继续走。 +fn unsupported_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> AppError { + // 声明值是不可信输入,只回显截断后的一段,避免把任意长度的 provider 文本带进错误体。 + let declared: String = raw.trim().chars().take(64).collect(); + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-artifact-content-type-unsupported", + "message": format!( + "provider 返回的 {} 产物类型不可识别,且字节特征与任何受支持格式都不匹配。", + slot.file_stem() + ), + "declaredContentType": declared, + })) } fn sha256_hex(bytes: &[u8]) -> String { @@ -282,19 +312,35 @@ mod tests { const JPEG_BYTES: &[u8] = b"\xFF\xD8\xFF\xE0rest-of-jpeg"; const WEBP_BYTES: &[u8] = b"RIFF\x24\x00\x00\x00WEBPVP8 rest-of-webp"; + /// 期望可归一的用例:失败即 panic,并把真实错误带出来。 + fn resolve(declared: &str, slot: Model3dArtifactSlot, bytes: &[u8]) -> String { + resolve_artifact_content_type(declared, slot, bytes) + .unwrap_or_else(|error| panic!("声明 {declared} 的产物应当可归一:{error}")) + } + + /// 期望被拒的用例:断言按「上游内容不合法」失败。 + fn resolve_rejected(declared: &str, slot: Model3dArtifactSlot, bytes: &[u8]) { + let error = resolve_artifact_content_type(declared, slot, bytes) + .expect_err(&format!("声明 {declared} 的产物必须被拒绝")); + assert_eq!( + error.status_code(), + StatusCode::BAD_GATEWAY, + "声明 {declared} 的产物未通过识别时应按上游内容不合法失败" + ); + } + #[test] fn model_content_type_follows_bytes_over_declaration() { - // provider 对二进制产物常给 application/octet-stream。归一后是 model/gltf-binary, - // 一个 FBX 结果就会以 .glb 落库,客户端按它判定预览必然解析失败。 - let content_type = resolve_artifact_content_type( + // provider 对二进制产物常给 application/octet-stream,照抄声明会让 FBX 以 .glb 落库。 + let content_type = resolve( "application/octet-stream", Model3dArtifactSlot::Model, FBX_BYTES, ); assert_eq!(content_type, "model/fbx"); assert_eq!( - artifact_extension(content_type.as_str(), Model3dArtifactSlot::Model), - "fbx" + Model3dArtifactSlot::Model.artifact_extension(content_type.as_str()), + Some("fbx") ); } @@ -303,11 +349,7 @@ mod tests { // 声明说 glb、字节是 fbx 时以字节为准:这份 content type 会进 asset_object, // 前端拿它决定禁用 3D 预览与否,不能让它说错。 assert_eq!( - resolve_artifact_content_type( - "model/gltf-binary", - Model3dArtifactSlot::Model, - FBX_BYTES, - ), + resolve("model/gltf-binary", Model3dArtifactSlot::Model, FBX_BYTES), "model/fbx" ); } @@ -315,49 +357,51 @@ mod tests { #[test] fn model_content_type_recognizes_each_supported_container() { assert_eq!( - resolve_artifact_content_type( + resolve( "application/octet-stream", Model3dArtifactSlot::Model, - GLB_BYTES, + GLB_BYTES ), "model/gltf-binary" ); assert_eq!( - resolve_artifact_content_type( + resolve( "application/octet-stream", Model3dArtifactSlot::Model, - GLTF_JSON_BYTES, + GLTF_JSON_BYTES ), "model/gltf+json" ); assert_eq!( - artifact_extension("model/gltf+json", Model3dArtifactSlot::Model), - "gltf" + Model3dArtifactSlot::Model.artifact_extension("model/gltf+json"), + Some("gltf") ); } #[test] - fn model_content_type_keeps_declaration_when_bytes_are_unknown() { - // 嗅不出来时保持既有行为:声明可用就信声明,否则回落 glb。 + fn model_content_type_rejects_declarations_outside_the_allowlist() { + // 字节认不出、声明又不在白名单时不再兜底成 .glb:放行 text/html 这类类型 + // 会落成「错的 content type + 拼出来的扩展名」,比直接失败更难发现。 + for declared in [ + "application/octet-stream", + "text/html; charset=utf-8", + "text/plain", + "", + ] { + resolve_rejected(declared, Model3dArtifactSlot::Model, UNKNOWN_BYTES); + } + // 白名单内的声明在字节认不出时仍被采信,且扩展名与它一致。 assert_eq!( - resolve_artifact_content_type( - "application/octet-stream", - Model3dArtifactSlot::Model, - UNKNOWN_BYTES, - ), - "model/gltf-binary" - ); - assert_eq!( - resolve_artifact_content_type( + resolve( "model/gltf-binary", Model3dArtifactSlot::Model, - UNKNOWN_BYTES, + UNKNOWN_BYTES ), "model/gltf-binary" ); assert_eq!( - artifact_extension("binary/octet-stream", Model3dArtifactSlot::Model), - "glb" + Model3dArtifactSlot::Model.artifact_extension("binary/octet-stream"), + None ); } @@ -373,57 +417,59 @@ mod tests { (WEBP_BYTES, "application/octet-stream", "image/webp"), ] { assert_eq!( - resolve_artifact_content_type(declared, Model3dArtifactSlot::Preview, bytes), + resolve(declared, Model3dArtifactSlot::Preview, bytes), expected, "声明 {declared} 的预览图必须按字节判定" ); } // 类型与对象键扩展名同源于这一次判定:PNG 预览落库是 preview.png。 assert_eq!( - artifact_extension("image/png", Model3dArtifactSlot::Preview), - "png" + Model3dArtifactSlot::Preview.artifact_extension("image/png"), + Some("png") ); } #[test] - fn preview_content_type_keeps_declaration_when_bytes_are_unknown() { - // 嗅不出图片格式时保持既有行为:声明可用就信声明,否则回落 webp。 + fn preview_content_type_keeps_only_allowlisted_declarations() { + // 带参数的声明先去掉参数再比对。 assert_eq!( - resolve_artifact_content_type( + resolve( "image/jpeg; charset=binary", Model3dArtifactSlot::Preview, - UNKNOWN_BYTES, + UNKNOWN_BYTES ), "image/jpeg" ); - assert_eq!( - resolve_artifact_content_type( - "application/octet-stream", - Model3dArtifactSlot::Preview, - UNKNOWN_BYTES, - ), - "image/webp" + // octet-stream、模型字节与白名单外的图片格式都不构成可用声明: + // 预览槽位同样不做「猜一个 webp」的兜底。 + resolve_rejected( + "application/octet-stream", + Model3dArtifactSlot::Preview, + UNKNOWN_BYTES, ); - // 模型字节不是预览图格式,不参与预览判定,仍走图片归一。 - assert_eq!( - resolve_artifact_content_type( - "application/octet-stream", - Model3dArtifactSlot::Preview, - GLB_BYTES, - ), - "image/webp" + resolve_rejected( + "application/octet-stream", + Model3dArtifactSlot::Preview, + GLB_BYTES, ); + resolve_rejected("image/avif", Model3dArtifactSlot::Preview, UNKNOWN_BYTES); } #[test] - fn artifact_extension_maps_known_fbx_spellings() { + fn artifact_extension_rejects_cross_slot_types() { assert_eq!( - artifact_extension("application/x-fbx", Model3dArtifactSlot::Model), - "fbx" + Model3dArtifactSlot::Model.artifact_extension("application/x-fbx"), + Some("fbx") ); assert_eq!( - artifact_extension("image/png", Model3dArtifactSlot::Preview), - "png" + Model3dArtifactSlot::Model.artifact_extension("image/png"), + None, + "预览图类型不能落进模型槽位" + ); + assert_eq!( + Model3dArtifactSlot::Preview.artifact_extension("model/fbx"), + None, + "模型类型不能落进预览槽位" ); } } diff --git a/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs b/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs index b92372bf6..473af1895 100644 --- a/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs +++ b/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs @@ -8,7 +8,7 @@ use crate::model3d::common::Model3dGenerationSource; /// image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。 /// /// 结果落点与 text-to-model 同形:平坦的可选 `projectId` / `canvasCompletion` / -/// `assetFolderId` / `assetLabel`,二选一,不用 tagged enum。 +/// `assetFolderId` / `assetLabel`,至少给一个,不用 tagged enum。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[derive(ts_rs::TS)] @@ -16,7 +16,7 @@ use crate::model3d::common::Model3dGenerationSource; pub struct Model3dImageToModelRequest { pub source: Model3dGenerationSource, pub generation: Model3dImageToModelParams, - /// 项目资源落点;与 `assetFolderId` 二选一。 + /// 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub project_id: Option, @@ -24,7 +24,7 @@ pub struct Model3dImageToModelRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub canvas_completion: Option, - /// 素材库落点;与 `projectId` 二选一。 + /// 素材库落点;与 `projectId` 至少给一个,两个都给合法。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub asset_folder_id: Option, diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs index 99c4c79b9..14ab253c6 100644 --- a/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs +++ b/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs @@ -11,14 +11,15 @@ use crate::editor_canvas::EditorCanvasGenerationCompletionPayload; /// /// 结果落点与其它生成接口同形:平坦的可选 `projectId` / `canvasCompletion` / /// `assetFolderId` / `assetLabel`,不用 tagged enum —— 客户端不必为「落项目还是落素材库」 -/// 多拼一层判别结构。服务端按「二选一」校验:两个都给或都不给都拒绝。 +/// 多拼一层判别结构。服务端按「至少一个落点」校验:两个都不给拒绝,两个都给合法(两条落点都落); +/// 没有 `projectId` 时结果只落素材库,与其它画布生成工具一致。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[derive(ts_rs::TS)] #[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] pub struct Model3dTextToModelRequest { pub generation: Model3dTextToModelParams, - /// 项目资源落点;与 `assetFolderId` 二选一。 + /// 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub project_id: Option, @@ -26,7 +27,7 @@ pub struct Model3dTextToModelRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub canvas_completion: Option, - /// 素材库落点;与 `projectId` 二选一。 + /// 素材库落点;与 `projectId` 至少给一个,两个都给合法。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub asset_folder_id: Option, diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts index 9f526ff80..7e7d00450 100644 --- a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts @@ -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, diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts index be9a05a24..ea175aba4 100644 --- a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts @@ -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, diff --git a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx index c06838063..2dc5d1029 100644 --- a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx +++ b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx @@ -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' }),