新增Tripo生成请求的平台组合校验

- 价格相关参数必须显式给出,texture 与 textureQuality、pbr 不能互相矛盾
- 目标落点与图片引用的 ID 不能为空白
- provider 能力组合规则复用 platform-tripo 预检,不维护第二份
- 校验结果直接产出定价输入,路由在扣费与 provider 调用前执行
- api-server 依赖 platform-tripo
This commit is contained in:
2026-09-21 12:53:22 +08:00
parent 8f6458ffe2
commit e105e34f83
4 changed files with 389 additions and 0 deletions
+1
View File
@@ -228,6 +228,7 @@ dependencies = [
"platform-matting",
"platform-oss",
"platform-speech",
"platform-tripo",
"platform-wechat",
"png",
"regex",
+1
View File
@@ -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 }
@@ -8,3 +8,5 @@
// 接入后应删除这层豁免,让未使用项重新暴露出来。
#[allow(dead_code)]
pub(crate) mod pricing;
#[allow(dead_code)]
pub(crate) mod validation;
@@ -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<String> {
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<Model3dPricingQuery, Model3dRequestError> {
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<Model3dPricingQuery, Model3dRequestError> {
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<bool>,
texture_quality: Option<Model3dTextureQuality>,
pbr: Option<bool>,
geometry_quality: Option<Model3dGeometryQuality>,
quad: Option<bool>,
smart_low_poly: Option<bool>,
generate_parts: Option<bool>,
}
impl PricingParamView {
fn validate(
self,
endpoint: Model3dEndpoint,
model_version: Model3dModelVersion,
) -> Result<Model3dPricingQuery, Model3dRequestError> {
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(()),
}
}