From fd48829c24677c38f2d654ae447ad85c470fc343 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 14:02:44 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E7=94=9F=E6=88=90=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E8=90=BD=E7=82=B9=E6=94=B9=E4=B8=BA=E5=B9=B3=E5=9D=A6=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E5=AD=97=E6=AE=B5=E5=B9=B6=E5=A4=8D=E7=94=A8=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=90=BD=E7=82=B9=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 的 text-to-model / image-to-model 请求用平坦可选 projectId / canvasCompletion / assetFolderId / assetLabel 取代 tagged enum target,删除 Model3dGenerationTarget 及其契约测试。 - 同步 ts-rs 生成绑定:新增四个可选字段并移除 Model3dGenerationTarget.ts 与 barrel 导出。 - api-server 校验层改按「项目资源 / 素材库二选一」校验,缺失、冲突与空白值都返回 400 并点名具体字段。 - 落点预检改用其它付费编辑器生成共用的 preflight_editor_generation_target_and_return,素材库落点不再读整库。 - worker 按平坦字段落库,素材名缺省回落到 MODEL3D_DEFAULT_ASSET_LABEL。 - 定向验证:cargo test -p shared-contracts 与 cargo test -p api-server tripo3d:: 全绿(54 passed)。 --- .../model3d/common/Model3dGenerationTarget.ts | 16 -- .../Model3dImageToModelRequest.ts | 22 ++- .../shared/src/contracts/model3d/index.ts | 1 - .../Model3dTextToModelRequest.ts | 23 ++- .../crates/api-server/src/tripo3d/job.rs | 46 +++++- .../crates/api-server/src/tripo3d/routes.rs | 39 +++-- .../crates/api-server/src/tripo3d/target.rs | 145 +++++++++++------- .../api-server/src/tripo3d/validation.rs | 145 ++++++++++++++---- .../crates/api-server/src/tripo3d/worker.rs | 59 +++---- .../src/model3d/common/generation_target.rs | 29 ---- .../src/model3d/common/mod.rs | 2 - .../src/model3d/image_to_model/request.rs | 23 ++- .../src/model3d/text_to_model/request.rs | 23 ++- .../tests/model3d_api_request_contract.rs | 74 ++++++--- 14 files changed, 431 insertions(+), 216 deletions(-) delete mode 100644 packages/shared/src/contracts/model3d/common/Model3dGenerationTarget.ts delete mode 100644 server-rs/crates/shared-contracts/src/model3d/common/generation_target.rs diff --git a/packages/shared/src/contracts/model3d/common/Model3dGenerationTarget.ts b/packages/shared/src/contracts/model3d/common/Model3dGenerationTarget.ts deleted file mode 100644 index 4dc7db337..000000000 --- a/packages/shared/src/contracts/model3d/common/Model3dGenerationTarget.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { EditorCanvasGenerationCompletionPayload } from '../../editor-canvas/EditorCanvasGenerationCompletionPayload'; - -/** - * 生成结果的落点。两个分支都必须给出定位字段,不存在“都不给就默认落素材库”的口径。 - */ -export type Model3dGenerationTarget = - | { - kind: 'projectResource'; - projectId: string; - /** - * 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置。 - */ - canvasCompletion?: EditorCanvasGenerationCompletionPayload | null; - } - | { kind: 'assetLibrary'; folderId: string; label: string }; 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 85b08dea6..15d1e671a 100644 --- a/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelRequest.ts +++ b/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelRequest.ts @@ -1,13 +1,31 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EditorCanvasGenerationCompletionPayload } from '../../editor-canvas/EditorCanvasGenerationCompletionPayload'; import type { Model3dGenerationSource } from '../common/Model3dGenerationSource'; -import type { Model3dGenerationTarget } from '../common/Model3dGenerationTarget'; import type { Model3dImageToModelParams } from './Model3dImageToModelParams'; /** * image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。 + * + * 结果落点与 text-to-model 同形:平坦的可选 `projectId` / `canvasCompletion` / + * `assetFolderId` / `assetLabel`,二选一,不用 tagged enum。 */ export type Model3dImageToModelRequest = { source: Model3dGenerationSource; generation: Model3dImageToModelParams; - target: Model3dGenerationTarget; + /** + * 项目资源落点;与 `assetFolderId` 二选一。 + */ + projectId?: string | null; + /** + * 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。 + */ + canvasCompletion?: EditorCanvasGenerationCompletionPayload | null; + /** + * 素材库落点;与 `projectId` 二选一。 + */ + assetFolderId?: string | null; + /** + * 素材名称;缺省用平台默认名。 + */ + assetLabel?: string | null; }; diff --git a/packages/shared/src/contracts/model3d/index.ts b/packages/shared/src/contracts/model3d/index.ts index dd4239b93..7e3b5c036 100644 --- a/packages/shared/src/contracts/model3d/index.ts +++ b/packages/shared/src/contracts/model3d/index.ts @@ -2,7 +2,6 @@ export type * from './common/Model3dCompression'; export type * from './common/Model3dExportOrientation'; export type * from './common/Model3dGeneratedArtifact'; export type * from './common/Model3dGenerationSource'; -export type * from './common/Model3dGenerationTarget'; export type * from './common/Model3dGenerationTargetRef'; export type * from './common/Model3dGeometryQuality'; export type * from './common/Model3dInputOrientation'; 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 880c7e39c..e37685d1a 100644 --- a/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelRequest.ts +++ b/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelRequest.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Model3dGenerationTarget } from '../common/Model3dGenerationTarget'; +import type { EditorCanvasGenerationCompletionPayload } from '../../editor-canvas/EditorCanvasGenerationCompletionPayload'; import type { Model3dTextToModelParams } from './Model3dTextToModelParams'; /** @@ -7,8 +7,27 @@ import type { Model3dTextToModelParams } from './Model3dTextToModelParams'; * * 生成参数单独嵌一层而不是摊平到顶层:serde 的 `deny_unknown_fields` 与 `flatten` * 不能共存,摊平会让顶层未知字段静默通过。 + * + * 结果落点与其它生成接口同形:平坦的可选 `projectId` / `canvasCompletion` / + * `assetFolderId` / `assetLabel`,不用 tagged enum —— 客户端不必为「落项目还是落素材库」 + * 多拼一层判别结构。服务端按「二选一」校验:两个都给或都不给都拒绝。 */ export type Model3dTextToModelRequest = { generation: Model3dTextToModelParams; - target: Model3dGenerationTarget; + /** + * 项目资源落点;与 `assetFolderId` 二选一。 + */ + projectId?: string | null; + /** + * 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。 + */ + canvasCompletion?: EditorCanvasGenerationCompletionPayload | null; + /** + * 素材库落点;与 `projectId` 二选一。 + */ + assetFolderId?: string | null; + /** + * 素材名称;缺省用平台默认名。 + */ + assetLabel?: 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 cb2717784..4a112eb31 100644 --- a/server-rs/crates/api-server/src/tripo3d/job.rs +++ b/server-rs/crates/api-server/src/tripo3d/job.rs @@ -4,7 +4,7 @@ //! 区分,结果类型也按端点严格区分,不做“一个 kind 带 mode 字段”的变形。 use serde_json::Value; -use shared_contracts::model3d::common::Model3dGenerationTarget; +use shared_contracts::editor_canvas::EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest; use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest; use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; @@ -62,10 +62,11 @@ impl Model3dJobRequest { } } - pub(crate) fn target(&self) -> &Model3dGenerationTarget { + /// 结果落点字段;两个端点的契约落点同形,校验、预检与落库共用这一份视图。 + pub(crate) fn target(&self) -> Model3dJobTarget<'_> { match self { - Self::TextToModel(request) => &request.target, - Self::ImageToModel(request) => &request.target, + Self::TextToModel(request) => text_to_model_target(request), + Self::ImageToModel(request) => image_to_model_target(request), } } @@ -95,6 +96,35 @@ impl Model3dJobRequest { const MODEL3D_IMAGE_TO_MODEL_AUDIT_PROMPT: &str = "图生 3D 模型"; +/// 结果落点:与其它生成接口同形的平坦字段(项目资源 / 素材库二选一)。 +/// +/// 校验层保证「恰好给一个」,因此下游按 `project_id` 是否存在分辨落点,不再需要判别枚举。 +#[derive(Clone, Copy, Debug)] +pub(crate) struct Model3dJobTarget<'a> { + pub(crate) project_id: Option<&'a str>, + pub(crate) canvas_completion: Option<&'a EditorCanvasGenerationCompletionRequest>, + pub(crate) asset_folder_id: Option<&'a str>, + pub(crate) asset_label: Option<&'a str>, +} + +pub(crate) fn text_to_model_target(request: &Model3dTextToModelRequest) -> Model3dJobTarget<'_> { + Model3dJobTarget { + project_id: request.project_id.as_deref(), + canvas_completion: request.canvas_completion.as_ref(), + asset_folder_id: request.asset_folder_id.as_deref(), + asset_label: request.asset_label.as_deref(), + } +} + +pub(crate) fn image_to_model_target(request: &Model3dImageToModelRequest) -> Model3dJobTarget<'_> { + Model3dJobTarget { + project_id: request.project_id.as_deref(), + canvas_completion: request.canvas_completion.as_ref(), + asset_folder_id: request.asset_folder_id.as_deref(), + asset_label: request.asset_label.as_deref(), + } +} + /// 从队列 payload 还原请求。 /// /// 队列 payload 比 API 请求多一个幂等指纹字段,且请求类型是 `deny_unknown_fields`; @@ -152,7 +182,7 @@ mod tests { "smartLowPoly": false, "generateParts": false }, - "target": { "kind": "projectResource", "projectId": "project-1" } + "projectId": "project-1" }) .to_string(); @@ -179,7 +209,7 @@ mod tests { "smartLowPoly": false, "generateParts": false }, - "target": { "kind": "projectResource", "projectId": "project-1" }, + "projectId": "project-1", "_externalApiRequestFingerprint": "fingerprint-1" }) .to_string(); @@ -205,7 +235,7 @@ mod tests { "smartLowPoly": false, "generateParts": false }, - "target": { "kind": "projectResource", "projectId": "project-1" }, + "projectId": "project-1", "_externalApiRequestFingerprint": "fingerprint-1", "unexpectedField": true }) @@ -215,7 +245,7 @@ mod tests { .is_err() ); - // 缺 generation / target 的 payload 与未知 job kind 都必须被拒。 + // 缺 generation / 落点的 payload 与未知 job kind 都必须被拒。 let payload = json!({ "prompt": "一只木箱" }).to_string(); assert!(parse_model3d_job_request(MODEL3D_TEXT_TO_MODEL_JOB_KIND, &payload).is_err()); assert!(parse_model3d_job_request("unknown_job_kind", &payload).is_err()); diff --git a/server-rs/crates/api-server/src/tripo3d/routes.rs b/server-rs/crates/api-server/src/tripo3d/routes.rs index 03ece8c6c..570935a35 100644 --- a/server-rs/crates/api-server/src/tripo3d/routes.rs +++ b/server-rs/crates/api-server/src/tripo3d/routes.rs @@ -25,7 +25,7 @@ use crate::{ use super::errors::{map_pricing_error, map_pricing_store_error, map_request_error}; use super::image_source::preflight_image_source; -use super::job::Model3dJobKind; +use super::job::{Model3dJobKind, Model3dJobTarget, image_to_model_target, text_to_model_target}; use super::queue::{Model3dSubmissionResponse, enqueue_model3d_job}; use super::target::preflight_generation_target; use super::validation::{validate_image_to_model_request, validate_text_to_model_request}; @@ -65,11 +65,12 @@ pub(crate) async fn submit_text_to_model( .map_err(|error| error.into_response_with_context(Some(&request_context)))?; // 落点预检与参数校验一样前置于查价:跨 owner / 不存在的落点必须在这里就被拒, // 不能等 worker 落库时才发现,那时 provider 预算已经花掉了。 - preflight_generation_target(&state, authenticated.claims().user_id(), &payload.target) + let target = text_to_model_target(&payload); + preflight_generation_target(&state, authenticated.claims().user_id(), target) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let price_mud_points = resolve_price_mud_points(&state, &request_context, &query).await?; - let source_entity_id = target_source_entity_id(&payload.target); + let source_entity_id = target_source_entity_id(target); let job = enqueue_model3d_job( &state, authenticated.claims().user_id(), @@ -102,11 +103,12 @@ pub(crate) async fn submit_image_to_model( .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; // 落点与图片输入一样,必须在查价、扣费与入队之前确认。 - preflight_generation_target(&state, authenticated.claims().user_id(), &payload.target) + let target = image_to_model_target(&payload); + preflight_generation_target(&state, authenticated.claims().user_id(), target) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let price_mud_points = resolve_price_mud_points(&state, &request_context, &query).await?; - let source_entity_id = target_source_entity_id(&payload.target); + let source_entity_id = target_source_entity_id(target); let job = enqueue_model3d_job( &state, authenticated.claims().user_id(), @@ -170,19 +172,14 @@ fn parse_json_payload( }) } -fn target_source_entity_id( - target: &shared_contracts::model3d::common::Model3dGenerationTarget, -) -> String { - match target { - shared_contracts::model3d::common::Model3dGenerationTarget::ProjectResource { - project_id, - .. - } => project_id.trim().to_string(), - shared_contracts::model3d::common::Model3dGenerationTarget::AssetLibrary { - folder_id, - .. - } => folder_id.trim().to_string(), - } +/// 任务行的 `source_entity_id`:落点身份(项目或素材夹),与其它生成接口同形。 +fn target_source_entity_id(target: Model3dJobTarget<'_>) -> String { + target + .project_id + .or(target.asset_folder_id) + .map(str::trim) + .unwrap_or_default() + .to_string() } /// 两个提交都必须显式给出 `Idempotency-Key`;缺失或格式非法一律 400,不静默生成键。 @@ -357,7 +354,8 @@ mod tests { "smartLowPoly": false, "generateParts": false }, - "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "测试" } + "assetFolderId": "folder-1", + "assetLabel": "测试" }) } @@ -375,7 +373,8 @@ mod tests { "smartLowPoly": false, "generateParts": false }, - "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "测试" } + "assetFolderId": "folder-1", + "assetLabel": "测试" }) } diff --git a/server-rs/crates/api-server/src/tripo3d/target.rs b/server-rs/crates/api-server/src/tripo3d/target.rs index 1a2bfb300..b4dc1d218 100644 --- a/server-rs/crates/api-server/src/tripo3d/target.rs +++ b/server-rs/crates/api-server/src/tripo3d/target.rs @@ -4,78 +4,80 @@ //! (画布项目 / 素材库文件夹)属于当前 owner 且仍然存在。否则一次注定落库失败的提交 //! 会先成功入队、扣费并花掉 provider 预算,最后才在 worker 落库阶段失败。 //! +//! 复用其它生成工具同一个定点预检 procedure(`preflight_editor_generation_target`): +//! 项目与素材夹都按主键查,不再为了一个 folder id 读整库。传下去的是 trim 后的原值, +//! 不做 `project` / `folder-*` → owner 默认素材夹的归一:归一值必须一路带到落库, +//! 而落点只在 3D 打开素材库入口后才需要(默认目录由落库 procedure 自己归一)。 +//! //! 跨 owner、已删除与不存在收敛成同一句 400,避免把「别人是否存在某个 ID」变成可探测信息; //! 只有基础设施故障保留原状态码。worker 落库时会再确认同一事实。 use axum::http::StatusCode; use serde_json::json; -use shared_contracts::model3d::common::Model3dGenerationTarget; -use spacetime_client::EditorProjectGetRecordInput; +use spacetime_client::editor_project::EditorGenerationTargetPreflightRecordInput; -use crate::{ - editor_project::{current_utc_micros, map_editor_project_error}, - http_error::AppError, - state::AppState, -}; +use crate::{editor_project::map_editor_project_error, http_error::AppError, state::AppState}; +use super::job::Model3dJobTarget; use super::provider::TRIPO_PROVIDER; /// 提交时的落点预检:只读元数据,不写记录,也不产生 provider 副作用。 pub(crate) async fn preflight_generation_target( state: &AppState, owner_user_id: &str, - target: &Model3dGenerationTarget, + target: Model3dJobTarget<'_>, ) -> Result<(), AppError> { - match target { - Model3dGenerationTarget::ProjectResource { project_id, .. } => { - // 按 (project_id, owner) 定点读取:跨 owner 与已删除在 procedure 侧就是同一种失败。 - state - .spacetime_client() - .get_editor_project(EditorProjectGetRecordInput { - project_id: project_id.trim().to_string(), - owner_user_id: owner_user_id.to_string(), - }) - .await - .map(|_| ()) - .map_err(map_editor_project_error) - .map_err(collapse_target_unavailable) - } - Model3dGenerationTarget::AssetLibrary { folder_id, .. } => { - // 素材库目前没有「按 folder ID 定点读取」的 procedure,只能读整库再匹配; - // 落点是文件夹(不是素材),因此也不能用引用解析代替。等窄查询补齐后改用它。 - let library = state - .spacetime_client() - .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) - .await - .map_err(map_editor_project_error)?; - let folder_id = folder_id.trim(); - if library - .folders - .iter() - .any(|folder| folder.folder_id == folder_id) - { - Ok(()) - } else { - Err(target_unavailable()) - } - } + let Some((field, project_id, asset_folder_id)) = preflight_target_locator(target) else { + // 校验层保证「恰好一个落点」,这里只是纵深防御:真走到这里也不能放行无落点提交。 + return Err(target_unavailable("projectId")); + }; + state + .spacetime_client() + .preflight_editor_generation_target(EditorGenerationTargetPreflightRecordInput { + owner_user_id: owner_user_id.trim().to_string(), + project_id, + asset_folder_id, + }) + .await + .map(|_| ()) + .map_err(map_editor_project_error) + .map_err(|error| collapse_target_unavailable(error, field)) +} + +/// 平坦落点 → 定点预检入参:返回(出错时点名的字段名、项目 ID、素材夹 ID)。 +/// +/// 传下去的就是 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, } } /// 未登记 / 跨 owner / 已删除都收敛成同一句 400;基础设施故障保留原状态码。 -fn collapse_target_unavailable(error: AppError) -> AppError { +fn collapse_target_unavailable(error: AppError, field: &'static str) -> AppError { if error.status_code().is_client_error() { - target_unavailable() + target_unavailable(field) } else { error } } -fn target_unavailable() -> AppError { +/// 落点不可用:字段名跟随平坦参数,指向调用方实际给错的那个键。 +fn target_unavailable(field: &'static str) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": TRIPO_PROVIDER, "reason": "model3d-target-unavailable", - "field": "target", + "field": field, "message": "生成结果的落点必须是当前账号已存在的画布项目或素材库文件夹。", })) } @@ -86,22 +88,61 @@ mod tests { #[test] fn target_unavailable_is_stable_and_machine_readable() { - let error = target_unavailable(); + let error = target_unavailable("projectId"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); let details = error.details().expect("details 必须存在"); assert_eq!(details["reason"], json!("model3d-target-unavailable")); - assert_eq!(details["field"], json!("target")); + assert_eq!(details["field"], json!("projectId")); } /// 跨 owner / 不存在(4xx)对外只有一种说法;基础设施故障不能被误报成用户问题。 #[test] fn only_client_errors_collapse_into_target_unavailable() { - let collapsed = collapse_target_unavailable(AppError::from_status(StatusCode::NOT_FOUND)); - assert_eq!(collapsed.details(), target_unavailable().details()); + let collapsed = collapse_target_unavailable( + AppError::from_status(StatusCode::NOT_FOUND), + "assetFolderId", + ); + assert_eq!( + collapsed.details(), + target_unavailable("assetFolderId").details() + ); - let preserved = - collapse_target_unavailable(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)); + let preserved = collapse_target_unavailable( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR), + "assetFolderId", + ); assert_eq!(preserved.status_code(), StatusCode::INTERNAL_SERVER_ERROR); - assert_ne!(preserved.details(), target_unavailable().details()); + assert_ne!( + preserved.details(), + target_unavailable("assetFolderId").details() + ); + } + + /// 平坦落点收敛成定点预检入参:只做 trim,两个都不给返回 `None`。 + #[test] + fn flat_locator_maps_to_point_lookup_without_rewriting_ids() { + let locator = |project_id, asset_folder_id| { + preflight_target_locator(Model3dJobTarget { + project_id, + canvas_completion: None, + asset_folder_id, + asset_label: None, + }) + }; + + assert_eq!( + locator(Some(" project-1 "), None), + Some(("projectId", Some("project-1".to_string()), None)) + ); + assert_eq!( + locator(None, Some(" folder-1 ")), + Some(("assetFolderId", None, Some("folder-1".to_string()))) + ); + // 两个都给由校验层拒绝;预检的兜底口径是项目优先,不静默换成素材库。 + assert_eq!( + locator(Some("project-1"), Some("folder-1")), + Some(("projectId", Some("project-1".to_string()), None)) + ); + assert_eq!(locator(None, None), None); } } diff --git a/server-rs/crates/api-server/src/tripo3d/validation.rs b/server-rs/crates/api-server/src/tripo3d/validation.rs index 0a78a3ae4..bf0eadf62 100644 --- a/server-rs/crates/api-server/src/tripo3d/validation.rs +++ b/server-rs/crates/api-server/src/tripo3d/validation.rs @@ -8,8 +8,7 @@ use platform_tripo::{TripoError, validate_image_to_model_params, validate_text_to_model_params}; use shared_contracts::model3d::common::{ - Model3dGenerationSource, Model3dGenerationTarget, Model3dGeometryQuality, Model3dModelVersion, - Model3dTextureQuality, + Model3dGenerationSource, Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality, }; use shared_contracts::model3d::image_to_model::{ Model3dImageToModelParams, Model3dImageToModelRequest, @@ -18,6 +17,7 @@ use shared_contracts::model3d::text_to_model::{ Model3dTextToModelParams, Model3dTextToModelRequest, }; +use super::job::{Model3dJobTarget, image_to_model_target, text_to_model_target}; use super::pricing::{Model3dEndpoint, Model3dPricingQuery, model3d_add_ons}; /// 请求被拒绝的原因。`InvalidRequest` 的字段名用 API 请求里的 JSON 字段路径。 @@ -68,7 +68,7 @@ pub(crate) fn validate_text_to_model_request( ) -> Result { let query = PricingParamView::from(&request.generation) .validate(Model3dEndpoint::TextToModel, request.generation.model)?; - validate_target(&request.target)?; + validate_target(text_to_model_target(request))?; validate_text_to_model_params(&request.generation).map_err(Model3dRequestError::Provider)?; Ok(query) } @@ -80,7 +80,7 @@ pub(crate) fn validate_image_to_model_request( validate_source(&request.source)?; let query = PricingParamView::from(&request.generation) .validate(Model3dEndpoint::ImageToModel, request.generation.model)?; - validate_target(&request.target)?; + validate_target(image_to_model_target(request))?; validate_image_to_model_params(&request.generation).map_err(Model3dRequestError::Provider)?; Ok(query) } @@ -233,7 +233,8 @@ mod tests { fn text_request(generation: Value) -> Model3dTextToModelRequest { serde_json::from_value(json!({ "generation": generation, - "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + "assetFolderId": "folder-1", + "assetLabel": "木椅" })) .expect("测试请求应可反序列化") } @@ -338,7 +339,8 @@ mod tests { let request: Model3dImageToModelRequest = serde_json::from_value(json!({ "source": { "kind": "asset", "assetId": " " }, "generation": image_generation_with(json!({})), - "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + "assetFolderId": "folder-1", + "assetLabel": "木椅" })) .expect("测试请求应可反序列化"); let error = validate_image_to_model_request(&request).expect_err("空白素材 ID 应被拒绝"); @@ -347,39 +349,124 @@ mod tests { let request: Model3dImageToModelRequest = serde_json::from_value(json!({ "source": { "kind": "resource", "resourceId": "resource-1" }, "generation": image_generation_with(json!({})), - "target": { "kind": "assetLibrary", "folderId": " ", "label": "木椅" } + "assetFolderId": " " })) .expect("测试请求应可反序列化"); let error = validate_image_to_model_request(&request).expect_err("空白目录 ID 应被拒绝"); - assert_eq!(error.field().as_deref(), Some("target.folderId")); + assert_eq!(error.field().as_deref(), Some("assetFolderId")); + } + + /// 平坦落点字段的四种组合:恰好一个才通过;都给、都不给与 blank 都按 400 拒绝。 + #[test] + fn target_requires_exactly_one_flat_locator() { + let cases: [(&str, Value, Option<&str>); 6] = [ + ("projectId", json!({ "projectId": "project-1" }), None), + ( + "assetFolderId", + json!({ "assetFolderId": "folder-1" }), + None, + ), + ( + "空白 projectId", + json!({ "projectId": " " }), + Some("projectId"), + ), + ( + "空白 assetFolderId", + json!({ "assetFolderId": " " }), + Some("assetFolderId"), + ), + ( + "两个都给", + json!({ "projectId": "project-1", "assetFolderId": "folder-1" }), + Some("assetFolderId"), + ), + ("都不给", json!({}), Some("projectId")), + ]; + for (name, target_fields, expected_field) in cases { + let mut payload = json!({ "generation": generation_with(json!({})) }); + for (key, value) in target_fields.as_object().expect("落点字段是对象") { + payload[key] = value.clone(); + } + let request: Model3dTextToModelRequest = + serde_json::from_value(payload).expect("测试请求应可反序列化"); + let error = validate_text_to_model_request(&request); + match expected_field { + None => assert!(error.is_ok(), "{name} 应通过校验:{error:?}"), + Some(field) => assert_eq!( + error.expect_err(name).field().as_deref(), + Some(field), + "{name}" + ), + } + } + } + + /// 画布占位框只在项目资源落点下有意义:落素材库时给它是调用方写错了字段。 + #[test] + fn canvas_completion_is_rejected_outside_project_target() { + let request: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": generation_with(json!({})), + "assetFolderId": "folder-1", + "canvasCompletion": { + "dialogId": "dialog-1", + "title": "木椅", + "placeholder": { + "x": 10, "y": 20, "width": 100, "height": 120, + "originalWidth": 100, "originalHeight": 120 + } + } + })) + .expect("测试请求应可反序列化"); + let error = + validate_text_to_model_request(&request).expect_err("素材库落点不应接受画布占位框"); + assert_eq!(error.field().as_deref(), Some("canvasCompletion")); } } -fn validate_target(target: &Model3dGenerationTarget) -> Result<(), Model3dRequestError> { - match target { - Model3dGenerationTarget::ProjectResource { project_id, .. } - if project_id.trim().is_empty() => - { - Err(Model3dRequestError::invalid( - "target.projectId", - "项目 ID 不能为空", - )) - } - Model3dGenerationTarget::AssetLibrary { folder_id, label } => { - if folder_id.trim().is_empty() { +/// 落点校验:项目资源与素材库二选一。 +/// +/// 与其它生成接口同一份字段形状,但 3D 生成更贵,因此不接受「都不给就默认落素材库」, +/// 也不接受两个都给 —— 落点不明时宁可在扣费前拒绝。 +fn validate_target(target: Model3dJobTarget<'_>) -> Result<(), Model3dRequestError> { + let project_id = target + .project_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let asset_folder_id = target + .asset_folder_id + .map(str::trim) + .filter(|value| !value.is_empty()); + if target.project_id.is_some() && project_id.is_none() { + return Err(Model3dRequestError::invalid( + "projectId", + "项目 ID 不能为空", + )); + } + if target.asset_folder_id.is_some() && asset_folder_id.is_none() { + return Err(Model3dRequestError::invalid( + "assetFolderId", + "素材库目录 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( - "target.folderId", - "素材库目录 ID 不能为空", - )); - } - if label.trim().is_empty() { - return Err(Model3dRequestError::invalid( - "target.label", - "素材名称不能为空", + "canvasCompletion", + "只有项目资源落点才能回填画布占位框", )); } Ok(()) } - _ => Ok(()), + (None, None) => Err(Model3dRequestError::invalid( + "projectId", + "必须给出落点:projectId(项目资源)或 assetFolderId(素材库)", + )), } } diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs index d73b479de..33248d94b 100644 --- a/server-rs/crates/api-server/src/tripo3d/worker.rs +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -13,9 +13,7 @@ use serde_json::json; use shared_contracts::editor_generation::{ editor_generation_stable_asset_id, editor_generation_stable_resource_id, }; -use shared_contracts::model3d::common::{ - Model3dGenerationTarget, Model3dGenerationTargetRef, Model3dTaskStatus, -}; +use shared_contracts::model3d::common::{Model3dGenerationTargetRef, Model3dTaskStatus}; use spacetime_client::{ EditorAssetCreateRecordInput, EditorProjectResourceCreateRecordInput, ExternalGenerationJobProviderCheckpointRecordInput, ExternalGenerationJobRecord, @@ -241,20 +239,25 @@ async fn persist_result( "message": format!("落库 3D 生成结果时:{error}。"), })) })?; - let (project_id, folder_id, label, canvas_completion) = match request.target() { - Model3dGenerationTarget::ProjectResource { - project_id, - canvas_completion, - } => ( - Some(project_id.clone()), - None, - MODEL3D_DEFAULT_ASSET_LABEL.to_string(), - canvas_completion.clone(), - ), - Model3dGenerationTarget::AssetLibrary { folder_id, label } => { - (None, Some(folder_id.clone()), label.clone(), None) - } - }; + // 落点与其它生成接口同形的平坦字段:校验层保证「项目资源 / 素材库恰好一个」。 + let target = request.target(); + let project_id = target + .project_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let folder_id = target + .asset_folder_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let label = target + .asset_label + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| MODEL3D_DEFAULT_ASSET_LABEL.to_string()); + let canvas_completion = target.canvas_completion.cloned(); let canvas_project_id = project_id.clone(); let resource_id = editor_generation_stable_resource_id( owner_user_id, @@ -269,6 +272,8 @@ 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 project_resource = project_id.map(|project_id| EditorProjectResourceCreateRecordInput { resource_id: resource_id.clone(), project_id, @@ -323,7 +328,11 @@ async fn persist_result( // 结果先构造:它引用的是对象元数据,与随后要搬进 item 的 asset_object 是两份值。 let result = build_result( request.job_kind(), - target_ref(request.target(), resource_id.clone(), asset_id_for_result), + target_ref( + project_id_for_result.is_some(), + resource_id.clone(), + asset_id_for_result, + ), &model, &preview, ); @@ -378,18 +387,16 @@ async fn persist_result( Ok(()) } +/// 结果里的落点引用:项目落点回资源 ID,素材库落点回素材 ID。 fn target_ref( - target: &Model3dGenerationTarget, + has_project: bool, resource_id: String, asset_id: String, ) -> Model3dGenerationTargetRef { - match target { - Model3dGenerationTarget::ProjectResource { .. } => { - Model3dGenerationTargetRef::ProjectResource { resource_id } - } - Model3dGenerationTarget::AssetLibrary { .. } => { - Model3dGenerationTargetRef::AssetLibrary { asset_id } - } + if has_project { + Model3dGenerationTargetRef::ProjectResource { resource_id } + } else { + Model3dGenerationTargetRef::AssetLibrary { asset_id } } } diff --git a/server-rs/crates/shared-contracts/src/model3d/common/generation_target.rs b/server-rs/crates/shared-contracts/src/model3d/common/generation_target.rs deleted file mode 100644 index 45e277436..000000000 --- a/server-rs/crates/shared-contracts/src/model3d/common/generation_target.rs +++ /dev/null @@ -1,29 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::MODEL3D_TS_EXPORT_DIR; -use crate::editor_canvas::EditorCanvasGenerationCompletionPayload; - -/// 生成结果的落点。两个分支都必须给出定位字段,不存在“都不给就默认落素材库”的口径。 -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] -#[derive(ts_rs::TS)] -#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] -pub enum Model3dGenerationTarget { - ProjectResource { - #[serde(rename = "projectId")] - project_id: String, - /// 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置。 - #[serde( - rename = "canvasCompletion", - default, - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - canvas_completion: Option, - }, - AssetLibrary { - #[serde(rename = "folderId")] - folder_id: String, - label: String, - }, -} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/mod.rs b/server-rs/crates/shared-contracts/src/model3d/common/mod.rs index 44411b1f5..3b018f65f 100644 --- a/server-rs/crates/shared-contracts/src/model3d/common/mod.rs +++ b/server-rs/crates/shared-contracts/src/model3d/common/mod.rs @@ -4,7 +4,6 @@ mod compression; mod export_orientation; mod generated_artifact; mod generation_source; -mod generation_target; mod generation_target_ref; mod geometry_quality; mod input_orientation; @@ -19,7 +18,6 @@ pub use compression::Model3dCompression; pub use export_orientation::Model3dExportOrientation; pub use generated_artifact::Model3dGeneratedArtifact; pub use generation_source::Model3dGenerationSource; -pub use generation_target::Model3dGenerationTarget; pub use generation_target_ref::Model3dGenerationTargetRef; pub use geometry_quality::Model3dGeometryQuality; pub use input_orientation::Model3dInputOrientation; 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 0ee11b0fa..b92372bf6 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 @@ -2,9 +2,13 @@ use serde::{Deserialize, Serialize}; use super::MODEL3D_TS_EXPORT_DIR; use super::params::Model3dImageToModelParams; -use crate::model3d::common::{Model3dGenerationSource, Model3dGenerationTarget}; +use crate::editor_canvas::EditorCanvasGenerationCompletionPayload; +use crate::model3d::common::Model3dGenerationSource; /// image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。 +/// +/// 结果落点与 text-to-model 同形:平坦的可选 `projectId` / `canvasCompletion` / +/// `assetFolderId` / `assetLabel`,二选一,不用 tagged enum。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[derive(ts_rs::TS)] @@ -12,5 +16,20 @@ use crate::model3d::common::{Model3dGenerationSource, Model3dGenerationTarget}; pub struct Model3dImageToModelRequest { pub source: Model3dGenerationSource, pub generation: Model3dImageToModelParams, - pub target: Model3dGenerationTarget, + /// 项目资源落点;与 `assetFolderId` 二选一。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub project_id: Option, + /// 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub canvas_completion: Option, + /// 素材库落点;与 `projectId` 二选一。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_folder_id: Option, + /// 素材名称;缺省用平台默认名。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_label: 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 fa4196b23..99c4c79b9 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 @@ -2,17 +2,36 @@ use serde::{Deserialize, Serialize}; use super::MODEL3D_TS_EXPORT_DIR; use super::params::Model3dTextToModelParams; -use crate::model3d::common::Model3dGenerationTarget; +use crate::editor_canvas::EditorCanvasGenerationCompletionPayload; /// text-to-model API 请求:provider 生成参数 + 平台字段。 /// /// 生成参数单独嵌一层而不是摊平到顶层:serde 的 `deny_unknown_fields` 与 `flatten` /// 不能共存,摊平会让顶层未知字段静默通过。 +/// +/// 结果落点与其它生成接口同形:平坦的可选 `projectId` / `canvasCompletion` / +/// `assetFolderId` / `assetLabel`,不用 tagged enum —— 客户端不必为「落项目还是落素材库」 +/// 多拼一层判别结构。服务端按「二选一」校验:两个都给或都不给都拒绝。 #[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, - pub target: Model3dGenerationTarget, + /// 项目资源落点;与 `assetFolderId` 二选一。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub project_id: Option, + /// 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub canvas_completion: Option, + /// 素材库落点;与 `projectId` 二选一。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_folder_id: Option, + /// 素材名称;缺省用平台默认名。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_label: 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 50aa7c121..a0b02ff08 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 @@ -1,41 +1,65 @@ use serde_json::json; use shared_contracts::model3d::Model3dGenerationResult; use shared_contracts::model3d::common::{ - Model3dGeneratedArtifact, Model3dGenerationSource, Model3dGenerationTarget, - Model3dGenerationTargetRef, + Model3dGeneratedArtifact, Model3dGenerationSource, Model3dGenerationTargetRef, }; use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest; use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; #[test] -fn text_to_model_request_accepts_generation_and_target() { +fn text_to_model_request_accepts_generation_and_flat_target() { let request: Model3dTextToModelRequest = serde_json::from_value(json!({ "generation": { "prompt": "一把木椅", "model": "v3.1-20260211", "texture": true }, - "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + "assetFolderId": "folder-1", + "assetLabel": "木椅" })) .expect("合法请求应可反序列化"); assert_eq!(request.generation.prompt, "一把木椅"); - assert!(matches!( - request.target, - Model3dGenerationTarget::AssetLibrary { .. } - )); + assert_eq!(request.asset_folder_id.as_deref(), Some("folder-1")); + assert_eq!(request.asset_label.as_deref(), Some("木椅")); + assert!(request.project_id.is_none()); + assert!(request.canvas_completion.is_none()); } +/// 落点是平坦可选字段:契约本身不强制「二选一」,也没给落点时必须照原样透传, +/// 由服务端在扣费前拒绝(见 api-server 的 `validate_target`)。 #[test] -fn target_requires_branch_local_fields() { - for invalid in [ - json!({ "kind": "assetLibrary", "label": "木椅" }), - json!({ "kind": "assetLibrary", "folderId": "folder-1" }), - json!({ "kind": "projectResource" }), - ] { - let error = serde_json::from_value::(invalid.clone()) - .expect_err("缺少分支必填字段应被拒绝"); - assert!( - error.to_string().contains("missing field"), - "缺少字段的报错应指出 missing field,输入 {invalid},实际为:{error}" - ); - } +fn target_fields_are_flat_and_optional() { + let project: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211" }, + "projectId": "project-1", + "canvasCompletion": { + "dialogId": "dialog-1", + "title": "木椅", + "placeholder": { + "x": 1, "y": 2, "width": 3, "height": 4, + "originalWidth": 3, "originalHeight": 4 + } + } + })) + .expect("项目落点应可反序列化"); + assert_eq!(project.project_id.as_deref(), Some("project-1")); + assert!(project.canvas_completion.is_some()); + assert!(project.asset_folder_id.is_none()); + + let without_target: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211" } + })) + .expect("缺少落点字段在契约层仍可反序列化"); + assert!(without_target.project_id.is_none()); + assert!(without_target.asset_folder_id.is_none()); + + // 落点是平坦字段:旧的 tagged enum 形状不再是合法输入。 + let error = serde_json::from_value::(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211" }, + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + })) + .expect_err("target 已不是请求字段,应被拒绝"); + assert!( + error.to_string().contains("unknown field"), + "旧 target 形状应报 unknown field,实际为:{error}" + ); } #[test] @@ -43,7 +67,7 @@ fn image_source_rejects_ambiguous_and_raw_input() { let both_ids = json!({ "source": { "kind": "resource", "resourceId": "r1", "assetId": "a1" }, "generation": { "model": "v3.1-20260211" }, - "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + "assetFolderId": "folder-1" }); let error = serde_json::from_value::(both_ids) .expect_err("同时给出资源与素材 ID 应被拒绝"); @@ -55,12 +79,12 @@ fn image_source_rejects_ambiguous_and_raw_input() { let raw_input = json!({ "source": "https://example.com/a.png", "generation": { "model": "v3.1-20260211" }, - "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + "assetFolderId": "folder-1" }); let error = serde_json::from_value::(raw_input) .expect_err("裸字符串图片输入应被拒绝"); // 拒绝必须发生在 source 本身:错误里点名 Model3dGenerationSource,避免将来 - // generation / target 的必填变化把这条负例“顶替”成通过。 + // generation / 落点字段的变化把这条负例“顶替”成通过。 assert!( error.to_string().contains("Model3dGenerationSource"), "裸字符串 source 的报错应点名 Model3dGenerationSource,实际为:{error}" @@ -75,7 +99,7 @@ fn image_source_rejects_ambiguous_and_raw_input() { fn api_request_rejects_unknown_top_level_fields() { let error = serde_json::from_value::(json!({ "generation": { "prompt": "一把木椅", "model": "v3.1-20260211" }, - "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" }, + "assetFolderId": "folder-1", "unexpected": true })) .expect_err("顶层未知字段应被拒绝");