From e105e34f83c352d3f5f216750eeee6a38a2cc59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 12:53:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9ETripo=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E7=9A=84=E5=B9=B3=E5=8F=B0=E7=BB=84=E5=90=88?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=20-=20=E4=BB=B7=E6=A0=BC=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=BF=85=E9=A1=BB=E6=98=BE=E5=BC=8F=E7=BB=99?= =?UTF-8?q?=E5=87=BA=EF=BC=8Ctexture=20=E4=B8=8E=20textureQuality=E3=80=81?= =?UTF-8?q?pbr=20=E4=B8=8D=E8=83=BD=E4=BA=92=E7=9B=B8=E7=9F=9B=E7=9B=BE=20?= =?UTF-8?q?-=20=E7=9B=AE=E6=A0=87=E8=90=BD=E7=82=B9=E4=B8=8E=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=BC=95=E7=94=A8=E7=9A=84=20ID=20=E4=B8=8D=E8=83=BD?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA=E7=99=BD=20-=20provider=20=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E7=BB=84=E5=90=88=E8=A7=84=E5=88=99=E5=A4=8D=E7=94=A8=20platfo?= =?UTF-8?q?rm-tripo=20=E9=A2=84=E6=A3=80=EF=BC=8C=E4=B8=8D=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E7=AC=AC=E4=BA=8C=E4=BB=BD=20-=20=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E7=9B=B4=E6=8E=A5=E4=BA=A7=E5=87=BA=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E8=BE=93=E5=85=A5=EF=BC=8C=E8=B7=AF=E7=94=B1=E5=9C=A8?= =?UTF-8?q?=E6=89=A3=E8=B4=B9=E4=B8=8E=20provider=20=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E5=89=8D=E6=89=A7=E8=A1=8C=20-=20api-server=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=20platform-tripo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-rs/Cargo.lock | 1 + server-rs/crates/api-server/Cargo.toml | 1 + .../crates/api-server/src/tripo3d/mod.rs | 2 + .../api-server/src/tripo3d/validation.rs | 385 ++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 server-rs/crates/api-server/src/tripo3d/validation.rs diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index b09dbb6f8..12885c91a 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -228,6 +228,7 @@ dependencies = [ "platform-matting", "platform-oss", "platform-speech", + "platform-tripo", "platform-wechat", "png", "regex", diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 415afd5b9..578762e1a 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -33,6 +33,7 @@ platform-llm = { workspace = true } platform-matting = { workspace = true } platform-oss = { workspace = true } platform-speech = { workspace = true } +platform-tripo = { workspace = true } platform-wechat = { workspace = true } hmac = { workspace = true } ring = { workspace = true } diff --git a/server-rs/crates/api-server/src/tripo3d/mod.rs b/server-rs/crates/api-server/src/tripo3d/mod.rs index 3c99b9eb1..ea3c9d05e 100644 --- a/server-rs/crates/api-server/src/tripo3d/mod.rs +++ b/server-rs/crates/api-server/src/tripo3d/mod.rs @@ -8,3 +8,5 @@ // 接入后应删除这层豁免,让未使用项重新暴露出来。 #[allow(dead_code)] pub(crate) mod pricing; +#[allow(dead_code)] +pub(crate) mod validation; diff --git a/server-rs/crates/api-server/src/tripo3d/validation.rs b/server-rs/crates/api-server/src/tripo3d/validation.rs new file mode 100644 index 000000000..0a78a3ae4 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/validation.rs @@ -0,0 +1,385 @@ +//! Tripo 生成请求的平台侧组合校验。 +//! +//! 这里只判两类事: +//! 1. 平台自己新增的口径——价格相关参数必须显式给出,贴图、贴图档位与 pbr 不能互相矛盾; +//! 2. 模型档位与参数的能力组合——直接复用 `platform-tripo` 的请求预检,不维护第二份规则。 +//! +//! 校验必须在扣费与任何 provider 调用之前完成;拿不到合法参数与价格就不提交、不入队。 + +use platform_tripo::{TripoError, validate_image_to_model_params, validate_text_to_model_params}; +use shared_contracts::model3d::common::{ + Model3dGenerationSource, Model3dGenerationTarget, Model3dGeometryQuality, Model3dModelVersion, + Model3dTextureQuality, +}; +use shared_contracts::model3d::image_to_model::{ + Model3dImageToModelParams, Model3dImageToModelRequest, +}; +use shared_contracts::model3d::text_to_model::{ + Model3dTextToModelParams, Model3dTextToModelRequest, +}; + +use super::pricing::{Model3dEndpoint, Model3dPricingQuery, model3d_add_ons}; + +/// 请求被拒绝的原因。`InvalidRequest` 的字段名用 API 请求里的 JSON 字段路径。 +#[derive(Debug)] +pub(crate) enum Model3dRequestError { + InvalidRequest { + field: &'static str, + message: String, + }, + /// provider 参数预检失败;模型档位与参数组合规则由 `platform-tripo` 独家维护。 + Provider(TripoError), +} + +impl Model3dRequestError { + fn invalid(field: &'static str, message: &str) -> Self { + Self::InvalidRequest { + field, + message: message.to_owned(), + } + } + + /// 出错字段名:平台规则直接给字段路径,provider 规则用 provider 的字段名。 + pub(crate) fn field(&self) -> Option { + match self { + Self::InvalidRequest { field, .. } => Some((*field).to_owned()), + Self::Provider(TripoError::InvalidParameters { field, .. }) => { + field.map(|field| field.to_string()) + } + Self::Provider(_) => None, + } + } +} + +impl std::fmt::Display for Model3dRequestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidRequest { field, message } => write!(f, "{field}: {message}"), + Self::Provider(error) => error.fmt(f), + } + } +} + +impl std::error::Error for Model3dRequestError {} + +/// text-to-model 请求校验;返回可直接用于查价的定价输入。 +pub(crate) fn validate_text_to_model_request( + request: &Model3dTextToModelRequest, +) -> Result { + let query = PricingParamView::from(&request.generation) + .validate(Model3dEndpoint::TextToModel, request.generation.model)?; + validate_target(&request.target)?; + validate_text_to_model_params(&request.generation).map_err(Model3dRequestError::Provider)?; + Ok(query) +} + +/// image-to-model 请求校验;图片归属与对象解析属于落库前的准备步骤,不在这里做。 +pub(crate) fn validate_image_to_model_request( + request: &Model3dImageToModelRequest, +) -> Result { + validate_source(&request.source)?; + let query = PricingParamView::from(&request.generation) + .validate(Model3dEndpoint::ImageToModel, request.generation.model)?; + validate_target(&request.target)?; + validate_image_to_model_params(&request.generation).map_err(Model3dRequestError::Provider)?; + Ok(query) +} + +/// 两个端点的价格相关参数视图;字段名与契约请求保持一致,便于给出定位到字段的报错。 +#[derive(Clone, Copy)] +struct PricingParamView { + texture: Option, + texture_quality: Option, + pbr: Option, + geometry_quality: Option, + quad: Option, + smart_low_poly: Option, + generate_parts: Option, +} + +impl PricingParamView { + fn validate( + self, + endpoint: Model3dEndpoint, + model_version: Model3dModelVersion, + ) -> Result { + let texture = self.texture.ok_or_else(|| { + Model3dRequestError::invalid("generation.texture", "必须显式给出是否生成贴图") + })?; + let geometry_quality = self.geometry_quality.ok_or_else(|| { + Model3dRequestError::invalid("generation.geometryQuality", "必须显式给出几何质量") + })?; + let quad = self.quad.ok_or_else(|| { + Model3dRequestError::invalid("generation.quad", "必须显式给出是否quad网格") + })?; + let smart_low_poly = self.smart_low_poly.ok_or_else(|| { + Model3dRequestError::invalid("generation.smartLowPoly", "必须显式给出是否智能低模") + })?; + let generate_parts = self.generate_parts.ok_or_else(|| { + Model3dRequestError::invalid("generation.generateParts", "必须显式给出是否生成分件") + })?; + + let texture_quality = match (texture, self.texture_quality) { + (true, Some(quality)) => quality, + (true, None) => { + return Err(Model3dRequestError::invalid( + "generation.textureQuality", + "texture=true 时必须显式给出贴图档位", + )); + } + (false, Some(_)) => { + return Err(Model3dRequestError::invalid( + "generation.textureQuality", + "texture=false 时不允许出现贴图档位", + )); + } + // 不生成贴图时贴图档位无意义,add-on 判定又被 texture 挡住,因此取标准档占位。 + (false, None) => Model3dTextureQuality::Standard, + }; + if !texture && self.pbr != Some(false) { + return Err(Model3dRequestError::invalid( + "generation.pbr", + "texture=false 时 pbr 必须显式给 false", + )); + } + + Ok(Model3dPricingQuery { + endpoint, + model_version, + texture, + add_ons: model3d_add_ons( + texture, + texture_quality, + geometry_quality, + quad, + smart_low_poly, + generate_parts, + ), + }) + } +} + +impl From<&Model3dTextToModelParams> for PricingParamView { + fn from(params: &Model3dTextToModelParams) -> Self { + Self { + texture: params.texture, + texture_quality: params.texture_quality, + pbr: params.pbr, + geometry_quality: params.geometry_quality, + quad: params.quad, + smart_low_poly: params.smart_low_poly, + generate_parts: params.generate_parts, + } + } +} + +impl From<&Model3dImageToModelParams> for PricingParamView { + fn from(params: &Model3dImageToModelParams) -> Self { + Self { + texture: params.texture, + texture_quality: params.texture_quality, + pbr: params.pbr, + geometry_quality: params.geometry_quality, + quad: params.quad, + smart_low_poly: params.smart_low_poly, + generate_parts: params.generate_parts, + } + } +} + +fn validate_source(source: &Model3dGenerationSource) -> Result<(), Model3dRequestError> { + match source { + Model3dGenerationSource::Resource { resource_id } if resource_id.trim().is_empty() => Err( + Model3dRequestError::invalid("source.resourceId", "画布资源 ID 不能为空"), + ), + Model3dGenerationSource::Asset { asset_id } if asset_id.trim().is_empty() => Err( + Model3dRequestError::invalid("source.assetId", "素材 ID 不能为空"), + ), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::*; + use crate::tripo3d::pricing::Model3dAddOn; + + /// 基准 generation:所有价格相关参数都显式给出。 + fn generation_with(overrides: Value) -> Value { + let mut params = json!({ + "prompt": "一把木椅", + "model": "v3.1-20260211", + "texture": true, + "textureQuality": "standard", + "pbr": true, + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }); + let params = params.as_object_mut().expect("基准参数是对象"); + for (key, value) in overrides.as_object().expect("覆盖值必须是对象") { + if value.is_null() { + params.remove(key); + } else { + params.insert(key.clone(), value.clone()); + } + } + json!(params) + } + + fn text_request(generation: Value) -> Model3dTextToModelRequest { + serde_json::from_value(json!({ + "generation": generation, + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + })) + .expect("测试请求应可反序列化") + } + + /// image-to-model 的生成参数没有 prompt,基准值去掉该字段。 + fn image_generation_with(overrides: Value) -> Value { + let mut params = generation_with(overrides); + params + .as_object_mut() + .expect("基准参数是对象") + .remove("prompt"); + params + } + + #[test] + fn pricing_params_must_be_explicit() { + for field in [ + "generation.texture", + "generation.geometryQuality", + "generation.quad", + "generation.smartLowPoly", + "generation.generateParts", + ] { + let name = field.rsplit('.').next().expect("字段路径含字段名"); + let request = text_request(generation_with(json!({ name: null }))); + let error = validate_text_to_model_request(&request).expect_err("缺少定价参数应被拒绝"); + assert_eq!(error.field().as_deref(), Some(field), "实际报错:{error}"); + } + } + + #[test] + fn texture_off_rejects_texture_quality_and_requires_explicit_non_pbr() { + let request = text_request(generation_with(json!({ "texture": false }))); + let error = + validate_text_to_model_request(&request).expect_err("texture=false 不允许出现贴图档位"); + assert_eq!( + error.field().as_deref(), + Some("generation.textureQuality"), + "实际报错:{error}" + ); + + let request = text_request(generation_with(json!({ + "texture": false, + "textureQuality": null + }))); + let error = validate_text_to_model_request(&request) + .expect_err("texture=false 且 pbr 未显式 false 应被拒绝"); + assert_eq!( + error.field().as_deref(), + Some("generation.pbr"), + "实际报错:{error}" + ); + } + + #[test] + fn texture_on_requires_texture_quality() { + let request = text_request(generation_with(json!({ "textureQuality": null }))); + let error = + validate_text_to_model_request(&request).expect_err("texture=true 必须有贴图档位"); + assert_eq!( + error.field().as_deref(), + Some("generation.textureQuality"), + "实际报错:{error}" + ); + } + + #[test] + fn provider_capability_rules_are_delegated_not_duplicated() { + // generate_parts 与 texture=true 互斥由 platform-tripo 判定,这里只验证它确实生效。 + let request = text_request(generation_with(json!({ "generateParts": true }))); + let error = validate_text_to_model_request(&request) + .expect_err("generateParts=true 与贴图同现应被 provider 预检拒绝"); + assert!( + matches!(error, Model3dRequestError::Provider(_)), + "应来自 provider 预检,实际为:{error}" + ); + assert_eq!( + error.field().as_deref(), + Some("generate_parts"), + "实际报错:{error}" + ); + } + + #[test] + fn valid_request_maps_to_pricing_query() { + let request = text_request(generation_with(json!({ + "textureQuality": "detailed", + "geometryQuality": "detailed" + }))); + let query = validate_text_to_model_request(&request).expect("合法请求应通过校验"); + + assert_eq!(query.endpoint, Model3dEndpoint::TextToModel); + assert_eq!(query.model_version, Model3dModelVersion::H31); + assert!(query.texture); + assert!(query.add_ons.contains(Model3dAddOn::HdTexture)); + assert!(query.add_ons.contains(Model3dAddOn::HdGeometry)); + assert!(!query.add_ons.contains(Model3dAddOn::QuadMesh)); + } + + #[test] + fn image_request_rejects_blank_source_and_target_ids() { + let request: Model3dImageToModelRequest = serde_json::from_value(json!({ + "source": { "kind": "asset", "assetId": " " }, + "generation": image_generation_with(json!({})), + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + })) + .expect("测试请求应可反序列化"); + let error = validate_image_to_model_request(&request).expect_err("空白素材 ID 应被拒绝"); + assert_eq!(error.field().as_deref(), Some("source.assetId")); + + let request: Model3dImageToModelRequest = serde_json::from_value(json!({ + "source": { "kind": "resource", "resourceId": "resource-1" }, + "generation": image_generation_with(json!({})), + "target": { "kind": "assetLibrary", "folderId": " ", "label": "木椅" } + })) + .expect("测试请求应可反序列化"); + let error = validate_image_to_model_request(&request).expect_err("空白目录 ID 应被拒绝"); + assert_eq!(error.field().as_deref(), Some("target.folderId")); + } +} + +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() { + return Err(Model3dRequestError::invalid( + "target.folderId", + "素材库目录 ID 不能为空", + )); + } + if label.trim().is_empty() { + return Err(Model3dRequestError::invalid( + "target.label", + "素材名称不能为空", + )); + } + Ok(()) + } + _ => Ok(()), + } +}