新增Tripo生成请求的平台组合校验
- 价格相关参数必须显式给出,texture 与 textureQuality、pbr 不能互相矛盾 - 目标落点与图片引用的 ID 不能为空白 - provider 能力组合规则复用 platform-tripo 预检,不维护第二份 - 校验结果直接产出定价输入,路由在扣费与 provider 调用前执行 - api-server 依赖 platform-tripo
This commit is contained in:
Generated
+1
@@ -228,6 +228,7 @@ dependencies = [
|
||||
"platform-matting",
|
||||
"platform-oss",
|
||||
"platform-speech",
|
||||
"platform-tripo",
|
||||
"platform-wechat",
|
||||
"png",
|
||||
"regex",
|
||||
|
||||
@@ -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(()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user