8f6458ffe2
- 新增 tripo3d/pricing.rs,底价按 endpoint × 模型版本 × 是否有贴图,add-on 按固定键表叠加 - 定价段加载即校验全部底价与 add-on 键,缺键即配置非法;缺段代表未配置,查价直接拒绝 - editor_generation_config 挂载可选 model3d 段并提供 fail-closed 查价入口 - 后台记录形状尚不支持 3D 段,暂由文件配置提供并留下同步待办 - model_version 增加 ALL 与排序,供定价表枚举校验
377 lines
12 KiB
Rust
377 lines
12 KiB
Rust
//! 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
|
||
);
|
||
}
|
||
}
|