From 9f4a71d2e3178e469a2d78b91b0bcdd757f48e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 11:01:20 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E5=AE=9A=E4=BB=B7=E6=AE=B5=E6=8B=86?= =?UTF-8?q?=E6=88=90=E7=AB=AF=E7=82=B9=E5=B9=B6=E5=88=97=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E6=AE=B5=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model3dPricingConfig 改为 textToModelPricing / imageToModelPricing 两段并列,各持自己的 versionPrices 与 addOnPrices - 新增 Model3dEndpointPricing 承接单端点定价,校验与查价按端点委派,溢出检查覆盖两段 - 受控默认 JSON 的 model3d 段同步改为两段结构 - 3D 定价相关用例夹具与断言更新到两段形状 --- .../editor-generation-pricing.default.json | 32 ++-- .../src/editor_generation_config.rs | 72 ++++--- server-rs/crates/api-server/src/state.rs | 5 +- .../crates/api-server/src/tripo3d/pricing.rs | 177 +++++++++++------- 4 files changed, 173 insertions(+), 113 deletions(-) diff --git a/server-rs/crates/api-server/config/editor-generation-pricing.default.json b/server-rs/crates/api-server/config/editor-generation-pricing.default.json index 05a2734f5..54d1cbfc4 100644 --- a/server-rs/crates/api-server/config/editor-generation-pricing.default.json +++ b/server-rs/crates/api-server/config/editor-generation-pricing.default.json @@ -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 } } } diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index d1561cac2..d3ff52837 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -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"); diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 178bc2f9a..c16e77cfd 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -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 diff --git a/server-rs/crates/api-server/src/tripo3d/pricing.rs b/server-rs/crates/api-server/src/tripo3d/pricing.rs index aa37995a3..8fb422d07 100644 --- a/server-rs/crates/api-server/src/tripo3d/pricing.rs +++ b/server-rs/crates/api-server/src/tripo3d/pricing.rs @@ -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, + pub add_on_prices: BTreeMap, +} + +/// 3D 定价段:两个端点并列,各段承载自己的设置,不在一段里用端点做判别键。 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct Model3dPricingConfig { - pub base_prices: BTreeMap>, - pub add_on_prices: BTreeMap, + 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 { + 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 键应加载失败");