diff --git a/packages/shared/src/contracts/model3d/common/Model3dGenerationTargetRef.ts b/packages/shared/src/contracts/model3d/common/Model3dGenerationTargetRef.ts index 21c0a11f0..36ca403fe 100644 --- a/packages/shared/src/contracts/model3d/common/Model3dGenerationTargetRef.ts +++ b/packages/shared/src/contracts/model3d/common/Model3dGenerationTargetRef.ts @@ -1,8 +1,18 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * 结果落点引用:与请求 `target` 同分支,但给的是已创建资源 / 素材的正式 ID。 + * 结果落点引用:与请求落点同形的平坦可选字段,给的是已创建资源 / 素材的正式 ID。 + * + * 与其它生成工具一致,项目资源与素材库可以同时产出(前端画布链路两个都发), + * 因此两个 ID 都是可选的:哪个落点写了行就带哪个 ID,两个都写了就都带。 */ -export type Model3dGenerationTargetRef = - | { kind: 'projectResource'; resourceId: string } - | { kind: 'assetLibrary'; assetId: string }; +export type Model3dGenerationTargetRef = { + /** + * 项目资源落点的正式资源 ID;没落画布时缺省。 + */ + resourceId?: string | null; + /** + * 素材库落点的正式素材 ID;没落素材库时缺省。 + */ + assetId?: string | null; +}; diff --git a/server-rs/crates/api-server/src/tripo3d/job.rs b/server-rs/crates/api-server/src/tripo3d/job.rs index f11582137..480e69589 100644 --- a/server-rs/crates/api-server/src/tripo3d/job.rs +++ b/server-rs/crates/api-server/src/tripo3d/job.rs @@ -102,9 +102,10 @@ impl Model3dJobRequest { const MODEL3D_IMAGE_TO_MODEL_AUDIT_PROMPT: &str = "图生 3D 模型"; -/// 结果落点:与其它生成接口同形的平坦字段(项目资源 / 素材库二选一)。 +/// 结果落点:与其它生成接口同形的平坦字段(项目资源 / 素材库可选,至少给一个)。 /// -/// 校验层保证「恰好给一个」,因此下游按 `project_id` 是否存在分辨落点,不再需要判别枚举。 +/// 与其它生成工具一致,两个落点可以同时给(前端画布链路两个都发),因此下游不再按 +/// 「二选一」分辨落点:`project_id` 有就落画布,`asset_folder_id` 有就落素材库。 #[derive(Clone, Copy, Debug)] pub(crate) struct Model3dJobTarget<'a> { pub(crate) project_id: Option<&'a str>, diff --git a/server-rs/crates/api-server/src/tripo3d/target.rs b/server-rs/crates/api-server/src/tripo3d/target.rs index b4dc1d218..e4d2a3827 100644 --- a/server-rs/crates/api-server/src/tripo3d/target.rs +++ b/server-rs/crates/api-server/src/tripo3d/target.rs @@ -27,10 +27,11 @@ pub(crate) async fn preflight_generation_target( owner_user_id: &str, target: Model3dJobTarget<'_>, ) -> Result<(), AppError> { - let Some((field, project_id, asset_folder_id)) = preflight_target_locator(target) else { - // 校验层保证「恰好一个落点」,这里只是纵深防御:真走到这里也不能放行无落点提交。 + let Some((project_id, asset_folder_id)) = preflight_target_locator(target) else { + // 校验层保证「至少一个落点」,这里只是纵深防御:真走到这里也不能放行无落点提交。 return Err(target_unavailable("projectId")); }; + let field = target_unavailable_field(project_id.as_deref(), asset_folder_id.as_deref()); state .spacetime_client() .preflight_editor_generation_target(EditorGenerationTargetPreflightRecordInput { @@ -44,22 +45,37 @@ pub(crate) async fn preflight_generation_target( .map_err(|error| collapse_target_unavailable(error, field)) } -/// 平坦落点 → 定点预检入参:返回(出错时点名的字段名、项目 ID、素材夹 ID)。 +/// 平坦落点 → 定点预检入参:返回(项目 ID、素材夹 ID)。 /// -/// 传下去的就是 trim 后的原值:校验值与随后入队、落库的值必须逐字一致, +/// 与其它生成工具同一口径:两个落点可以同时给(前端画布链路两个都发),procedure 会逐个定点查; +/// 传下去的就是 trim 后的原值 —— 校验值与随后入队、落库的值必须逐字一致, /// 不能校验归一化后的目录却把原始值落库。 /// 返回 `None` 表示两个落点字段都没给:调用方按「落点不可用」拒绝。 fn preflight_target_locator( target: Model3dJobTarget<'_>, -) -> Option<(&'static str, Option, Option)> { - match (target.project_id, target.asset_folder_id) { - (Some(project_id), _) => Some(("projectId", Some(project_id.trim().to_string()), None)), - (None, Some(asset_folder_id)) => Some(( - "assetFolderId", - None, - Some(asset_folder_id.trim().to_string()), - )), - (None, None) => None, +) -> Option<(Option, Option)> { + let project_id = target + .project_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let asset_folder_id = target + .asset_folder_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + (project_id.is_some() || asset_folder_id.is_some()).then_some((project_id, asset_folder_id)) +} + +/// 落点不可用时对外点名的字段:只给一个就点它,两个都给无法归因,回落到 `target`。 +fn target_unavailable_field( + project_id: Option<&str>, + asset_folder_id: Option<&str>, +) -> &'static str { + match (project_id.is_some(), asset_folder_id.is_some()) { + (true, false) => "projectId", + (false, true) => "assetFolderId", + _ => "target", } } @@ -118,7 +134,7 @@ mod tests { ); } - /// 平坦落点收敛成定点预检入参:只做 trim,两个都不给返回 `None`。 + /// 平坦落点收敛成定点预检入参:只做 trim,两个都给就都传,都不给返回 `None`。 #[test] fn flat_locator_maps_to_point_lookup_without_rewriting_ids() { let locator = |project_id, asset_folder_id| { @@ -132,17 +148,34 @@ mod tests { assert_eq!( locator(Some(" project-1 "), None), - Some(("projectId", Some("project-1".to_string()), None)) + Some((Some("project-1".to_string()), None)) ); assert_eq!( locator(None, Some(" folder-1 ")), - Some(("assetFolderId", None, Some("folder-1".to_string()))) + Some((None, Some("folder-1".to_string()))) ); - // 两个都给由校验层拒绝;预检的兜底口径是项目优先,不静默换成素材库。 assert_eq!( locator(Some("project-1"), Some("folder-1")), - Some(("projectId", Some("project-1".to_string()), None)) + Some((Some("project-1".to_string()), Some("folder-1".to_string()))) ); assert_eq!(locator(None, None), None); } + + /// 出错字段:只有一个落点时点名它,两个都给时无法归因,回落到 `target`。 + #[test] + fn unavailable_field_names_the_given_locators() { + assert_eq!( + target_unavailable_field(Some("project-1"), None), + "projectId" + ); + assert_eq!( + target_unavailable_field(None, Some("folder-1")), + "assetFolderId" + ); + assert_eq!( + target_unavailable_field(Some("project-1"), Some("folder-1")), + "target" + ); + assert_eq!(target_unavailable_field(None, None), "target"); + } } diff --git a/server-rs/crates/api-server/src/tripo3d/validation.rs b/server-rs/crates/api-server/src/tripo3d/validation.rs index bf0eadf62..1563d3d7d 100644 --- a/server-rs/crates/api-server/src/tripo3d/validation.rs +++ b/server-rs/crates/api-server/src/tripo3d/validation.rs @@ -356,16 +356,22 @@ mod tests { assert_eq!(error.field().as_deref(), Some("assetFolderId")); } - /// 平坦落点字段的四种组合:恰好一个才通过;都给、都不给与 blank 都按 400 拒绝。 + /// 平坦落点的合法组合:项目、素材库都可以给,两个一起给也合法(与其它生成工具一致); + /// 一个都不给、blank 与「素材库落点带画布占位框」都按 400 拒绝。 #[test] - fn target_requires_exactly_one_flat_locator() { - let cases: [(&str, Value, Option<&str>); 6] = [ - ("projectId", json!({ "projectId": "project-1" }), None), + fn target_accepts_both_locators_but_requires_at_least_one() { + let cases: [(&str, Value, Option<&str>); 7] = [ + ("只给 projectId", json!({ "projectId": "project-1" }), None), ( - "assetFolderId", + "只给 assetFolderId", json!({ "assetFolderId": "folder-1" }), None, ), + ( + "两个都给", + json!({ "projectId": "project-1", "assetFolderId": "folder-1" }), + None, + ), ( "空白 projectId", json!({ "projectId": " " }), @@ -376,12 +382,22 @@ mod tests { json!({ "assetFolderId": " " }), Some("assetFolderId"), ), - ( - "两个都给", - json!({ "projectId": "project-1", "assetFolderId": "folder-1" }), - Some("assetFolderId"), - ), ("都不给", json!({}), Some("projectId")), + ( + "素材库落点带画布占位框", + json!({ + "assetFolderId": "folder-1", + "canvasCompletion": { + "dialogId": "dialog-1", + "title": "木椅", + "placeholder": { + "x": 1, "y": 2, "width": 3, "height": 4, + "originalWidth": 3, "originalHeight": 4 + } + } + }), + Some("canvasCompletion"), + ), ]; for (name, target_fields, expected_field) in cases { let mut payload = json!({ "generation": generation_with(json!({})) }); @@ -449,24 +465,20 @@ fn validate_target(target: Model3dJobTarget<'_>) -> Result<(), Model3dRequestErr "素材库目录 ID 不能为空", )); } - match (project_id, asset_folder_id) { - (Some(_), Some(_)) => Err(Model3dRequestError::invalid( - "assetFolderId", - "项目资源与素材库落点只能给一个", - )), - (Some(_), None) => Ok(()), - (None, Some(_)) => { - if target.canvas_completion.is_some() { - return Err(Model3dRequestError::invalid( - "canvasCompletion", - "只有项目资源落点才能回填画布占位框", - )); - } - Ok(()) - } - (None, None) => Err(Model3dRequestError::invalid( + // 与其它生成工具同一条口径:项目资源与素材库可以同时给(前端画布链路两个都发), + // 但一个都不给就没有结果落点——付费生成不能接受这种提交。 + if project_id.is_none() && asset_folder_id.is_none() { + return Err(Model3dRequestError::invalid( "projectId", "必须给出落点:projectId(项目资源)或 assetFolderId(素材库)", - )), + )); } + // 画布占位框是项目资源落点的回填载荷:没有项目就没有画布可以回填。 + if project_id.is_none() && target.canvas_completion.is_some() { + return Err(Model3dRequestError::invalid( + "canvasCompletion", + "只有项目资源落点才能回填画布占位框", + )); + } + Ok(()) } diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs index cfc930059..fd6adaa0f 100644 --- a/server-rs/crates/api-server/src/tripo3d/worker.rs +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -239,7 +239,7 @@ async fn persist_result( "message": format!("落库 3D 生成结果时:{error}。"), })) })?; - // 落点与其它生成接口同形的平坦字段:校验层保证「项目资源 / 素材库恰好一个」。 + // 落点与其它生成接口同形的平坦字段:校验层保证「至少一个」,两个都给时两条落点都写。 let target = request.target(); let project_id = target .project_id @@ -272,8 +272,7 @@ async fn persist_result( MODEL3D_MODEL_SLOT, ); let has_resource = project_id.is_some(); - // 结果里回落的落点身份要在 project_id 被搬进资源记录之前留下。 - let project_id_for_result = project_id.clone(); + let has_asset = folder_id.is_some(); let project_resource = project_id.map(|project_id| EditorProjectResourceCreateRecordInput { resource_id: resource_id.clone(), project_id, @@ -329,9 +328,8 @@ async fn persist_result( let result = build_result( request.job_kind(), target_ref( - project_id_for_result.is_some(), - resource_id.clone(), - asset_id_for_result, + has_resource.then(|| resource_id.clone()), + has_asset.then_some(asset_id_for_result), ), &model, &preview, @@ -387,16 +385,12 @@ async fn persist_result( Ok(()) } -/// 结果里的落点引用:项目落点回资源 ID,素材库落点回素材 ID。 -fn target_ref( - has_project: bool, - resource_id: String, - asset_id: String, -) -> Model3dGenerationTargetRef { - if has_project { - Model3dGenerationTargetRef::ProjectResource { resource_id } - } else { - Model3dGenerationTargetRef::AssetLibrary { asset_id } +/// 结果里的落点引用:写了项目资源行就带 `resourceId`,写了素材行就带 `assetId`; +/// 两个落点都写(前端画布链路的常态)时两个都带 —— 与请求落点同形的平坦字段。 +fn target_ref(resource_id: Option, asset_id: Option) -> Model3dGenerationTargetRef { + Model3dGenerationTargetRef { + resource_id, + asset_id, } } @@ -500,8 +494,9 @@ mod tests { ] { let result = build_result( kind, - Model3dGenerationTargetRef::ProjectResource { - resource_id: "resource-1".to_string(), + Model3dGenerationTargetRef { + resource_id: Some("resource-1".to_string()), + asset_id: None, }, &model, &preview, @@ -510,8 +505,9 @@ mod tests { let result = &payload["result"]; assert_eq!(result["kind"], json!(expected_kind)); - assert_eq!(result["target"]["kind"], json!("projectResource")); assert_eq!(result["target"]["resourceId"], json!("resource-1")); + // 只落项目资源时不得凭空带出素材 ID:结果引用与请求落点同形。 + assert_eq!(result["target"].get("assetId"), None); assert_eq!( result["model"]["objectKey"], json!("editor/model3d/task-1/model.glb") diff --git a/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs b/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs index 7b319e438..6dabe75f0 100644 --- a/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs +++ b/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs @@ -2,18 +2,21 @@ use serde::{Deserialize, Serialize}; use super::MODEL3D_TS_EXPORT_DIR; -/// 结果落点引用:与请求 `target` 同分支,但给的是已创建资源 / 素材的正式 ID。 -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +/// 结果落点引用:与请求落点同形的平坦可选字段,给的是已创建资源 / 素材的正式 ID。 +/// +/// 与其它生成工具一致,项目资源与素材库可以同时产出(前端画布链路两个都发), +/// 因此两个 ID 都是可选的:哪个落点写了行就带哪个 ID,两个都写了就都带。 +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[derive(ts_rs::TS)] #[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] -pub enum Model3dGenerationTargetRef { - ProjectResource { - #[serde(rename = "resourceId")] - resource_id: String, - }, - AssetLibrary { - #[serde(rename = "assetId")] - asset_id: String, - }, +pub struct Model3dGenerationTargetRef { + /// 项目资源落点的正式资源 ID;没落画布时缺省。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub resource_id: Option, + /// 素材库落点的正式素材 ID;没落素材库时缺省。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_id: Option, } diff --git a/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs b/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs index a0b02ff08..3802c0cd5 100644 --- a/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs +++ b/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs @@ -114,7 +114,7 @@ fn api_request_rejects_unknown_top_level_fields() { fn generation_result_is_tagged_per_endpoint() { let result: Model3dGenerationResult = serde_json::from_value(json!({ "kind": "textToModel", - "target": { "kind": "projectResource", "resourceId": "res-1" }, + "target": { "resourceId": "res-1" }, "model": { "objectKey": "models/res-1.glb", "contentType": "model/gltf-binary", @@ -135,8 +135,9 @@ fn generation_result_is_tagged_per_endpoint() { }; assert_eq!( text.target, - Model3dGenerationTargetRef::ProjectResource { - resource_id: "res-1".into() + Model3dGenerationTargetRef { + resource_id: Some("res-1".into()), + asset_id: None, } ); assert_eq!( @@ -161,7 +162,7 @@ fn generation_result_is_tagged_per_endpoint() { // 对称覆盖 imageToModel 分支:tag 路由与分支字段都要单独守住。 let image_result: Model3dGenerationResult = serde_json::from_value(json!({ "kind": "imageToModel", - "target": { "kind": "projectResource", "resourceId": "res-2" }, + "target": { "assetId": "asset-2" }, "model": { "objectKey": "models/res-2.glb", "contentType": "model/gltf-binary", @@ -180,10 +181,12 @@ fn generation_result_is_tagged_per_endpoint() { let Model3dGenerationResult::ImageToModel(image) = image_result else { panic!("kind=imageToModel 不应解成 textToModel 分支"); }; + // 只落素材库时,平坦引用只带 assetId。 assert_eq!( image.target, - Model3dGenerationTargetRef::ProjectResource { - resource_id: "res-2".into() + Model3dGenerationTargetRef { + resource_id: None, + asset_id: Some("asset-2".into()), } ); assert_eq!(