Files
Genarrative/server-rs/crates/api-server/src/tripo3d/pricing.rs
T
k88936 8f6458ffe2 新增api-server Tripo3D模块与泥点定价结构
- 新增 tripo3d/pricing.rs,底价按 endpoint × 模型版本 × 是否有贴图,add-on 按固定键表叠加
- 定价段加载即校验全部底价与 add-on 键,缺键即配置非法;缺段代表未配置,查价直接拒绝
- editor_generation_config 挂载可选 model3d 段并提供 fail-closed 查价入口
- 后台记录形状尚不支持 3D 段,暂由文件配置提供并留下同步待办
- model_version 增加 ALL 与排序,供定价表枚举校验
2026-09-21 12:53:02 +08:00

377 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tripo 3D 生成的泥点定价表。
//!
//! 价格形状是「底价 + 可叠加 add-on」,现有 `模型 → 档位 → 单价` 查表表达不了,
//! 因此单独一段配置:底价按 `endpoint × modelVersion × 是否有贴图` 拆分,
//! add-on 按请求参数判定后叠加。单位一律是泥点,不引入 provider 的 credit 概念。
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use shared_contracts::model3d::common::{
Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality,
};
/// 生成端点。批量价键与请求入口一一对应,不做 provider 侧的变形。
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Model3dEndpoint {
TextToModel,
ImageToModel,
}
impl Model3dEndpoint {
pub(crate) const ALL: [Self; 2] = [Self::TextToModel, Self::ImageToModel];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::TextToModel => "text-to-model",
Self::ImageToModel => "image-to-model",
}
}
}
/// 叠加在底价之上的加价项,键名与请求参数的判定规则一一对应。
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum Model3dAddOn {
HdTexture,
UltraTexture,
HdGeometry,
QuadMesh,
SmartLowPoly,
GenerateParts,
}
impl Model3dAddOn {
pub(crate) const ALL: [Self; 6] = [
Self::HdTexture,
Self::UltraTexture,
Self::HdGeometry,
Self::QuadMesh,
Self::SmartLowPoly,
Self::GenerateParts,
];
}
/// 同一模型版本在「不带贴图 / 带贴图」两种形态下的底价。
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Model3dBasePrice {
pub no_texture: u32,
pub texture: u32,
}
impl Model3dBasePrice {
pub(crate) fn for_texture(&self, texture: bool) -> u32 {
if texture {
self.texture
} else {
self.no_texture
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct Model3dPricingConfig {
pub base_prices: BTreeMap<Model3dEndpoint, BTreeMap<Model3dModelVersion, Model3dBasePrice>>,
pub add_on_prices: BTreeMap<Model3dAddOn, u32>,
}
/// 一次提交的定价输入:只携带判定价格需要的字段,全部来自已校验的请求。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct Model3dPricingQuery {
pub(crate) endpoint: Model3dEndpoint,
pub(crate) model_version: Model3dModelVersion,
pub(crate) texture: bool,
pub(crate) add_ons: Model3dAddOnSet,
}
/// 命中的 add-on 集合;由请求参数判定,不含重复项。
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct Model3dAddOnSet {
hd_texture: bool,
ultra_texture: bool,
hd_geometry: bool,
quad_mesh: bool,
smart_low_poly: bool,
generate_parts: bool,
}
impl Model3dAddOnSet {
pub(crate) fn iter(self) -> impl Iterator<Item = Model3dAddOn> {
Model3dAddOn::ALL
.into_iter()
.filter(move |add_on| self.contains(*add_on))
}
pub(crate) fn contains(self, add_on: Model3dAddOn) -> bool {
match add_on {
Model3dAddOn::HdTexture => self.hd_texture,
Model3dAddOn::UltraTexture => self.ultra_texture,
Model3dAddOn::HdGeometry => self.hd_geometry,
Model3dAddOn::QuadMesh => self.quad_mesh,
Model3dAddOn::SmartLowPoly => self.smart_low_poly,
Model3dAddOn::GenerateParts => self.generate_parts,
}
}
}
/// add-on 判定规则:只有这些组合加价,`fast` / `standard` 贴图不加价。
pub(crate) fn model3d_add_ons(
texture: bool,
texture_quality: Model3dTextureQuality,
geometry_quality: Model3dGeometryQuality,
quad: bool,
smart_low_poly: bool,
generate_parts: bool,
) -> Model3dAddOnSet {
Model3dAddOnSet {
hd_texture: texture && matches!(texture_quality, Model3dTextureQuality::Detailed),
ultra_texture: texture && matches!(texture_quality, Model3dTextureQuality::Extreme),
hd_geometry: matches!(geometry_quality, Model3dGeometryQuality::Detailed),
quad_mesh: quad,
smart_low_poly,
generate_parts,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum Model3dPricingError {
/// `model3d` 段整体缺失:3D 定价尚未配置,接口不开放,也不扣费。
NotConfigured,
MissingBasePrice {
endpoint: Model3dEndpoint,
model_version: Model3dModelVersion,
},
MissingAddOnPrice(Model3dAddOn),
}
impl std::fmt::Display for Model3dPricingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotConfigured => f.write_str("Tripo 3D 定价未配置"),
Self::MissingBasePrice {
endpoint,
model_version,
} => write!(
f,
"Tripo 3D 定价缺少底价:endpoint={} model={}",
endpoint.as_str(),
model_version.as_str()
),
Self::MissingAddOnPrice(add_on) => {
write!(f, "Tripo 3D 定价缺少 add-on{add_on:?}")
}
}
}
}
impl std::error::Error for Model3dPricingError {}
impl Model3dPricingConfig {
/// 加载即校验:两个端点 × 每个模型版本 × 两种贴图态与全部 add-on 键必须齐全。
pub(crate) fn validate(&self) -> Result<(), String> {
for endpoint in Model3dEndpoint::ALL {
let prices = self
.base_prices
.get(&endpoint)
.ok_or_else(|| format!("缺少 endpoint {} 的底价表", endpoint.as_str()))?;
for model_version in Model3dModelVersion::ALL {
if !prices.contains_key(&model_version) {
return Err(format!(
"缺少 endpoint {} 模型 {} 的底价",
endpoint.as_str(),
model_version.as_str()
));
}
}
for model_version in prices.keys() {
if !Model3dModelVersion::ALL.contains(model_version) {
return Err(format!(
"endpoint {} 出现了契约不支持的模型版本 {}",
endpoint.as_str(),
model_version.as_str()
));
}
}
}
for add_on in Model3dAddOn::ALL {
if !self.add_on_prices.contains_key(&add_on) {
return Err(format!("缺少 add-on {add_on:?} 的价格"));
}
}
Ok(())
}
/// 查价:底价 + 命中的全部 add-on。缺键直接报错,不回退默认值。
pub(crate) fn price(&self, query: &Model3dPricingQuery) -> Result<u32, Model3dPricingError> {
let base = self
.base_prices
.get(&query.endpoint)
.and_then(|prices| prices.get(&query.model_version))
.ok_or(Model3dPricingError::MissingBasePrice {
endpoint: query.endpoint,
model_version: query.model_version,
})?;
query
.add_ons
.iter()
.try_fold(base.for_texture(query.texture), |total, add_on| {
self.add_on_prices
.get(&add_on)
.map(|price| total.saturating_add(*price))
.ok_or(Model3dPricingError::MissingAddOnPrice(add_on))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn version_prices(
no_texture: u32,
texture: u32,
) -> BTreeMap<Model3dModelVersion, Model3dBasePrice> {
Model3dModelVersion::ALL
.into_iter()
.map(|version| {
(
version,
Model3dBasePrice {
no_texture,
texture,
},
)
})
.collect()
}
/// 测试夹具价格:底价按端点区分,add-on 依次取 5 的倍数,便于断言“底价 + 命中 add-on”。
fn sample_config() -> Model3dPricingConfig {
let mut base_prices = BTreeMap::new();
base_prices.insert(Model3dEndpoint::TextToModel, version_prices(10, 20));
base_prices.insert(Model3dEndpoint::ImageToModel, version_prices(30, 40));
let add_on_prices = Model3dAddOn::ALL
.into_iter()
.enumerate()
.map(|(index, add_on)| (add_on, (index as u32 + 1) * 5))
.collect();
Model3dPricingConfig {
base_prices,
add_on_prices,
}
}
fn text_query(texture: bool, add_ons: Model3dAddOnSet) -> Model3dPricingQuery {
Model3dPricingQuery {
endpoint: Model3dEndpoint::TextToModel,
model_version: Model3dModelVersion::H31,
texture,
add_ons,
}
}
#[test]
fn config_requires_every_base_price_and_add_on_key() {
sample_config().validate().expect("夹具配置应合法");
let mut missing_base = sample_config();
missing_base
.base_prices
.get_mut(&Model3dEndpoint::ImageToModel)
.expect("夹具含图片端点")
.remove(&Model3dModelVersion::P2);
let error = missing_base.validate().expect_err("缺少底价键应加载失败");
assert!(error.contains("缺少"), "报错应说明缺键,实际为:{error}");
let mut missing_add_on = sample_config();
missing_add_on.add_on_prices.remove(&Model3dAddOn::QuadMesh);
let error = missing_add_on
.validate()
.expect_err("缺少 add-on 键应加载失败");
assert!(
error.contains("add-on"),
"报错应说明缺 add-on,实际为:{error}"
);
}
#[test]
fn price_is_base_plus_matched_add_ons() {
let config = sample_config();
assert_eq!(
config
.price(&text_query(false, Model3dAddOnSet::default()))
.expect("不带贴图底价存在"),
10
);
let add_ons = model3d_add_ons(
true,
Model3dTextureQuality::Detailed,
Model3dGeometryQuality::Detailed,
false,
false,
false,
);
assert_eq!(
config
.price(&text_query(true, add_ons))
.expect("带贴图底价与 add-on 都存在"),
20 + 5 + 15
);
}
#[test]
fn texture_quality_only_charges_detailed_and_extreme() {
for (quality, expected) in [
(Model3dTextureQuality::Fast, None),
(Model3dTextureQuality::Standard, None),
(
Model3dTextureQuality::Detailed,
Some(Model3dAddOn::HdTexture),
),
(
Model3dTextureQuality::Extreme,
Some(Model3dAddOn::UltraTexture),
),
] {
let add_ons = model3d_add_ons(
true,
quality,
Model3dGeometryQuality::Standard,
false,
false,
false,
);
match expected {
Some(add_on) => assert!(add_ons.contains(add_on), "{quality:?} 应命中 {add_on:?}"),
None => assert_eq!(add_ons.iter().count(), 0, "{quality:?} 不应产生贴图加价"),
}
}
}
#[test]
fn geometry_quad_smart_low_poly_and_parts_each_add_price() {
let add_ons = model3d_add_ons(
false,
Model3dTextureQuality::Standard,
Model3dGeometryQuality::Detailed,
true,
true,
true,
);
assert_eq!(add_ons.iter().count(), 4);
assert_eq!(
sample_config()
.price(&text_query(false, add_ons))
.expect("add-on 键齐全"),
10 + 15 + 20 + 25 + 30
);
}
}