187d7735b9
将模型定价改为 SpacetimeDB 强类型持久化并兼容旧配置种子迁移 修复快速编辑支付弹窗期间框选显示和交互冻结 补齐后台图片放大以及视频音频资源预览 收紧主站和 External 资源换签授权并记录管理员跨用户审计 固化外部生成入队价格、attempt 钱包结算和最终 lease 失败收口 加固运行时身份轮换、bootstrap secret 与生产构建发布门禁 同步生成绑定、定向测试、运维脚本和项目文档
914 lines
31 KiB
Rust
914 lines
31 KiB
Rust
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<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>,
|
|
}
|
|
|
|
#[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 {
|
|
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<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)?;
|
|
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<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::*;
|
|
|
|
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"));
|
|
}
|
|
}
|