From ee2d505eb3cb06507bf8005c08d7c3ae50e218d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 15:21:25 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E6=8F=90=E4=BA=A4=E5=9C=A8=E6=89=A3?= =?UTF-8?q?=E8=B4=B9=E5=89=8D=E9=A2=84=E6=A3=80=E8=90=BD=E7=82=B9=E5=BD=92?= =?UTF-8?q?=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 target 预检模块:画布项目按 (project_id, owner) 定点确认,素材库文件夹按 owner 的库匹配 - 两个提交路由都在查价与入队之前调用;跨 owner / 不存在的落点回 400 model3d-target-unavailable - 只剩基础设施故障保留原状态码,并补了收敛口径的单测 --- .../crates/api-server/src/tripo3d/mod.rs | 1 + .../crates/api-server/src/tripo3d/routes.rs | 12 +- .../crates/api-server/src/tripo3d/target.rs | 107 ++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 server-rs/crates/api-server/src/tripo3d/target.rs diff --git a/server-rs/crates/api-server/src/tripo3d/mod.rs b/server-rs/crates/api-server/src/tripo3d/mod.rs index 34bd988c7..a5e0f1c7b 100644 --- a/server-rs/crates/api-server/src/tripo3d/mod.rs +++ b/server-rs/crates/api-server/src/tripo3d/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod queue; pub(crate) mod result; pub(crate) mod routes; pub(crate) mod storage; +pub(crate) mod target; pub(crate) mod validation; pub(crate) mod worker; diff --git a/server-rs/crates/api-server/src/tripo3d/routes.rs b/server-rs/crates/api-server/src/tripo3d/routes.rs index 9d7eac162..ea59c56a1 100644 --- a/server-rs/crates/api-server/src/tripo3d/routes.rs +++ b/server-rs/crates/api-server/src/tripo3d/routes.rs @@ -1,6 +1,6 @@ //! Tripo 生成的内部提交入口。 //! -//! 执行顺序固定:解析 JSON → 平台组合校验(含 provider 预检)→ 查价 → 入队。 +//! 执行顺序固定:解析 JSON → 平台组合校验 → 输入预检 → 落点预检 → 查价 → 入队。 //! 任何一步失败都发生在扣费与 provider 调用之前;状态读取复用既有任务查询接口。 use axum::{ @@ -27,6 +27,7 @@ use super::errors::{map_pricing_error, map_pricing_store_error, map_request_erro use super::image_source::preflight_image_source; use super::job::Model3dJobKind; 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}; pub(crate) const TEXT_TO_MODEL_ROUTE: &str = "/api/assets/tripo/text-to-model"; @@ -62,6 +63,11 @@ pub(crate) async fn submit_text_to_model( let query = validate_text_to_model_request(&payload) .map_err(map_request_error) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + // 落点预检与参数校验一样前置于查价:跨 owner / 不存在的落点必须在这里就被拒, + // 不能等 worker 落库时才发现,那时 provider 预算已经花掉了。 + preflight_generation_target(&state, authenticated.claims().user_id(), &payload.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 job = enqueue_model3d_job( @@ -95,6 +101,10 @@ pub(crate) async fn submit_image_to_model( preflight_image_source(&state, authenticated.claims().user_id(), &payload.source) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + // 落点与图片输入一样,必须在查价、扣费与入队之前确认。 + preflight_generation_target(&state, authenticated.claims().user_id(), &payload.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 job = enqueue_model3d_job( diff --git a/server-rs/crates/api-server/src/tripo3d/target.rs b/server-rs/crates/api-server/src/tripo3d/target.rs new file mode 100644 index 000000000..1a2bfb300 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/target.rs @@ -0,0 +1,107 @@ +//! 3D 提交的落点预检。 +//! +//! 与 `image_source` 的预检对称:在查价、扣费与入队之前,先按主键确认落点 +//! (画布项目 / 素材库文件夹)属于当前 owner 且仍然存在。否则一次注定落库失败的提交 +//! 会先成功入队、扣费并花掉 provider 预算,最后才在 worker 落库阶段失败。 +//! +//! 跨 owner、已删除与不存在收敛成同一句 400,避免把「别人是否存在某个 ID」变成可探测信息; +//! 只有基础设施故障保留原状态码。worker 落库时会再确认同一事实。 + +use axum::http::StatusCode; +use serde_json::json; +use shared_contracts::model3d::common::Model3dGenerationTarget; +use spacetime_client::EditorProjectGetRecordInput; + +use crate::{ + editor_project::{current_utc_micros, map_editor_project_error}, + http_error::AppError, + state::AppState, +}; + +use super::provider::TRIPO_PROVIDER; + +/// 提交时的落点预检:只读元数据,不写记录,也不产生 provider 副作用。 +pub(crate) async fn preflight_generation_target( + state: &AppState, + owner_user_id: &str, + target: &Model3dGenerationTarget, +) -> 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()) + } + } + } +} + +/// 未登记 / 跨 owner / 已删除都收敛成同一句 400;基础设施故障保留原状态码。 +fn collapse_target_unavailable(error: AppError) -> AppError { + if error.status_code().is_client_error() { + target_unavailable() + } else { + error + } +} + +fn target_unavailable() -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-target-unavailable", + "field": "target", + "message": "生成结果的落点必须是当前账号已存在的画布项目或素材库文件夹。", + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_unavailable_is_stable_and_machine_readable() { + let error = target_unavailable(); + 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")); + } + + /// 跨 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 preserved = + collapse_target_unavailable(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)); + assert_eq!(preserved.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + assert_ne!(preserved.details(), target_unavailable().details()); + } +}