Files
Genarrative/server-rs/crates/api-server/src/editor_generation_config.rs
T
k88936 058d851a69 去掉 model3d_price 上已过期的 dead_code 豁免
- server-rs/crates/api-server/src/editor_generation_config.rs:model3d_price 现在由 tripo3d 提交路由真实消费,删掉「里程碑二的路由接上之前只有测试消费」这句已过期注释与配套的 #[allow(dead_code)]

验证:cargo check -p api-server(--locked)无该函数相关告警
2026-09-22 13:43:45 +08:00

1307 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::BTreeMap,
fmt, fs, io,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use serde::{Deserialize, Serialize};
use crate::asset_billing::current_external_generation_billing_price_mud_points;
use crate::tripo3d::pricing::{Model3dPricingConfig, Model3dPricingError, Model3dPricingQuery};
/// 图片画布编辑器生成类能力的泥点配置。
///
/// 中文注释:默认值来自独立 JSON 文件;后台修改写入 SpacetimeDB 全局配置表。
/// 生成扣费校验必须通过本模块读取运行时快照,避免前端展示价格成为真相源。
pub(crate) const EDITOR_GENERATION_PRICING_DEFAULT_JSON: &str =
include_str!("../config/editor-generation-pricing.default.json");
const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
const EDITOR_VIDEO_MODEL_SEEDANCE_2: &str = "seedance2.0";
const EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast";
const EDITOR_VIDEO_MODEL_KLING_3: &str = "kling3.0";
const EDITOR_VIDEO_MODEL_KLING_3_OMNI: &str = "kling3.0-omni";
const EDITOR_VIDEO_MODEL_VEO_3_1: &str = "veo3.1";
const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast";
pub(crate) const EDITOR_SOUND_EFFECT_MODEL_VIDU: &str = "audio1.0";
pub(crate) const EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS: &str =
shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL;
pub(crate) const EDITOR_BACKGROUND_MUSIC_MODEL_SUNO: &str = "chirp-v5";
const IMAGE_PRICE_SIZE_0_5K: &str = "0.5K";
const IMAGE_PRICE_SIZE_1K: &str = "1K";
const IMAGE_PRICE_SIZE_2K: &str = "2K";
const DEFAULT_IMAGE_PRICE_SIZE: &str = IMAGE_PRICE_SIZE_1K;
const SPEC_IMAGE_PRICE_SIZE: &str = IMAGE_PRICE_SIZE_2K;
const DEFAULT_VIDEO_RESOLUTION: &str = "480p";
const REQUIRED_NANOBANANA_IMAGE_SIZES: &[&str] = &[
IMAGE_PRICE_SIZE_0_5K,
IMAGE_PRICE_SIZE_1K,
IMAGE_PRICE_SIZE_2K,
];
const REQUIRED_GPT_IMAGE_SIZES: &[&str] = &[IMAGE_PRICE_SIZE_1K, IMAGE_PRICE_SIZE_2K];
const REQUIRED_VIDEO_MODELS: &[&str] = &[
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
EDITOR_VIDEO_MODEL_SEEDANCE_2,
EDITOR_VIDEO_MODEL_KLING_3,
EDITOR_VIDEO_MODEL_KLING_3_OMNI,
EDITOR_VIDEO_MODEL_VEO_3_1,
EDITOR_VIDEO_MODEL_VEO_3_1_FAST,
];
const REQUIRED_VIDEO_RESOLUTIONS: &[&str] = &["480p", "720p", "1080p"];
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum EditorGenerationPricingUnit {
PerGeneration,
PerSecond,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorGenerationModelPricing {
pub unit: EditorGenerationPricingUnit,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub price: Option<u32>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub prices: BTreeMap<String, u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[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)]
pub(crate) struct EditorGenerationPricingStore {
current: Arc<RwLock<EditorGenerationPricingConfig>>,
}
#[derive(Debug)]
pub(crate) enum EditorGenerationPricingError {
Io(io::Error),
Json(serde_json::Error),
Invalid(String),
#[cfg_attr(test, allow(dead_code))]
Persistence(String),
LockPoisoned,
}
impl EditorGenerationPricingConfig {
/// Tripo 3D 提交查价:缺段或缺键都直接报错,不回退默认值。
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>,
image_size: Option<&str>,
) -> u32 {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
let normalized_model = normalize_editor_image_model(model);
let normalized_size =
normalize_editor_generation_image_price_size(normalized_model, image_size);
read_tier_price(
&self.models,
normalized_model,
EDITOR_IMAGE_MODEL_NANOBANANA2,
normalized_size,
DEFAULT_IMAGE_PRICE_SIZE,
)
}
pub(crate) fn spec_model_mud_points(&self, model: Option<&str>) -> u32 {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
let normalized_model = normalize_non_empty_model(model, EDITOR_IMAGE_MODEL_GPT_IMAGE_2);
read_tier_price(
&self.models,
normalized_model,
EDITOR_IMAGE_MODEL_GPT_IMAGE_2,
SPEC_IMAGE_PRICE_SIZE,
SPEC_IMAGE_PRICE_SIZE,
)
}
pub(crate) fn image_generation_mud_points(
&self,
kind: Option<&str>,
model: Option<&str>,
image_size: Option<&str>,
) -> u32 {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
match kind.map(str::trim) {
Some("spec") => self.spec_model_mud_points(model),
Some("character" | "icon" | "ui-design" | "publication-material" | "scene") => {
self.image_model_mud_points(model, image_size)
}
_ => self.image_model_mud_points(model, image_size),
}
}
pub(crate) fn video_model_mud_points(
&self,
model: Option<&str>,
resolution: &str,
duration_seconds: u32,
) -> u32 {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
let normalized_model = normalize_video_model(model);
let per_second = read_tier_price(
&self.models,
normalized_model,
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
resolution,
DEFAULT_VIDEO_RESOLUTION,
);
per_second * duration_seconds
}
pub(crate) fn character_animation_model_mud_points(
&self,
model: Option<&str>,
resolution: &str,
duration_seconds: u32,
) -> u32 {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
let normalized_model = normalize_non_empty_model(model, EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST);
let per_second = read_tier_price(
&self.models,
normalized_model,
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
resolution,
DEFAULT_VIDEO_RESOLUTION,
);
per_second * duration_seconds
}
pub(crate) fn sound_effect_model_mud_points(&self, model: Option<&str>) -> u32 {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
let normalized_model =
normalize_non_empty_model(model, EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
read_flat_price(
&self.models,
normalized_model,
EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS,
)
}
pub(crate) fn background_music_model_mud_points(&self, model: Option<&str>) -> u32 {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
let normalized_model = normalize_non_empty_model(model, EDITOR_BACKGROUND_MUSIC_MODEL_SUNO);
read_flat_price(
&self.models,
normalized_model,
EDITOR_BACKGROUND_MUSIC_MODEL_SUNO,
)
}
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,
EditorGenerationPricingUnit::PerGeneration,
REQUIRED_NANOBANANA_IMAGE_SIZES,
)?;
validate_required_tier_prices(
&self.models,
EDITOR_IMAGE_MODEL_GPT_IMAGE_2,
EditorGenerationPricingUnit::PerGeneration,
REQUIRED_GPT_IMAGE_SIZES,
)?;
for model in REQUIRED_VIDEO_MODELS {
validate_required_tier_prices(
&self.models,
model,
EditorGenerationPricingUnit::PerSecond,
REQUIRED_VIDEO_RESOLUTIONS,
)?;
}
validate_required_flat_price(
&self.models,
EDITOR_SOUND_EFFECT_MODEL_VIDU,
EditorGenerationPricingUnit::PerGeneration,
)?;
validate_required_flat_price(
&self.models,
EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS,
EditorGenerationPricingUnit::PerGeneration,
)?;
validate_required_flat_price(
&self.models,
EDITOR_BACKGROUND_MUSIC_MODEL_SUNO,
EditorGenerationPricingUnit::PerGeneration,
)?;
Ok(())
}
}
impl EditorGenerationPricingStore {
pub(crate) fn load(override_path: PathBuf) -> Result<Self, EditorGenerationPricingError> {
let current = load_editor_generation_pricing_from_paths(Some(&override_path))?;
Ok(Self {
current: Arc::new(RwLock::new(current)),
})
}
pub(crate) fn snapshot(
&self,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
self.current
.read()
.map_err(|_| EditorGenerationPricingError::LockPoisoned)
.map(|guard| guard.clone())
}
pub(crate) fn replace(
&self,
next: EditorGenerationPricingConfig,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
next.validate()?;
let mut guard = self
.current
.write()
.map_err(|_| EditorGenerationPricingError::LockPoisoned)?;
*guard = next.clone();
Ok(next)
}
}
pub(crate) fn default_editor_generation_pricing_override_path() -> PathBuf {
PathBuf::from(
"/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json",
)
}
pub(crate) fn legacy_editor_generation_pricing_override_path() -> PathBuf {
PathBuf::from(".app/editor-generation-pricing.override.json")
}
pub(crate) fn load_editor_generation_pricing_from_paths(
override_path: Option<&Path>,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
let legacy_override_path = legacy_editor_generation_pricing_override_path();
let default_override_path = default_editor_generation_pricing_override_path();
load_editor_generation_pricing_from_candidates(
override_path,
Some(legacy_override_path.as_path()),
override_path == Some(default_override_path.as_path()),
)
}
fn load_editor_generation_pricing_from_candidates(
override_path: Option<&Path>,
legacy_override_path: Option<&Path>,
allow_legacy_fallback: bool,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
let mut config = parse_editor_generation_pricing_json(
EDITOR_GENERATION_PRICING_DEFAULT_JSON,
"默认模型定价配置",
)?;
let fallback_legacy_path =
legacy_override_path.filter(|path| allow_legacy_fallback && path.exists());
let selected_override_path = override_path
.filter(|path| path.exists())
.or(fallback_legacy_path);
if let Some(path) = selected_override_path {
let override_json = fs::read_to_string(path).map_err(EditorGenerationPricingError::Io)?;
let source = path.to_string_lossy();
let mut override_config =
serde_json::from_str::<EditorGenerationPricingConfig>(override_json.as_str())
.map_err(EditorGenerationPricingError::Json)?;
backfill_legacy_sfx_pricing(&mut override_config, &config, source.as_ref())?;
override_config.validate().map_err(|error| match error {
EditorGenerationPricingError::Invalid(message) => {
EditorGenerationPricingError::Invalid(format!("{source}: {message}"))
}
other => other,
})?;
config = override_config;
}
config.validate()?;
Ok(config)
}
fn backfill_legacy_sfx_pricing(
config: &mut EditorGenerationPricingConfig,
fallback: &EditorGenerationPricingConfig,
source: &str,
) -> Result<(), EditorGenerationPricingError> {
if config
.models
.contains_key(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)
{
return Ok(());
}
let pricing = fallback
.models
.get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)
.cloned()
.ok_or_else(|| {
EditorGenerationPricingError::Invalid(format!(
"{source}: 受控默认配置缺少模型 {EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS}"
))
})?;
config
.models
.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing);
Ok(())
}
pub(crate) fn parse_editor_generation_pricing_json(
json: &str,
source: &str,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
serde_json::from_str::<EditorGenerationPricingConfig>(json)
.map_err(EditorGenerationPricingError::Json)
.and_then(|config| {
config.validate().map_err(|error| match error {
EditorGenerationPricingError::Invalid(message) => {
EditorGenerationPricingError::Invalid(format!("{source}: {message}"))
}
other => other,
})?;
Ok(config)
})
}
#[cfg(test)]
pub(crate) fn serialize_editor_generation_pricing_json(
config: &EditorGenerationPricingConfig,
) -> Result<String, EditorGenerationPricingError> {
config.validate()?;
serde_json::to_string(config).map_err(EditorGenerationPricingError::Json)
}
#[cfg(test)]
pub(crate) fn editor_image_generation_mud_points(
kind: Option<&str>,
model: Option<&str>,
image_size: Option<&str>,
) -> u32 {
default_runtime_pricing().image_generation_mud_points(kind, model, image_size)
}
#[cfg(test)]
pub(crate) fn editor_video_model_generation_mud_points(
model: Option<&str>,
resolution: &str,
duration_seconds: u32,
) -> u32 {
default_runtime_pricing().video_model_mud_points(model, resolution, duration_seconds)
}
#[cfg(test)]
pub(crate) fn editor_character_animation_model_mud_points(
model: Option<&str>,
resolution: &str,
duration_seconds: u32,
) -> u32 {
default_runtime_pricing().character_animation_model_mud_points(
model,
resolution,
duration_seconds,
)
}
#[cfg(test)]
pub(crate) fn editor_sound_effect_model_generation_mud_points(model: Option<&str>) -> u32 {
default_runtime_pricing().sound_effect_model_mud_points(model)
}
#[cfg(test)]
pub(crate) fn editor_background_music_model_generation_mud_points(model: Option<&str>) -> u32 {
default_runtime_pricing().background_music_model_mud_points(model)
}
#[cfg(test)]
fn default_runtime_pricing() -> EditorGenerationPricingConfig {
load_editor_generation_pricing_from_paths(None).expect("默认模型定价配置必须是合法 JSON")
}
fn normalize_editor_image_model(model: Option<&str>) -> &'static str {
match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2,
Some(EDITOR_IMAGE_MODEL_NANOBANANA2)
| Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS)
| Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => EDITOR_IMAGE_MODEL_NANOBANANA2,
_ => EDITOR_IMAGE_MODEL_NANOBANANA2,
}
}
fn normalize_editor_generation_image_price_size(
model: &str,
image_size: Option<&str>,
) -> &'static str {
match image_size.map(str::trim).filter(|value| !value.is_empty()) {
Some("0.5K" | "0.5k") if model == EDITOR_IMAGE_MODEL_NANOBANANA2 => IMAGE_PRICE_SIZE_0_5K,
Some("2K" | "2k") => IMAGE_PRICE_SIZE_2K,
_ => IMAGE_PRICE_SIZE_1K,
}
}
fn normalize_video_model(model: Option<&str>) -> &'static str {
match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_VIDEO_MODEL_SEEDANCE_2) => EDITOR_VIDEO_MODEL_SEEDANCE_2,
Some(EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST) => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
Some(EDITOR_VIDEO_MODEL_KLING_3) => EDITOR_VIDEO_MODEL_KLING_3,
Some(EDITOR_VIDEO_MODEL_KLING_3_OMNI) => EDITOR_VIDEO_MODEL_KLING_3_OMNI,
Some(EDITOR_VIDEO_MODEL_VEO_3_1) => EDITOR_VIDEO_MODEL_VEO_3_1,
Some(EDITOR_VIDEO_MODEL_VEO_3_1_FAST) => EDITOR_VIDEO_MODEL_VEO_3_1_FAST,
_ => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
}
}
fn normalize_non_empty_model<'a>(model: Option<&'a str>, fallback: &'static str) -> &'a str {
model
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(fallback)
}
fn read_flat_price(
models: &BTreeMap<String, EditorGenerationModelPricing>,
model: &str,
fallback_model: &str,
) -> u32 {
models
.get(model)
.and_then(|entry| entry.price)
.or_else(|| models.get(fallback_model).and_then(|entry| entry.price))
.unwrap_or(0)
}
fn read_tier_price(
models: &BTreeMap<String, EditorGenerationModelPricing>,
model: &str,
fallback_model: &str,
tier: &str,
fallback_tier: &str,
) -> u32 {
models
.get(model)
.and_then(|entry| {
entry
.prices
.get(tier)
.or_else(|| entry.prices.get(fallback_tier))
.copied()
.or(entry.price)
})
.or_else(|| {
models.get(fallback_model).and_then(|entry| {
entry
.prices
.get(tier)
.or_else(|| entry.prices.get(fallback_tier))
.copied()
.or(entry.price)
})
})
.unwrap_or(0)
}
fn validate_all_model_entries(
models: &BTreeMap<String, EditorGenerationModelPricing>,
) -> Result<(), EditorGenerationPricingError> {
if models.is_empty() {
return Err(EditorGenerationPricingError::Invalid(
"models 不能为空".to_string(),
));
}
for (model, pricing) in models {
if model.trim().is_empty() {
return Err(EditorGenerationPricingError::Invalid(
"models 不能包含空模型名".to_string(),
));
}
if pricing.price.is_some() == !pricing.prices.is_empty() {
return Err(EditorGenerationPricingError::Invalid(format!(
"models.{model} 必须且只能配置 price 或 prices 其中一种"
)));
}
if pricing.price == Some(0) {
return Err(EditorGenerationPricingError::Invalid(format!(
"models.{model}.price 必须大于 0"
)));
}
for (tier, price) in &pricing.prices {
if tier.trim().is_empty() {
return Err(EditorGenerationPricingError::Invalid(format!(
"models.{model}.prices 不能包含空档位"
)));
}
if *price == 0 {
return Err(EditorGenerationPricingError::Invalid(format!(
"models.{model}.{tier} 必须大于 0"
)));
}
}
}
Ok(())
}
fn validate_required_flat_price(
models: &BTreeMap<String, EditorGenerationModelPricing>,
model: &str,
expected_unit: EditorGenerationPricingUnit,
) -> Result<(), EditorGenerationPricingError> {
let pricing = models.get(model).ok_or_else(|| {
EditorGenerationPricingError::Invalid(format!("models 缺少模型 {model} 的泥点配置"))
})?;
if pricing.unit != expected_unit {
return Err(EditorGenerationPricingError::Invalid(format!(
"models.{model}.unit 必须是 {}",
format_pricing_unit(expected_unit)
)));
}
let price = pricing.price.ok_or_else(|| {
EditorGenerationPricingError::Invalid(format!("models.{model} 缺少单次泥点配置"))
})?;
if price == 0 {
return Err(EditorGenerationPricingError::Invalid(format!(
"models.{model}.price 必须大于 0"
)));
}
Ok(())
}
fn validate_required_tier_prices(
models: &BTreeMap<String, EditorGenerationModelPricing>,
model: &str,
expected_unit: EditorGenerationPricingUnit,
required_tiers: &[&str],
) -> Result<(), EditorGenerationPricingError> {
let pricing = models.get(model).ok_or_else(|| {
EditorGenerationPricingError::Invalid(format!("models 缺少模型 {model} 的泥点配置"))
})?;
if pricing.unit != expected_unit {
return Err(EditorGenerationPricingError::Invalid(format!(
"models.{model}.unit 必须是 {}",
format_pricing_unit(expected_unit)
)));
}
for tier in required_tiers {
let price = pricing.prices.get(*tier).ok_or_else(|| {
EditorGenerationPricingError::Invalid(format!("models.{model} 缺少 {tier} 的泥点配置"))
})?;
if *price == 0 {
return Err(EditorGenerationPricingError::Invalid(format!(
"models.{model}.{tier} 必须大于 0"
)));
}
}
Ok(())
}
fn format_pricing_unit(unit: EditorGenerationPricingUnit) -> &'static str {
match unit {
EditorGenerationPricingUnit::PerGeneration => "perGeneration",
EditorGenerationPricingUnit::PerSecond => "perSecond",
}
}
impl fmt::Display for EditorGenerationPricingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "模型定价配置文件读写失败:{error}"),
Self::Json(error) => write!(f, "模型定价配置 JSON 解析失败:{error}"),
Self::Invalid(message) => write!(f, "模型定价配置不合法:{message}"),
Self::Persistence(message) => write!(f, "模型定价配置入库失败:{message}"),
Self::LockPoisoned => write!(f, "模型定价配置锁已损坏"),
}
}
}
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, Model3dAddOnSet, 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,
}
}
/// Tripo 官方 credit → 泥点的换算系数:泥点 = `ceil(系数 × credit)`。
const MODEL3D_CREDIT_TO_MUD_FACTOR: f64 = 0.8;
fn mud_from_credit(credit: u32) -> u32 {
(f64::from(credit) * MODEL3D_CREDIT_TO_MUD_FACTOR).ceil() as u32
}
/// `model3d` 段缺失是合法形态,代表 3D 定价尚未配置:查价必须失败关闭,不扣费。
/// 受控默认配置现在带真实数值,所以这里显式摘掉该段来验证缺失语义。
#[test]
fn editor_generation_pricing_fails_closed_when_model3d_section_is_absent() {
let mut json: Value =
serde_json::from_str(EDITOR_GENERATION_PRICING_DEFAULT_JSON).expect("默认配置是 JSON");
json.as_object_mut()
.expect("默认配置应是对象")
.remove("model3d");
let config = parse_editor_generation_pricing_json(&json.to_string(), "测试配置")
.expect("缺少 3D 段仍应可加载");
assert!(config.model3d.is_none());
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);
}
/// 受控默认配置里的 3D 泥点价必须严格等于 `ceil(0.8 × Tripo 官方 credit)`:
/// 底价按「端点 × 模型版本 × 是否有贴图」,add-on 按项叠加。改价必须先改这里的
/// credit 表,否则这条断言会失败,避免默认价脱离已确认的换算口径。
#[test]
fn default_model3d_prices_convert_tripo_credits_at_configured_factor() {
let config = default_runtime_pricing();
let model3d = config.model3d.as_ref().expect("默认配置必须带 3D 定价段");
// (端点, 模型版本, 无贴图 credit, 带贴图 credit)
let base_credits = [
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::H31,
10,
20,
),
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::H30,
10,
20,
),
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::H25,
10,
20,
),
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::P1,
30,
40,
),
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::P2,
100,
110,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::H31,
20,
30,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::H30,
20,
30,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::H25,
20,
30,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::P1,
40,
50,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::P2,
100,
110,
),
];
for (endpoint, model_version, no_texture_credit, texture_credit) in base_credits {
for (texture, credit) in [(false, no_texture_credit), (true, texture_credit)] {
let price = config
.model3d_price(&Model3dPricingQuery {
endpoint,
model_version,
texture,
add_ons: Model3dAddOnSet::default(),
})
.expect("默认配置必须能查出底价");
assert_eq!(
price,
mud_from_credit(credit),
"{} {model_version:?} texture={texture} 的底价应为 ceil(0.8 × {credit}) 泥点",
endpoint.as_str(),
);
}
}
let add_on_credits = [
(Model3dAddOn::HdTexture, 10),
(Model3dAddOn::UltraTexture, 20),
(Model3dAddOn::HdGeometry, 20),
(Model3dAddOn::QuadMesh, 5),
(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}) 泥点"
);
}
}
#[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!(
"{prefix}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should work")
.as_nanos()
))
}
#[test]
fn editor_generation_pricing_uses_single_model_map_with_units() {
let config = default_runtime_pricing();
assert_eq!(
config
.models
.get("gpt-image-2")
.expect("gpt-image-2 pricing should exist")
.unit,
EditorGenerationPricingUnit::PerGeneration
);
assert_eq!(
config
.models
.get("seedance2.0-fast")
.expect("seedance fast pricing should exist")
.unit,
EditorGenerationPricingUnit::PerSecond
);
assert!(!config.models.contains_key("spec:gpt-image-2"));
assert!(
!config
.models
.contains_key("character-animation:seedance2.0-fast")
);
}
#[tokio::test]
async fn worker_billing_context_freezes_all_editor_generation_prices() {
let config = default_runtime_pricing();
let inline_image_price = config.image_model_mud_points(Some("gpt-image-2"), Some("2K"));
let queued_price = 37;
crate::asset_billing::with_external_generation_billing_context(
"extgen-pricing-test".to_string(),
queued_price,
async {
assert_eq!(
config.image_model_mud_points(Some("gpt-image-2"), Some("2K")),
queued_price
);
assert_eq!(
config.spec_model_mud_points(Some("gpt-image-2")),
queued_price
);
assert_eq!(
config.image_generation_mud_points(
Some("character"),
Some("gpt-image-2"),
Some("2K")
),
queued_price
);
assert_eq!(
config.video_model_mud_points(Some("seedance2.0"), "720p", 5),
queued_price
);
assert_eq!(
config.character_animation_model_mud_points(
Some("seedance2.0-fast"),
"720p",
6
),
queued_price
);
assert_eq!(
config
.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)),
queued_price
);
assert_eq!(
config.background_music_model_mud_points(Some("chirp-v5")),
queued_price
);
},
)
.await;
assert_eq!(
config.image_model_mud_points(Some("gpt-image-2"), Some("2K")),
inline_image_price
);
assert_ne!(inline_image_price, queued_price);
}
#[test]
fn editor_generation_pricing_default_override_path_uses_data_dir() {
assert_eq!(
default_editor_generation_pricing_override_path(),
PathBuf::from(
"/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json"
)
);
}
#[test]
fn editor_generation_pricing_default_path_falls_back_to_legacy_override_seed() {
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-test");
let primary_path = temp_dir.join("var/lib/editor-generation-pricing.override.json");
let legacy_path = temp_dir
.join(".app")
.join("editor-generation-pricing.override.json");
std::fs::create_dir_all(legacy_path.parent().expect("legacy parent"))
.expect("legacy parent should create");
let mut legacy_config = default_runtime_pricing();
legacy_config
.models
.get_mut("audio1.0")
.expect("audio model exists")
.price = Some(17);
std::fs::write(
&legacy_path,
serialize_editor_generation_pricing_json(&legacy_config)
.expect("legacy config should serialize"),
)
.expect("legacy override should write");
let loaded = load_editor_generation_pricing_from_candidates(
Some(primary_path.as_path()),
Some(legacy_path.as_path()),
true,
)
.expect("legacy override should seed cache");
assert_eq!(loaded.sound_effect_model_mud_points(Some("audio1.0")), 17);
}
#[test]
fn editor_generation_pricing_legacy_override_backfills_new_sfx_model() {
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-sfx-test");
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
let override_path = temp_dir.join("editor-generation-pricing.override.json");
let mut legacy_config = default_runtime_pricing();
legacy_config
.models
.remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
legacy_config
.models
.get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU)
.expect("legacy sound effect pricing should exist")
.price = Some(17);
std::fs::write(
&override_path,
serde_json::to_string(&legacy_config).expect("legacy config should serialize"),
)
.expect("legacy override should write");
let loaded = load_editor_generation_pricing_from_paths(Some(&override_path))
.expect("legacy override should backfill the new SFX model");
assert_eq!(
loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)),
5
);
assert_eq!(
loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_VIDU)),
17
);
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
}
#[test]
fn editor_generation_pricing_legacy_override_still_rejects_other_missing_models() {
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-required-model-test");
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
let override_path = temp_dir.join("editor-generation-pricing.override.json");
let mut legacy_config = default_runtime_pricing();
legacy_config
.models
.remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
legacy_config
.models
.remove(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO);
std::fs::write(
&override_path,
serde_json::to_string(&legacy_config).expect("legacy config should serialize"),
)
.expect("legacy override should write");
let error = load_editor_generation_pricing_from_paths(Some(&override_path))
.expect_err("only the new SFX model may be backfilled");
assert!(
error
.to_string()
.contains(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO)
);
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
}
#[test]
fn editor_image_and_spec_prices_share_model_size_rates() {
assert_eq!(
editor_image_generation_mud_points(
Some("image"),
Some("gemini-3.1-flash-image-preview"),
Some("0.5K")
),
8
);
assert_eq!(
editor_image_generation_mud_points(
Some("character"),
Some("gemini-3.1-flash-image-preview"),
Some("1K")
),
12
);
assert_eq!(
editor_image_generation_mud_points(
Some("scene"),
Some("gemini-3.1-flash-image-preview"),
Some("1K")
),
12
);
assert_eq!(
editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("1K")),
3
);
assert_eq!(
editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("2K")),
5
);
assert_eq!(
editor_image_generation_mud_points(Some("spec"), Some("gpt-image-2"), Some("2K")),
5
);
}
#[test]
fn editor_video_and_character_animation_share_model_resolution_rates() {
assert_eq!(
editor_video_model_generation_mud_points(Some("seedance2.0-fast"), "720p", 6),
120
);
assert_eq!(
editor_character_animation_model_mud_points(Some("seedance2.0-fast"), "720p", 6),
120
);
assert_eq!(
editor_video_model_generation_mud_points(Some("seedance2.0"), "720p", 5),
120
);
}
#[test]
fn editor_audio_generation_price_uses_configured_model_rates() {
assert_eq!(
editor_sound_effect_model_generation_mud_points(Some(
EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS
)),
5
);
assert_eq!(
editor_sound_effect_model_generation_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_VIDU)),
5
);
assert_eq!(
editor_background_music_model_generation_mud_points(Some("chirp-v5")),
12
);
}
#[test]
fn editor_generation_pricing_can_be_overridden_from_single_model_json_file() {
let temp_dir = unique_temp_dir("genarrative-pricing-test");
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
let override_path = temp_dir.join("editor-generation-pricing.override.json");
std::fs::write(
&override_path,
r#"{
"models": {
"gemini-3.1-flash-image-preview": {
"unit": "perGeneration",
"prices": { "0.5K": 9, "1K": 18, "2K": 36 }
},
"gpt-image-2": {
"unit": "perGeneration",
"prices": { "1K": 31, "2K": 62 }
},
"seedance2.0-fast": {
"unit": "perSecond",
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
},
"seedance2.0": {
"unit": "perSecond",
"prices": { "480p": 13, "720p": 26, "1080p": 52 }
},
"kling3.0": {
"unit": "perSecond",
"prices": { "480p": 16, "720p": 32, "1080p": 64 }
},
"kling3.0-omni": {
"unit": "perSecond",
"prices": { "480p": 21, "720p": 42, "1080p": 84 }
},
"veo3.1": {
"unit": "perSecond",
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
},
"veo3.1-fast": {
"unit": "perSecond",
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
},
"audio1.0": { "unit": "perGeneration", "price": 15 },
"eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 16 },
"chirp-v5": { "unit": "perGeneration", "price": 9 }
}
}"#,
)
.expect("override file should write");
let config = load_editor_generation_pricing_from_paths(Some(&override_path))
.expect("pricing should load");
assert_eq!(
config.image_model_mud_points(Some("gpt-image-2"), Some("2K")),
62
);
assert_eq!(config.spec_model_mud_points(Some("gpt-image-2")), 62);
assert_eq!(
config.video_model_mud_points(Some("seedance2.0"), "720p", 5),
130
);
assert_eq!(
config.character_animation_model_mud_points(Some("seedance2.0-fast"), "720p", 6),
132
);
assert_eq!(
config.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)),
16
);
assert_eq!(config.sound_effect_model_mud_points(Some("audio1.0")), 15);
assert_eq!(
config.background_music_model_mud_points(Some("chirp-v5")),
9
);
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
}
#[test]
fn editor_generation_pricing_rejects_missing_required_image_size() {
let error = parse_editor_generation_pricing_json(
r#"{
"models": {
"gemini-3.1-flash-image-preview": {
"unit": "perGeneration",
"prices": { "0.5K": 8, "1K": 12, "2K": 24 }
},
"gpt-image-2": {
"unit": "perGeneration",
"prices": { "1K": 20 }
},
"seedance2.0-fast": {
"unit": "perSecond",
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
},
"seedance2.0": {
"unit": "perSecond",
"prices": { "480p": 12, "720p": 24, "1080p": 48 }
},
"kling3.0": {
"unit": "perSecond",
"prices": { "480p": 15, "720p": 30, "1080p": 60 }
},
"kling3.0-omni": {
"unit": "perSecond",
"prices": { "480p": 20, "720p": 40, "1080p": 80 }
},
"veo3.1": {
"unit": "perSecond",
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
},
"veo3.1-fast": {
"unit": "perSecond",
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
},
"audio1.0": { "unit": "perGeneration", "price": 10 },
"eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 5 },
"chirp-v5": { "unit": "perGeneration", "price": 5 }
}
}"#,
"测试模型定价配置",
)
.expect_err("missing 2K price should fail");
assert!(error.to_string().contains("gpt-image-2 缺少 2K"));
}
}