diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index 636eca142..0c8c213f5 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -703,6 +703,7 @@ mod tests { &EditorToolContext::default(), &EditorGenerationPricingConfig { models: Default::default(), + model3d: None, }, ) .expect("successful partial tool output should still build a confirmation card"); 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 11fdaa818..062543fdd 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -8,6 +8,7 @@ use std::{ use serde::{Deserialize, Serialize}; use crate::asset_billing::current_external_generation_billing_price_mud_points; +use crate::tripo3d::pricing::{Model3dPricingConfig, Model3dPricingError, Model3dPricingQuery}; /// 图片画布编辑器生成类能力的泥点配置。 /// @@ -76,6 +77,10 @@ pub(crate) struct EditorGenerationModelPricing { #[serde(rename_all = "camelCase")] pub(crate) struct EditorGenerationPricingConfig { pub models: BTreeMap, + /// Tripo 3D 生成定价。业务给出泥点数值之前允许缺失, + /// 缺失即代表 3D 定价未配置:提交被拒绝,不扣费也不调用 provider。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model3d: Option, } #[derive(Clone, Debug)] @@ -94,6 +99,19 @@ pub(crate) enum EditorGenerationPricingError { } impl EditorGenerationPricingConfig { + /// Tripo 3D 提交查价:缺段或缺键都直接报错,不回退默认值。 + // 里程碑二的路由接上之前,只有测试消费这个方法。 + #[allow(dead_code)] + pub(crate) fn model3d_price( + &self, + query: &Model3dPricingQuery, + ) -> Result { + self.model3d + .as_ref() + .ok_or(Model3dPricingError::NotConfigured)? + .price(query) + } + pub(crate) fn image_model_mud_points( &self, model: Option<&str>, @@ -213,6 +231,11 @@ impl EditorGenerationPricingConfig { pub(crate) fn validate(&self) -> Result<(), EditorGenerationPricingError> { validate_all_model_entries(&self.models)?; + if let Some(model3d) = &self.model3d { + model3d.validate().map_err(|message| { + EditorGenerationPricingError::Invalid(format!("model3d 定价:{message}")) + })?; + } validate_required_tier_prices( &self.models, EDITOR_IMAGE_MODEL_NANOBANANA2, @@ -634,6 +657,127 @@ impl std::error::Error for EditorGenerationPricingError {} #[cfg(test)] mod tests { use super::*; + use serde_json::{Value, json}; + use shared_contracts::model3d::common::{ + Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality, + }; + + use crate::tripo3d::pricing::{ + Model3dAddOn, Model3dEndpoint, Model3dPricingError, Model3dPricingQuery, 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)); + } + json!({ + "basePrices": Value::Object(base_prices), + "addOnPrices": { + "hdTexture": 5, + "ultraTexture": 10, + "hdGeometry": 15, + "quadMesh": 20, + "smartLowPoly": 25, + "generateParts": 30 + } + }) + } + + fn pricing_json_with_model3d(model3d: Value) -> String { + let mut config: Value = + serde_json::from_str(EDITOR_GENERATION_PRICING_DEFAULT_JSON).expect("默认配置是 JSON"); + config["model3d"] = model3d; + config.to_string() + } + + fn query( + texture: bool, + add_ons: crate::tripo3d::pricing::Model3dAddOnSet, + ) -> Model3dPricingQuery { + Model3dPricingQuery { + endpoint: Model3dEndpoint::TextToModel, + model_version: Model3dModelVersion::H31, + texture, + add_ons, + } + } + + #[test] + fn editor_generation_pricing_keeps_model3d_optional_until_configured() { + let config = default_runtime_pricing(); + + assert!( + config.model3d.is_none(), + "业务给出数值前,受控默认配置不带 3D 定价段" + ); + let error = config + .model3d_price(&query( + false, + model3d_add_ons( + false, + Model3dTextureQuality::Standard, + Model3dGeometryQuality::Standard, + false, + false, + false, + ), + )) + .expect_err("未配置 3D 定价时应拒绝查价"); + assert_eq!(error, Model3dPricingError::NotConfigured); + } + + #[test] + fn editor_generation_pricing_validates_model3d_section_when_present() { + let json = pricing_json_with_model3d(model3d_pricing_json()); + let config = parse_editor_generation_pricing_json(&json, "测试配置") + .expect("完整 3D 定价段应可加载"); + + let add_ons = model3d_add_ons( + true, + Model3dTextureQuality::Detailed, + Model3dGeometryQuality::Standard, + false, + false, + false, + ); + assert!(add_ons.contains(Model3dAddOn::HdTexture)); + assert_eq!( + config + .model3d_price(&query(true, add_ons)) + .expect("底价与 add-on 都在夹具里"), + 20 + 5 + ); + + let mut incomplete = model3d_pricing_json(); + incomplete["basePrices"]["image-to-model"] + .as_object_mut() + .expect("夹具含图片端点底价") + .remove("P2-20260801"); + let error = parse_editor_generation_pricing_json( + &pricing_json_with_model3d(incomplete), + "测试配置", + ) + .expect_err("缺少任一底价键应加载失败"); + assert!( + error.to_string().contains("缺少"), + "报错应说明缺键,实际为:{error}" + ); + } fn unique_temp_dir(prefix: &str) -> PathBuf { std::env::temp_dir().join(format!( diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index c0980823f..7021ea4a4 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -80,6 +80,7 @@ mod state; mod telemetry; mod tracking; mod tracking_outbox; +mod tripo3d; mod vector_engine_audio_generation; mod volcengine_speech; mod wallet_refund_outbox; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 91c58792f..08617629e 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -419,6 +419,8 @@ fn editor_generation_pricing_to_records( config: &EditorGenerationPricingConfig, ) -> Result, EditorGenerationPricingError> { config.validate()?; + // 只序列化 `models`:3D 定价段不在记录形状里,由文件配置提供,见 + // editor_generation_pricing_from_record 的说明。 Ok(config .models .iter() @@ -496,7 +498,13 @@ fn editor_generation_pricing_from_record( })?; models.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); } - let config = EditorGenerationPricingConfig { models }; + // TODO Tripo 3D 定价:后台记录形状还只有 `模型 → 档位 → 单价`,表达不了 + // endpoint × 模型版本 × 是否有贴图的底价与 add-on 叠加,因此 3D 段暂由文件配置提供, + // 后台改价不会覆盖它;等记录形状补齐后再改为从 SpacetimeDB 读取。 + let config = EditorGenerationPricingConfig { + models, + model3d: legacy_fallback.model3d.clone(), + }; config.validate()?; Ok(config) } diff --git a/server-rs/crates/api-server/src/tripo3d/mod.rs b/server-rs/crates/api-server/src/tripo3d/mod.rs new file mode 100644 index 000000000..3c99b9eb1 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/mod.rs @@ -0,0 +1,10 @@ +//! Tripo 3D 生成的 api-server 侧接入点。 +//! +//! 这里只放 Tripo 生成自己的东西:请求组合校验、泥点定价、入队与产物落库。 +//! 异步执行复用现有 `external_generation_job` 队列与查询接口,不再造第二套任务模型; +//! 与历史 Hyper3D 能力(`platform-hyper3d`、`/api/assets/hyper3d/*`)没有任何共用。 + +// 里程碑二的路由与 worker 接上之前,这两个模块只有测试消费者; +// 接入后应删除这层豁免,让未使用项重新暴露出来。 +#[allow(dead_code)] +pub(crate) mod pricing; diff --git a/server-rs/crates/api-server/src/tripo3d/pricing.rs b/server-rs/crates/api-server/src/tripo3d/pricing.rs new file mode 100644 index 000000000..6b1989bf2 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/pricing.rs @@ -0,0 +1,376 @@ +//! 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>, + pub add_on_prices: BTreeMap, +} + +/// 一次提交的定价输入:只携带判定价格需要的字段,全部来自已校验的请求。 +#[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 { + 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 { + 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::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 + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/model_version.rs b/server-rs/crates/shared-contracts/src/model3d/common/model_version.rs index 172a1bcf1..4e905abdd 100644 --- a/server-rs/crates/shared-contracts/src/model3d/common/model_version.rs +++ b/server-rs/crates/shared-contracts/src/model3d/common/model_version.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use super::MODEL3D_TS_EXPORT_DIR; -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ts_rs::TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize, ts_rs::TS)] #[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] pub enum Model3dModelVersion { #[serde(rename = "v3.1-20260211")] @@ -18,6 +18,9 @@ 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",