契约枚举的线上取值只由serde定义

- shared-contracts 新增 wire_str 与 wire_str_lossy:线上取值只从 serde rename / rename_all 取,不再维护第二份字面量
- 删除模型版本、纹理质量、纹理版本、纹理对齐、几何质量、输入方向、导出方向、压缩共 8 个枚举上手工维护的 as_str
- platform-tripo 新增 common/wire.rs 负责契约枚举到 provider 请求字符串的转换,三个入口的 to_sdk_params 改走它并把失败归一成 provider 错误
- api-server 的定价文案与 job 模型版本取值改用 serde 取值,job 的 model_version 由 &'static str 改为 String
- 取值来源只剩 serde 一处,改 rename 不会再静默分叉
This commit is contained in:
2026-09-21 19:46:43 +08:00
parent c0d6531e92
commit 3af0d3d90b
20 changed files with 122 additions and 187 deletions
+1
View File
@@ -4162,6 +4162,7 @@ version = "0.1.0"
dependencies = [
"bytes",
"reqwest",
"serde",
"serde_json",
"shared-contracts",
"tokio",
@@ -78,11 +78,13 @@ impl Model3dJobRequest {
}
}
pub(crate) fn model_version(&self) -> &'static str {
match self {
Self::TextToModel(request) => request.generation.model.as_str(),
Self::ImageToModel(request) => request.generation.model.as_str(),
}
/// 审计与落库用的模型版本线上取值;取值只由契约枚举的 serde rename 定义。
pub(crate) fn model_version(&self) -> String {
let model = match self {
Self::TextToModel(request) => request.generation.model,
Self::ImageToModel(request) => request.generation.model,
};
shared_contracts::model3d::wire_str_lossy(&model)
}
}
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use shared_contracts::model3d::common::{
Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality,
};
use shared_contracts::model3d::wire_str_lossy;
/// 生成端点。批量价键与请求入口一一对应,不做 provider 侧的变形。
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
@@ -158,7 +159,7 @@ impl std::fmt::Display for Model3dPricingError {
f,
"Tripo 3D 定价缺少底价:endpoint={} model={}",
endpoint.as_str(),
model_version.as_str()
wire_str_lossy(model_version)
),
Self::MissingAddOnPrice(add_on) => {
write!(f, "Tripo 3D 定价缺少 add-on{add_on:?}")
@@ -182,7 +183,7 @@ impl Model3dPricingConfig {
return Err(format!(
"缺少 endpoint {} 模型 {} 的底价",
endpoint.as_str(),
model_version.as_str()
wire_str_lossy(&model_version)
));
}
}
@@ -191,7 +192,7 @@ impl Model3dPricingConfig {
return Err(format!(
"endpoint {} 出现了契约不支持的模型版本 {}",
endpoint.as_str(),
model_version.as_str()
wire_str_lossy(model_version)
));
}
}
@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
shared-contracts = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tripo3d-sdk = { workspace = true }
bytes = { workspace = true }
@@ -9,23 +9,23 @@ use std::collections::HashMap;
use serde_json::Value;
use shared_contracts::model3d::common::Model3dTextureVersion;
use super::{TripoError, wire};
const EXTRA_TEXTURE_VERSION: &str = "texture_version";
const EXTRA_DELIGHT: &str = "delight";
/// 按 provider 需要的键名构造 `extra`;未提供的参数不写入,由 provider 取默认值
/// 按 provider 需要的键名构造 `extra`;未提供的参数不写入,由 provider 取默认值
/// `texture_version` 的取值仍由契约枚举的 serde rename 定义。
pub(crate) fn extra_fields(
texture_version: Option<Model3dTextureVersion>,
delight: Option<bool>,
) -> HashMap<String, Value> {
) -> Result<HashMap<String, Value>, TripoError> {
let mut extra = HashMap::new();
if let Some(value) = texture_version {
extra.insert(
EXTRA_TEXTURE_VERSION.into(),
Value::String(value.as_str().into()),
);
extra.insert(EXTRA_TEXTURE_VERSION.into(), Value::String(wire(&value)?));
}
if let Some(value) = delight {
extra.insert(EXTRA_DELIGHT.into(), Value::Bool(value));
}
extra
Ok(extra)
}
@@ -5,6 +5,7 @@ mod extra;
mod mapping;
mod types;
mod validation;
mod wire;
pub use client::TripoProviderClient;
pub use config::TripoSettings;
@@ -18,3 +19,4 @@ pub use types::{
pub(crate) use validation::{
TripoGenerationOptions, validate_generation_options, validate_task_id,
};
pub(crate) use wire::{wire, wire_option};
@@ -0,0 +1,25 @@
//! 契约枚举 → provider 请求里要发的线上取值。
//!
//! 取值只由 `shared-contracts` 里的 serde `rename` / `rename_all` 定义:provider 侧
//! 不再维护第二份字面量,两处各写一份、改一处就静默分叉的问题不会再出现。
use serde::Serialize;
use super::TripoError;
/// 契约枚举 → provider 线上取值。
///
/// 契约枚举都是「序列化成字符串」的形态;真拿到非字符串说明契约被改坏,这里归一成
/// provider 内部错误,而不是 panic 或静默兜底。
pub(crate) fn wire<T: Serialize + ?Sized>(value: &T) -> Result<String, TripoError> {
shared_contracts::model3d::wire_str(value).map_err(|error| TripoError::Sdk {
message: format!("契约枚举未按字符串序列化:{error}"),
})
}
/// [`wire`] 的 `Option` 形态:缺省仍是缺省。
pub(crate) fn wire_option<T: Serialize + ?Sized>(
value: Option<&T>,
) -> Result<Option<String>, TripoError> {
value.map(wire).transpose()
}
@@ -1,13 +1,9 @@
use shared_contracts::model3d::common::{
Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dInputOrientation,
Model3dTextureAlignment, Model3dTextureQuality,
};
use shared_contracts::model3d::image_to_model::Model3dImageToModelParams;
use tripo3d_sdk::{models::FileInput, params::ImageToModelParams};
use crate::common::{
TripoError, TripoField, TripoProviderClient, TripoTaskHandle, TripoValidationReason,
extra_fields,
extra_fields, wire, wire_option,
};
use super::validation::validate_image_to_model_params;
@@ -101,53 +97,38 @@ impl TripoProviderClient {
let task_id = self
.client
.image_to_model(to_sdk_params(input, params))
.image_to_model(to_sdk_params(input, params)?)
.await
.map_err(TripoError::from)?;
Ok(TripoTaskHandle { task_id })
}
}
fn to_sdk_params(input: FileInput, params: &Model3dImageToModelParams) -> ImageToModelParams {
ImageToModelParams {
fn to_sdk_params(
input: FileInput,
params: &Model3dImageToModelParams,
) -> Result<ImageToModelParams, TripoError> {
Ok(ImageToModelParams {
input,
model: Some(params.model.as_str().to_owned()),
model: Some(wire(&params.model)?),
enable_image_autofix: params.enable_image_autofix,
model_seed: params.model_seed,
texture_seed: params.texture_seed,
texture: params.texture,
pbr: params.pbr,
texture_quality: params
.texture_quality
.map(Model3dTextureQuality::as_str)
.map(str::to_owned),
extra: extra_fields(params.texture_version, params.delight),
texture_alignment: params
.texture_alignment
.map(Model3dTextureAlignment::as_str)
.map(str::to_owned),
geometry_quality: params
.geometry_quality
.map(Model3dGeometryQuality::as_str)
.map(str::to_owned),
texture_quality: wire_option(params.texture_quality.as_ref())?,
extra: extra_fields(params.texture_version, params.delight)?,
texture_alignment: wire_option(params.texture_alignment.as_ref())?,
geometry_quality: wire_option(params.geometry_quality.as_ref())?,
face_limit: params.face_limit,
auto_size: params.auto_size,
orientation: params
.orientation
.map(Model3dInputOrientation::as_str)
.map(str::to_owned),
orientation: wire_option(params.orientation.as_ref())?,
quad: params.quad,
smart_low_poly: params.smart_low_poly,
generate_parts: params.generate_parts,
compress: params
.compress
.map(Model3dCompression::as_str)
.map(str::to_owned),
compress: wire_option(params.compress.as_ref())?,
export_uv: params.export_uv,
export_orientation: params
.export_orientation
.map(Model3dExportOrientation::as_str)
.map(str::to_owned),
export_orientation: wire_option(params.export_orientation.as_ref())?,
style: None,
}
})
}
@@ -1,7 +1,3 @@
use shared_contracts::model3d::common::{
Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dInputOrientation,
Model3dTextureAlignment, Model3dTextureQuality,
};
use shared_contracts::model3d::multiview_to_model::{
Model3dMultiviewInputs, Model3dMultiviewToModelRequest,
};
@@ -9,7 +5,8 @@ use tripo3d_sdk::{models::FileInput, params::MultiviewToModelParams};
use crate::common::{
TripoError, TripoField, TripoGenerationOptions, TripoProviderClient, TripoTaskHandle,
TripoValidationReason, extra_fields, validate_generation_options, validate_task_id,
TripoValidationReason, extra_fields, validate_generation_options, validate_task_id, wire,
wire_option,
};
impl TripoProviderClient {
@@ -66,7 +63,7 @@ impl TripoProviderClient {
})?;
let task_id = self
.client
.multiview_to_model(to_sdk_params(request))
.multiview_to_model(to_sdk_params(request)?)
.await
.map_err(TripoError::from)?;
Ok(TripoTaskHandle { task_id })
@@ -80,7 +77,9 @@ fn optional_view(value: &Option<String>) -> Option<FileInput> {
.map(|value| FileInput::from(value.trim()))
}
fn to_sdk_params(request: &Model3dMultiviewToModelRequest) -> MultiviewToModelParams {
fn to_sdk_params(
request: &Model3dMultiviewToModelRequest,
) -> Result<MultiviewToModelParams, TripoError> {
let mut params = match &request.inputs {
Model3dMultiviewInputs::Views {
front,
@@ -97,41 +96,23 @@ fn to_sdk_params(request: &Model3dMultiviewToModelRequest) -> MultiviewToModelPa
MultiviewToModelParams::from_task_id(task_id.trim().to_owned())
}
};
params.model = Some(request.model.as_str().to_owned());
params.model = Some(wire(&request.model)?);
params.model_seed = request.model_seed;
params.texture_seed = request.texture_seed;
params.texture = request.texture;
params.pbr = request.pbr;
params.texture_quality = request
.texture_quality
.map(Model3dTextureQuality::as_str)
.map(str::to_owned);
params.extra = extra_fields(request.texture_version, request.delight);
params.geometry_quality = request
.geometry_quality
.map(Model3dGeometryQuality::as_str)
.map(str::to_owned);
params.texture_alignment = request
.texture_alignment
.map(Model3dTextureAlignment::as_str)
.map(str::to_owned);
params.texture_quality = wire_option(request.texture_quality.as_ref())?;
params.extra = extra_fields(request.texture_version, request.delight)?;
params.geometry_quality = wire_option(request.geometry_quality.as_ref())?;
params.texture_alignment = wire_option(request.texture_alignment.as_ref())?;
params.face_limit = request.face_limit;
params.auto_size = request.auto_size;
params.orientation = request
.orientation
.map(Model3dInputOrientation::as_str)
.map(str::to_owned);
params.orientation = wire_option(request.orientation.as_ref())?;
params.quad = request.quad;
params.smart_low_poly = request.smart_low_poly;
params.generate_parts = request.generate_parts;
params.compress = request
.compress
.map(Model3dCompression::as_str)
.map(str::to_owned);
params.compress = wire_option(request.compress.as_ref())?;
params.export_uv = request.export_uv;
params.export_orientation = request
.export_orientation
.map(Model3dExportOrientation::as_str)
.map(str::to_owned);
params
params.export_orientation = wire_option(request.export_orientation.as_ref())?;
Ok(params)
}
@@ -1,6 +1,5 @@
use crate::common::{TripoError, TripoProviderClient, TripoTaskHandle, extra_fields};
use shared_contracts::model3d::common::{
Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dTextureQuality,
use crate::common::{
TripoError, TripoProviderClient, TripoTaskHandle, extra_fields, wire, wire_option,
};
use shared_contracts::model3d::text_to_model::Model3dTextToModelParams;
@@ -14,46 +13,36 @@ impl TripoProviderClient {
validate_text_to_model_params(params)?;
let task_id = self
.client
.text_to_model(to_sdk_params(params))
.text_to_model(to_sdk_params(params)?)
.await
.map_err(TripoError::from)?;
Ok(TripoTaskHandle { task_id })
}
}
fn to_sdk_params(params: &Model3dTextToModelParams) -> tripo3d_sdk::params::TextToModelParams {
tripo3d_sdk::params::TextToModelParams {
fn to_sdk_params(
params: &Model3dTextToModelParams,
) -> Result<tripo3d_sdk::params::TextToModelParams, TripoError> {
Ok(tripo3d_sdk::params::TextToModelParams {
prompt: params.prompt.clone(),
model: Some(params.model.as_str().to_owned()),
model: Some(wire(&params.model)?),
negative_prompt: params.negative_prompt.clone(),
image_seed: params.image_seed,
model_seed: params.model_seed,
texture_seed: params.texture_seed,
texture: params.texture,
pbr: params.pbr,
texture_quality: params
.texture_quality
.map(Model3dTextureQuality::as_str)
.map(str::to_owned),
extra: extra_fields(params.texture_version, params.delight),
geometry_quality: params
.geometry_quality
.map(Model3dGeometryQuality::as_str)
.map(str::to_owned),
texture_quality: wire_option(params.texture_quality.as_ref())?,
extra: extra_fields(params.texture_version, params.delight)?,
geometry_quality: wire_option(params.geometry_quality.as_ref())?,
face_limit: params.face_limit,
auto_size: params.auto_size,
quad: params.quad,
smart_low_poly: params.smart_low_poly,
generate_parts: params.generate_parts,
compress: params
.compress
.map(Model3dCompression::as_str)
.map(str::to_owned),
compress: wire_option(params.compress.as_ref())?,
export_uv: params.export_uv,
export_orientation: params
.export_orientation
.map(Model3dExportOrientation::as_str)
.map(str::to_owned),
export_orientation: wire_option(params.export_orientation.as_ref())?,
style: None,
}
})
}
@@ -8,11 +8,3 @@ pub enum Model3dCompression {
#[serde(rename = "geometry")]
Geometry,
}
impl Model3dCompression {
pub const fn as_str(self) -> &'static str {
match self {
Self::Geometry => "geometry",
}
}
}
@@ -14,14 +14,3 @@ pub enum Model3dExportOrientation {
#[serde(rename = "-y")]
MinusY,
}
impl Model3dExportOrientation {
pub const fn as_str(self) -> &'static str {
match self {
Self::PlusX => "+x",
Self::MinusX => "-x",
Self::PlusY => "+y",
Self::MinusY => "-y",
}
}
}
@@ -10,12 +10,3 @@ pub enum Model3dGeometryQuality {
Standard,
Detailed,
}
impl Model3dGeometryQuality {
pub const fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Detailed => "detailed",
}
}
}
@@ -10,12 +10,3 @@ pub enum Model3dInputOrientation {
Default,
AlignImage,
}
impl Model3dInputOrientation {
pub const fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::AlignImage => "align_image",
}
}
}
@@ -20,14 +20,4 @@ pub enum Model3dModelVersion {
impl Model3dModelVersion {
/// 契约支持的全部模型版本;定价配置必须为每个版本给出两个端点的底价。
pub const ALL: [Self; 5] = [Self::H31, Self::H30, Self::H25, Self::P1, Self::P2];
pub const fn as_str(self) -> &'static str {
match self {
Self::H31 => "v3.1-20260211",
Self::H30 => "v3.0-20250812",
Self::H25 => "v2.5-20250123",
Self::P1 => "P1-20260311",
Self::P2 => "P2-20260801",
}
}
}
@@ -10,12 +10,3 @@ pub enum Model3dTextureAlignment {
OriginalImage,
Geometry,
}
impl Model3dTextureAlignment {
pub const fn as_str(self) -> &'static str {
match self {
Self::OriginalImage => "original_image",
Self::Geometry => "geometry",
}
}
}
@@ -12,14 +12,3 @@ pub enum Model3dTextureQuality {
Detailed,
Extreme,
}
impl Model3dTextureQuality {
pub const fn as_str(self) -> &'static str {
match self {
Self::Fast => "fast",
Self::Standard => "standard",
Self::Detailed => "detailed",
Self::Extreme => "extreme",
}
}
}
@@ -12,13 +12,3 @@ pub enum Model3dTextureVersion {
#[serde(rename = "v2.5-20250123")]
V25,
}
impl Model3dTextureVersion {
pub const fn as_str(self) -> &'static str {
match self {
Self::V35 => "v3.5-20260815",
Self::V30 => "v3.0-20250812",
Self::V25 => "v2.5-20250123",
}
}
}
@@ -24,3 +24,32 @@ pub mod text_to_model;
pub use generation_result::Model3dGenerationResult;
pub const MODEL3D_CONTRACT_VERSION: &str = "model3d.v1";
/// 契约枚举的线上取值。
///
/// 取值只由枚举上的 serde `rename` / `rename_all` 定义:调用方需要字符串时走这里,
/// 不要在枚举旁边再手写一份 `as_str` —— 同一份取值出现两处,改一处就会静默分叉。
/// 契约里的枚举都是「序列化成字符串」的形态;序列化失败或形态不符说明契约被改坏,
/// 因此按错误返回,而不是 panic 或静默兜底。
pub fn wire_str<T>(value: &T) -> Result<String, serde_json::Error>
where
T: serde::Serialize + ?Sized,
{
match serde_json::to_value(value)? {
serde_json::Value::String(text) => Ok(text),
other => Err(serde::ser::Error::custom(format!(
"model3d 契约枚举应序列化成字符串,实际为 {other}"
))),
}
}
/// 与 [`wire_str`] 相同,但失败时退回 `Debug` 名称。
///
/// 只用于日志与审计文案:这些位置必须给出字符串且不能失败,退回值明显不是线上取值,
/// 不会被误当成正常结果。
pub fn wire_str_lossy<T>(value: &T) -> String
where
T: serde::Serialize + std::fmt::Debug + ?Sized,
{
wire_str(value).unwrap_or_else(|_| format!("{value:?}"))
}
@@ -69,7 +69,7 @@ fn multiview_inputs_reject_unknown_fields_inside_variants() {
fn multiview_request_rejects_unknown_top_level_fields() {
let error = serde_json::from_value::<Model3dMultiviewToModelRequest>(json!({
"inputs": { "kind": "taskId", "taskId": "tripo-task-1" },
"model": Model3dModelVersion::H31.as_str(),
"model": "v3.1-20260211",
"unexpected": true
}))
.expect_err("请求体顶层出现未知字段应被拒绝");