3D 定价段拆成端点并列的两段结构

- Model3dPricingConfig 改为 textToModelPricing / imageToModelPricing 两段并列,各持自己的 versionPrices 与 addOnPrices
- 新增 Model3dEndpointPricing 承接单端点定价,校验与查价按端点委派,溢出检查覆盖两段
- 受控默认 JSON 的 model3d 段同步改为两段结构
- 3D 定价相关用例夹具与断言更新到两段形状
This commit is contained in:
2026-09-23 11:01:20 +08:00
parent 774b74d3d6
commit 9f4a71d2e3
4 changed files with 173 additions and 113 deletions
@@ -77,29 +77,39 @@
}
},
"model3d": {
"basePrices": {
"text-to-model": {
"textToModelPricing": {
"versionPrices": {
"v3.1-20260211": { "noTexture": 8, "texture": 16 },
"v3.0-20250812": { "noTexture": 8, "texture": 16 },
"v2.5-20250123": { "noTexture": 8, "texture": 16 },
"P1-20260311": { "noTexture": 24, "texture": 32 },
"P2-20260801": { "noTexture": 80, "texture": 88 }
},
"image-to-model": {
"addOnPrices": {
"hdTexture": 8,
"ultraTexture": 16,
"hdGeometry": 16,
"quadMesh": 4,
"smartLowPoly": 8,
"generateParts": 16
}
},
"imageToModelPricing": {
"versionPrices": {
"v3.1-20260211": { "noTexture": 16, "texture": 24 },
"v3.0-20250812": { "noTexture": 16, "texture": 24 },
"v2.5-20250123": { "noTexture": 16, "texture": 24 },
"P1-20260311": { "noTexture": 32, "texture": 40 },
"P2-20260801": { "noTexture": 80, "texture": 88 }
},
"addOnPrices": {
"hdTexture": 8,
"ultraTexture": 16,
"hdGeometry": 16,
"quadMesh": 4,
"smartLowPoly": 8,
"generateParts": 16
}
},
"addOnPrices": {
"hdTexture": 8,
"ultraTexture": 16,
"hdGeometry": 16,
"quadMesh": 4,
"smartLowPoly": 8,
"generateParts": 16
}
}
}
@@ -690,27 +690,23 @@ mod tests {
model3d_add_ons,
};
/// 最小完整 3D 价格段:每个模型版本两个端点 × 两种贴图态 + 全部 add-on。
fn model3d_pricing_json() -> Value {
let mut base_prices = serde_json::Map::new();
for endpoint in ["text-to-model", "image-to-model"] {
let mut versions = serde_json::Map::new();
for version in [
"v3.1-20260211",
"v3.0-20250812",
"v2.5-20250123",
"P1-20260311",
"P2-20260801",
] {
versions.insert(
version.to_string(),
json!({ "noTexture": 10, "texture": 20 }),
);
}
base_prices.insert(endpoint.to_string(), Value::Object(versions));
/// 单个端点的最小完整定价段:每个模型版本两种贴图态 + 全部 add-on。
fn endpoint_pricing_json() -> Value {
let mut version_prices = serde_json::Map::new();
for version in [
"v3.1-20260211",
"v3.0-20250812",
"v2.5-20250123",
"P1-20260311",
"P2-20260801",
] {
version_prices.insert(
version.to_string(),
json!({ "noTexture": 10, "texture": 20 }),
);
}
json!({
"basePrices": Value::Object(base_prices),
"versionPrices": Value::Object(version_prices),
"addOnPrices": {
"hdTexture": 5,
"ultraTexture": 10,
@@ -722,6 +718,14 @@ mod tests {
})
}
/// 最小完整 3D 价格段:两个端点并列,各持自己的版本底价与加价项。
fn model3d_pricing_json() -> Value {
json!({
"textToModelPricing": endpoint_pricing_json(),
"imageToModelPricing": endpoint_pricing_json(),
})
}
fn pricing_json_with_model3d(model3d: Value) -> String {
let mut config: Value =
serde_json::from_str(EDITOR_GENERATION_PRICING_DEFAULT_JSON).expect("默认配置是 JSON");
@@ -881,17 +885,23 @@ mod tests {
(Model3dAddOn::SmartLowPoly, 10),
(Model3dAddOn::GenerateParts, 20),
];
for (add_on, credit) in add_on_credits {
let price = model3d
.add_on_prices
.get(&add_on)
.copied()
.unwrap_or_else(|| panic!("默认配置缺少 {add_on:?} 的价格"));
assert_eq!(
price,
mud_from_credit(credit),
"{add_on:?} 的加价应为 ceil(0.8 × {credit}) 泥点"
);
for endpoint in Model3dEndpoint::ALL {
for (add_on, credit) in add_on_credits {
let price = model3d
.endpoint_pricing(endpoint)
.add_on_prices
.get(&add_on)
.copied()
.unwrap_or_else(|| {
panic!("默认配置 {} 缺少 {add_on:?} 的价格", endpoint.as_str())
});
assert_eq!(
price,
mud_from_credit(credit),
"{} {add_on:?} 的加价应为 ceil(0.8 × {credit}) 泥点",
endpoint.as_str()
);
}
}
}
@@ -918,7 +928,7 @@ mod tests {
);
let mut incomplete = model3d_pricing_json();
incomplete["basePrices"]["image-to-model"]
incomplete["imageToModelPricing"]["versionPrices"]
.as_object_mut()
.expect("夹具含图片端点底价")
.remove("P2-20260801");
+4 -1
View File
@@ -2812,7 +2812,10 @@ mod tests {
// 显式给出 3D 段时按 payload 生效,而不是无脑沿用旧值。
let mut custom = default_model3d.clone();
custom.add_on_prices.insert(Model3dAddOn::QuadMesh, 7);
custom
.text_to_model_pricing
.add_on_prices
.insert(Model3dAddOn::QuadMesh, 7);
let mut explicit = before.clone();
explicit.model3d = Some(custom.clone());
let saved = state
@@ -1,8 +1,8 @@
//! Tripo 3D 生成的泥点定价表。
//!
//! 价格形状是「底价 + 可叠加 add-on」,现有 `模型 → 档位 → 单价` 查表表达不了,
//! 因此单独一段配置:底价按 `endpoint × modelVersion × 是否有贴图` 拆分
//! add-on 按请求参数判定后叠加。单位一律是泥点,不引入 provider 的 credit 概念。
//! 价格形状是「底价 + 可叠加加价项」,现有 `模型 → 档位 → 单价` 查表表达不了,
//! 因此单独一段配置:两个端点各持一段,段内按 `modelVersion × 是否有贴图` 给底价
//! 加价项按请求参数判定后叠加。单位一律是泥点,不引入 provider 的 credit 概念。
use std::collections::BTreeMap;
@@ -72,11 +72,31 @@ impl Model3dBasePrice {
}
}
/// 单个端点的整套 3D 定价:模型版本底价 + 该端点自己的加价项价目。
///
/// 两段加价项当前数值相同,仍各持一份:Tripo 若对某个端点差异化加价,只改那一段。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct Model3dEndpointPricing {
pub version_prices: BTreeMap<Model3dModelVersion, Model3dBasePrice>,
pub add_on_prices: BTreeMap<Model3dAddOn, u32>,
}
/// 3D 定价段:两个端点并列,各段承载自己的设置,不在一段里用端点做判别键。
#[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>,
pub text_to_model_pricing: Model3dEndpointPricing,
pub image_to_model_pricing: Model3dEndpointPricing,
}
impl Model3dPricingConfig {
pub(crate) fn endpoint_pricing(&self, endpoint: Model3dEndpoint) -> &Model3dEndpointPricing {
match endpoint {
Model3dEndpoint::TextToModel => &self.text_to_model_pricing,
Model3dEndpoint::ImageToModel => &self.image_to_model_pricing,
}
}
}
/// 一次提交的定价输入:只携带判定价格需要的字段,全部来自已校验的请求。
@@ -177,55 +197,28 @@ impl std::fmt::Display for Model3dPricingError {
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.iter().copied() {
if !prices.contains_key(&model_version) {
return Err(format!(
"缺少 endpoint {} 模型 {} 的底价",
endpoint.as_str(),
wire_str_lossy(&model_version)
));
}
}
for model_version in prices.keys() {
if !Model3dModelVersion::ALL.contains(model_version) {
return Err(format!(
"endpoint {} 出现了契约不支持的模型版本 {}",
endpoint.as_str(),
wire_str_lossy(model_version)
));
}
}
}
for add_on in Model3dAddOn::ALL {
if !self.add_on_prices.contains_key(&add_on) {
return Err(format!("缺少 add-on {add_on:?} 的价格"));
}
self.endpoint_pricing(endpoint).validate(endpoint)?;
}
// 加价项与底价都要累加进账单,配置阶段就挡住溢出,避免结算时静默截断。
let add_on_total = self
.add_on_prices
.values()
.try_fold(0u32, |total, price| total.checked_add(*price));
let max_base_price = self
.base_prices
.values()
.flat_map(|prices| prices.values())
.flat_map(|price| [price.no_texture, price.texture])
.max()
.unwrap_or(0);
if add_on_total
.and_then(|total| total.checked_add(max_base_price))
.is_none()
{
return Err("3D 定价表加总超出 u32 上限,请下调配置价格".to_string());
let mut add_on_total = 0u32;
let mut max_base_price = 0u32;
for endpoint in Model3dEndpoint::ALL {
let pricing = self.endpoint_pricing(endpoint);
for price in pricing.add_on_prices.values() {
add_on_total = add_on_total
.checked_add(*price)
.ok_or_else(overflow_error)?;
}
for price in pricing.version_prices.values() {
max_base_price = max_base_price.max(price.no_texture).max(price.texture);
}
}
add_on_total
.checked_add(max_base_price)
.ok_or_else(overflow_error)?;
Ok(())
}
@@ -242,14 +235,53 @@ impl Model3dPricingConfig {
return Err(Model3dPricingError::InconsistentTextureAddOn(add_on));
}
let base = self
.base_prices
.get(&query.endpoint)
.and_then(|prices| prices.get(&query.model_version))
.ok_or(Model3dPricingError::MissingBasePrice {
self.endpoint_pricing(query.endpoint).price(query)
}
}
fn overflow_error() -> String {
"3D 定价表加总超出 u32 上限,请下调配置价格".to_string()
}
impl Model3dEndpointPricing {
fn validate(&self, endpoint: Model3dEndpoint) -> Result<(), String> {
for model_version in Model3dModelVersion::ALL.iter().copied() {
if !self.version_prices.contains_key(&model_version) {
return Err(format!(
"缺少 endpoint {} 模型 {} 的底价",
endpoint.as_str(),
wire_str_lossy(&model_version)
));
}
}
for model_version in self.version_prices.keys() {
if !Model3dModelVersion::ALL.contains(model_version) {
return Err(format!(
"endpoint {} 出现了契约不支持的模型版本 {}",
endpoint.as_str(),
wire_str_lossy(model_version)
));
}
}
for add_on in Model3dAddOn::ALL {
if !self.add_on_prices.contains_key(&add_on) {
return Err(format!(
"endpoint {} 缺少 add-on {add_on:?} 的价格",
endpoint.as_str()
));
}
}
Ok(())
}
/// 查价:本段底价 + 命中的全部加价项。缺键直接报错,不回退默认值。
fn price(&self, query: &Model3dPricingQuery) -> Result<u32, Model3dPricingError> {
let base = self.version_prices.get(&query.model_version).ok_or(
Model3dPricingError::MissingBasePrice {
endpoint: query.endpoint,
model_version: query.model_version,
})?;
},
)?;
query
.add_ons
@@ -286,19 +318,22 @@ mod tests {
.collect()
}
/// 测试夹具价格:底价按端点区分,add-on 依次取 5 的倍数,便于断言“底价 + 命中 add-on”
/// 测试夹具价格:两段底价取不同值,加价项各自一份、依次取 5 的倍数
fn sample_endpoint_pricing(no_texture: u32, texture: u32) -> Model3dEndpointPricing {
Model3dEndpointPricing {
version_prices: version_prices(no_texture, texture),
add_on_prices: Model3dAddOn::ALL
.into_iter()
.enumerate()
.map(|(index, add_on)| (add_on, (index as u32 + 1) * 5))
.collect(),
}
}
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,
text_to_model_pricing: sample_endpoint_pricing(10, 20),
image_to_model_pricing: sample_endpoint_pricing(30, 40),
}
}
@@ -317,15 +352,17 @@ mod tests {
let mut missing_base = sample_config();
missing_base
.base_prices
.get_mut(&Model3dEndpoint::ImageToModel)
.expect("夹具含图片端点")
.image_to_model_pricing
.version_prices
.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);
missing_add_on
.image_to_model_pricing
.add_on_prices
.remove(&Model3dAddOn::QuadMesh);
let error = missing_add_on
.validate()
.expect_err("缺少 add-on 键应加载失败");