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' }),