Files
Genarrative/server-rs/crates/api-server/src/editor_generation_config.rs
T
k88936 ee4a9434fa 后台定价读取带版本、保存整段必填并做事务内乐观锁
- shared-contracts 新增定价版本冲突标记,module 与 api-server 共用一处声明
- SpacetimeDB upsert input 增加期望版本,事务内比对不一致即整笔拒绝(种子写入显式不比对)
- 定价 store 记录当前版本,读取路径带出 updated_at 微秒值
- 后台 GET 返回 models / 3D 两段 / updatedAtMicros,POST 要求两段显式给出且带期望版本
- 缺 3D 段 400、版本不匹配 409,退役 with_previous_model3d 与「省略即沿用」用例
- 绑定按 input 新字段重新生成,模块补乐观锁比对用例
2026-09-23 11:38:23 +08:00

1426 lines
51 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,
atomic::{AtomicI64, Ordering},
},
};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::asset_billing::current_external_generation_billing_price_mud_points;
use crate::tripo3d::pricing::{
Model3dPricingConfig, Model3dPricingError, Model3dPricingPublicView, 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>,
}
/// 主站公开读模型的响应体:`models` 原样透出,3D 段投影回迁移前的旧形状
/// ([`Model3dPricingPublicView`]),让画布 3D 入口不需要随本次迁移改动。
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorGenerationPricingPublicResponse {
pub models: BTreeMap<String, EditorGenerationModelPricing>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model3d: Option<Model3dPricingPublicView>,
}
#[derive(Clone, Debug)]
pub(crate) struct EditorGenerationPricingStore {
current: Arc<RwLock<EditorGenerationPricingConfig>>,
/// 当前配置的定价版本:SpacetimeDB 行的 `updated_at` 微秒值。0 表示还没读到过行
/// (表为空或读不到),后台保存的乐观锁就比对它。
version_micros: Arc<AtomicI64>,
}
#[derive(Debug)]
pub(crate) enum EditorGenerationPricingError {
Io(io::Error),
Json(serde_json::Error),
Invalid(String),
/// 3D 定价查询失败:结构化保留「未配置 / 缺底价 / 缺加价项」三种语义。
Model3d(Model3dPricingError),
/// 保存时发现定价已被他人改动:后台必须重新读取后再保存,不能静默覆盖。
Conflict,
#[cfg_attr(test, allow(dead_code))]
Persistence(String),
LockPoisoned,
}
impl From<Model3dPricingError> for EditorGenerationPricingError {
fn from(error: Model3dPricingError) -> Self {
Self::Model3d(error)
}
}
impl EditorGenerationPricingConfig {
/// 公开读模型视图:字段名与嵌套形状保持迁移前一致,见
/// [`EditorGenerationPricingPublicResponse`]。
pub(crate) fn into_public_response(self) -> EditorGenerationPricingPublicResponse {
EditorGenerationPricingPublicResponse {
models: self.models,
model3d: self.model3d.map(Model3dPricingConfig::into_public_view),
}
}
/// Tripo 3D 提交查价:缺段或缺键都直接报错,不回退默认值。
///
/// 错误与同文件其它查询统一成 [`EditorGenerationPricingError`],3D 结构化原因收在
/// [`EditorGenerationPricingError::Model3d`] 里,调用方不必再处理第二套错误枚举。
pub(crate) fn model3d_price(
&self,
query: &Model3dPricingQuery,
) -> Result<u32, EditorGenerationPricingError> {
self.model3d
.as_ref()
.ok_or(Model3dPricingError::NotConfigured)?
.price(query)
.map_err(Into::into)
}
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)),
version_micros: Arc::new(AtomicI64::new(0)),
})
}
/// 当前定价版本。后台把它发给前端,保存时原样回传做乐观锁比对。
pub(crate) fn version_micros(&self) -> i64 {
self.version_micros.load(Ordering::Acquire)
}
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,
version_micros: i64,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
next.validate()?;
let mut guard = self
.current
.write()
.map_err(|_| EditorGenerationPricingError::LockPoisoned)?;
*guard = next.clone();
self.version_micros.store(version_micros, Ordering::Release);
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())?;
backfill_legacy_model3d_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(())
}
/// 旧覆盖文件可能整段没有 3D 定价(3D 是后加的段):缺失时用当前受控默认价补齐。
///
/// 覆盖文件只承担种子与兜底,不代表「3D 未配置」;这里补齐后,表内缺 3D 段的老行也能
/// 按同一份默认价归一化,不会因为文件里没有这一段就把 3D 生成整段关掉。
fn backfill_legacy_model3d_pricing(
config: &mut EditorGenerationPricingConfig,
fallback: &EditorGenerationPricingConfig,
source: &str,
) {
if config.model3d.is_some() || fallback.model3d.is_none() {
return;
}
warn!(
source,
"覆盖文件缺少 3D 定价段,已按受控默认价补齐;确认 3D 价格后再从后台保存一次"
);
config.model3d = fallback.model3d.clone();
}
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, "模型定价配置锁已损坏"),
Self::Conflict => write!(f, "定价已被其他人更新,请重新读取后再保存"),
// 直接透传内层文案,保持接口响应里的 message 与改造前一致。
Self::Model3d(error) => write!(f, "{error}"),
}
}
}
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,
};
/// 单个端点的最小完整定价段:每个模型版本两种贴图态 + 全部 add-on。
fn endpoint_pricing_json() -> Value {
let mut version_prices = serde_json::Map::new();
for version in [
"v3.1-20260211",
"v3.0-20250812",
"v2.5-20250123",
"P1-20260311",
"P2-20260801",
] {
version_prices.insert(
version.to_string(),
json!({ "noTexture": 10, "texture": 20 }),
);
}
json!({
"versionPrices": Value::Object(version_prices),
"addOnPrices": {
"hdTexture": 5,
"ultraTexture": 10,
"hdGeometry": 15,
"quadMesh": 20,
"smartLowPoly": 25,
"generateParts": 30
}
})
}
/// 最小完整 3D 价格段:两个端点并列,各持自己的版本底价与加价项。
fn model3d_pricing_json() -> Value {
json!({
"textToModelPricing": endpoint_pricing_json(),
"imageToModelPricing": endpoint_pricing_json(),
})
}
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!(
matches!(
error,
EditorGenerationPricingError::Model3d(Model3dPricingError::NotConfigured)
),
"3D 段缺失应以 NotConfigured 收口,实际 {error:?}"
);
}
/// 受控默认配置里的 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 endpoint in Model3dEndpoint::ALL {
for (add_on, credit) in add_on_credits {
let price = model3d
.endpoint_pricing(endpoint)
.add_on_prices
.get(&add_on)
.copied()
.unwrap_or_else(|| {
panic!("默认配置 {} 缺少 {add_on:?} 的价格", endpoint.as_str())
});
assert_eq!(
price,
mud_from_credit(credit),
"{} {add_on:?} 的加价应为 ceil(0.8 × {credit}) 泥点",
endpoint.as_str()
);
}
}
}
#[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["imageToModelPricing"]["versionPrices"]
.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_backfills_missing_model3d_section() {
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-model3d-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.model3d = None;
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 3D section");
assert_eq!(
loaded.model3d,
default_runtime_pricing().model3d,
"覆盖文件缺 3D 段时应补上受控默认价"
);
assert_eq!(loaded.sound_effect_model_mud_points(Some("audio1.0")), 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"));
}
}