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; /// 图片画布编辑器生成类能力的泥点配置。 /// /// 中文注释:默认值来自独立 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_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, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub prices: BTreeMap, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub(crate) struct EditorGenerationPricingConfig { pub models: BTreeMap, } #[derive(Clone, Debug)] pub(crate) struct EditorGenerationPricingStore { current: Arc>, } #[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 { 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") => { 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_VIDU); read_flat_price( &self.models, normalized_model, EDITOR_SOUND_EFFECT_MODEL_VIDU, ) } 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)?; 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_BACKGROUND_MUSIC_MODEL_SUNO, EditorGenerationPricingUnit::PerGeneration, )?; Ok(()) } } impl EditorGenerationPricingStore { pub(crate) fn load(override_path: PathBuf) -> Result { 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 { self.current .read() .map_err(|_| EditorGenerationPricingError::LockPoisoned) .map(|guard| guard.clone()) } pub(crate) fn replace( &self, next: EditorGenerationPricingConfig, ) -> Result { 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 { 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 { 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)?; config = parse_editor_generation_pricing_json( override_json.as_str(), path.to_string_lossy().as_ref(), )?; } config.validate()?; Ok(config) } pub(crate) fn parse_editor_generation_pricing_json( json: &str, source: &str, ) -> Result { serde_json::from_str::(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 { 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, 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, 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, ) -> 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, 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, 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::*; 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("audio1.0")), 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_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("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("audio1.0")), 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 }, "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("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 }, "chirp-v5": { "unit": "perGeneration", "price": 5 } } }"#, "测试模型定价配置", ) .expect_err("missing 2K price should fail"); assert!(error.to_string().contains("gpt-image-2 缺少 2K")); } }