新增api-server Tripo3D模块与泥点定价结构

- 新增 tripo3d/pricing.rs,底价按 endpoint × 模型版本 × 是否有贴图,add-on 按固定键表叠加
- 定价段加载即校验全部底价与 add-on 键,缺键即配置非法;缺段代表未配置,查价直接拒绝
- editor_generation_config 挂载可选 model3d 段并提供 fail-closed 查价入口
- 后台记录形状尚不支持 3D 段,暂由文件配置提供并留下同步待办
- model_version 增加 ALL 与排序,供定价表枚举校验
This commit is contained in:
2026-09-21 12:53:02 +08:00
parent f90e114bcb
commit 8f6458ffe2
7 changed files with 545 additions and 2 deletions
@@ -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");
@@ -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<String, EditorGenerationModelPricing>,
/// Tripo 3D 生成定价。业务给出泥点数值之前允许缺失,
/// 缺失即代表 3D 定价未配置:提交被拒绝,不扣费也不调用 provider。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model3d: Option<Model3dPricingConfig>,
}
#[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<u32, Model3dPricingError> {
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!(
+1
View File
@@ -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;
+9 -1
View File
@@ -419,6 +419,8 @@ fn editor_generation_pricing_to_records(
config: &EditorGenerationPricingConfig,
) -> Result<Vec<EditorGenerationModelPricingRecord>, 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)
}
@@ -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;
@@ -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<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
);
}
}
@@ -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",