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
@@ -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 常量。
@@ -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;
/**
@@ -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;
/**
+151 -105
View File
@@ -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`,素材库下载命名与后台等非浏览器消费方
/// 都会拿到错的类型。浏览器 `<img>` 自己能按魔数嗅探,但那不是让记录值继续说谎的理由。
/// provider 对二进制产物常给 `application/octet-stream`因此「字节优先」是这条链路的前提:
/// 客户端按这份 content type 判定能不能预览,说错就是拿 GLTFLoader 去解 FBX。预览图同理:
/// provider / CDN 常把 PNG 渲染图标成 `application/octet-stream` 或干脆不写 `Content-Type`
/// 照抄声明就会把 `image/webp` 写进 OSS metadata 与 `asset_object.content_type`素材库下载
/// 命名与后台等非浏览器消费方都会拿到错的类型。浏览器 `<img>` 自己能按魔数嗅探,
/// 但那不是让记录值继续说谎的理由。
///
/// 声明值只在槽位白名单内才被采信:白名单外的取值会让类型与扩展名互相矛盾
/// 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<String, AppError> {
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,
"模型类型不能落进预览槽位"
);
}
}
@@ -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<String>,
@@ -24,7 +24,7 @@ pub struct Model3dImageToModelRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
/// 素材库落点;与 `projectId` 二选一
/// 素材库落点;与 `projectId` 至少给一个,两个都给合法
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub asset_folder_id: Option<String>,
@@ -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<String>,
@@ -26,7 +27,7 @@ pub struct Model3dTextToModelRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
/// 素材库落点;与 `projectId` 二选一
/// 素材库落点;与 `projectId` 至少给一个,两个都给合法
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub asset_folder_id: Option<String>,
@@ -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' }),