3D 定价记录转换与读取归一化接上 SpacetimeDB 两段列
- 新增 editor_generation_model3d_records 深模块:配置与两段记录行之间双向转换 - 写入方向严格:落库前按契约强校验,缺键与契约外键都不写 - 读取方向归一化:记录缺键(含整段缺失的老行)用本地配置同段补齐,契约外键剔除并告警,补齐后仍不合法才判非法 - 定价配置读取以表为准,覆盖文件降级为种子与兜底;state.rs 保存路径写入两段 - spacetime-client 补导出 3D 定价记录类型,api-server 定向用例覆盖往返、补齐、剔除与拒绝
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
//! 3D 定价段在「配置文件结构」与「SpacetimeDB 记录结构」之间的转换。
|
||||
//!
|
||||
//! 记录结构按展平风格存键值行:版本价一行含两档价,加价项一行一条,键都是字符串。
|
||||
//! 写入方向严格——写之前先按契约强校验,缺键与契约外的键都不允许落库;
|
||||
//! 读取方向做契约归一化——老记录缺的键从本地配置同段补齐,契约之外的遗留键剔除并告警,
|
||||
//! 归一化后仍不合法才判配置非法。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::model3d::wire_str;
|
||||
use spacetime_client::{
|
||||
EditorGenerationModel3dAddOnPriceRecord, EditorGenerationModel3dPricingRecord,
|
||||
EditorGenerationModel3dVersionPriceRecord,
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::editor_generation_config::EditorGenerationPricingError;
|
||||
use shared_contracts::model3d::common::Model3dModelVersion;
|
||||
|
||||
use crate::tripo3d::pricing::{
|
||||
Model3dAddOn, Model3dBasePrice, Model3dEndpoint, Model3dEndpointPricing, Model3dPricingConfig,
|
||||
};
|
||||
|
||||
/// 3D 定价在记录形状里的两段:每个端点一段,段内是版本价行与加价项行。
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct EditorGenerationModel3dSections {
|
||||
pub text_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
|
||||
pub image_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
|
||||
}
|
||||
|
||||
/// 配置 → 记录。段整体缺失时两列都写空,代表 3D 定价未配置(提交 fail closed)。
|
||||
pub(crate) fn model3d_sections_from_config(
|
||||
config: Option<&Model3dPricingConfig>,
|
||||
) -> Result<EditorGenerationModel3dSections, EditorGenerationPricingError> {
|
||||
let Some(config) = config else {
|
||||
return Ok(EditorGenerationModel3dSections::default());
|
||||
};
|
||||
config.validate().map_err(|message| {
|
||||
EditorGenerationPricingError::Invalid(format!("model3d 定价:{message}"))
|
||||
})?;
|
||||
Ok(EditorGenerationModel3dSections {
|
||||
text_to_model_pricing: Some(record_from_endpoint_pricing(
|
||||
Model3dEndpoint::TextToModel,
|
||||
&config.text_to_model_pricing,
|
||||
)?),
|
||||
image_to_model_pricing: Some(record_from_endpoint_pricing(
|
||||
Model3dEndpoint::ImageToModel,
|
||||
&config.image_to_model_pricing,
|
||||
)?),
|
||||
})
|
||||
}
|
||||
|
||||
/// 记录 → 配置,并按契约归一化。
|
||||
///
|
||||
/// `fallback` 是本地配置(受控默认 JSON / 覆盖文件)里的 3D 段,只承担两件事:
|
||||
/// 补齐记录里缺的键(含整段缺失的老记录),以及在没有表数据时决定「3D 是否已配置」。
|
||||
pub(crate) fn model3d_config_from_sections(
|
||||
sections: EditorGenerationModel3dSections,
|
||||
fallback: Option<&Model3dPricingConfig>,
|
||||
) -> Result<Option<Model3dPricingConfig>, EditorGenerationPricingError> {
|
||||
let text_to_model_pricing = endpoint_pricing_from_record(
|
||||
Model3dEndpoint::TextToModel,
|
||||
sections.text_to_model_pricing,
|
||||
fallback.map(|config| &config.text_to_model_pricing),
|
||||
)?;
|
||||
let image_to_model_pricing = endpoint_pricing_from_record(
|
||||
Model3dEndpoint::ImageToModel,
|
||||
sections.image_to_model_pricing,
|
||||
fallback.map(|config| &config.image_to_model_pricing),
|
||||
)?;
|
||||
match (text_to_model_pricing, image_to_model_pricing) {
|
||||
(None, None) => Ok(None),
|
||||
(Some(text_to_model_pricing), Some(image_to_model_pricing)) => {
|
||||
Ok(Some(Model3dPricingConfig {
|
||||
text_to_model_pricing,
|
||||
image_to_model_pricing,
|
||||
}))
|
||||
}
|
||||
// 单段缺失说明写入侧或归一化出了问题:不能拿一半的定价去收费。
|
||||
(text, image) => Err(EditorGenerationPricingError::Invalid(format!(
|
||||
"SpacetimeDB 模型定价配置 3D 段只配了一半:text_to_model_pricing={} image_to_model_pricing={}",
|
||||
text.is_some(),
|
||||
image.is_some()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_from_endpoint_pricing(
|
||||
endpoint: Model3dEndpoint,
|
||||
pricing: &Model3dEndpointPricing,
|
||||
) -> Result<EditorGenerationModel3dPricingRecord, EditorGenerationPricingError> {
|
||||
let version_prices = pricing
|
||||
.version_prices
|
||||
.iter()
|
||||
.map(|(model_version, base)| {
|
||||
Ok(EditorGenerationModel3dVersionPriceRecord {
|
||||
model_version: contract_key(model_version, endpoint, "模型版本")?,
|
||||
no_texture: base.no_texture,
|
||||
texture: base.texture,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, EditorGenerationPricingError>>()?;
|
||||
let add_on_prices = pricing
|
||||
.add_on_prices
|
||||
.iter()
|
||||
.map(|(add_on, price)| {
|
||||
Ok(EditorGenerationModel3dAddOnPriceRecord {
|
||||
add_on: contract_key(add_on, endpoint, "加价项")?,
|
||||
price: *price,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, EditorGenerationPricingError>>()?;
|
||||
Ok(EditorGenerationModel3dPricingRecord {
|
||||
version_prices,
|
||||
add_on_prices,
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint_pricing_from_record(
|
||||
endpoint: Model3dEndpoint,
|
||||
record: Option<EditorGenerationModel3dPricingRecord>,
|
||||
fallback: Option<&Model3dEndpointPricing>,
|
||||
) -> Result<Option<Model3dEndpointPricing>, EditorGenerationPricingError> {
|
||||
// 记录整段缺失(老行、或迁移前从未配过 3D)时直接用本地配置:这才是「覆盖文件只做
|
||||
// 种子与兜底」的落点,缺键补齐与整段兜底走同一条路径。
|
||||
let Some(record) = record else {
|
||||
return Ok(fallback.cloned());
|
||||
};
|
||||
|
||||
let mut version_prices = BTreeMap::new();
|
||||
for entry in record.version_prices {
|
||||
let Some(model_version) =
|
||||
parse_contract_key::<Model3dModelVersion>(&entry.model_version, endpoint, "模型版本")
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if version_prices
|
||||
.insert(
|
||||
model_version,
|
||||
Model3dBasePrice {
|
||||
no_texture: entry.no_texture,
|
||||
texture: entry.texture,
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||||
"SpacetimeDB 模型定价配置 3D 段重复模型版本:endpoint={} model={}",
|
||||
endpoint.as_str(),
|
||||
entry.model_version
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut add_on_prices = BTreeMap::new();
|
||||
for entry in record.add_on_prices {
|
||||
let Some(add_on) = parse_contract_key::<Model3dAddOn>(&entry.add_on, endpoint, "加价项")
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if add_on_prices.insert(add_on, entry.price).is_some() {
|
||||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||||
"SpacetimeDB 模型定价配置 3D 段重复加价项:endpoint={} add_on={}",
|
||||
endpoint.as_str(),
|
||||
entry.add_on
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 记录值优先,本地配置补缺:发布新增模型版本后,表里还没有该键时按本地受控价补齐。
|
||||
let mut merged = fallback.cloned().unwrap_or_default();
|
||||
merged.version_prices.extend(version_prices);
|
||||
merged.add_on_prices.extend(add_on_prices);
|
||||
merged.validate(endpoint).map_err(|message| {
|
||||
EditorGenerationPricingError::Invalid(format!("SpacetimeDB 模型定价配置 {message}"))
|
||||
})?;
|
||||
Ok(Some(merged))
|
||||
}
|
||||
|
||||
/// 记录里的键是字符串,契约之外的遗留键(例如版本下线后表里的旧键)在这里剔除并告警。
|
||||
fn parse_contract_key<T: DeserializeOwned>(
|
||||
key: &str,
|
||||
endpoint: Model3dEndpoint,
|
||||
kind: &str,
|
||||
) -> Option<T> {
|
||||
match serde_json::from_value::<T>(Value::String(key.to_string())) {
|
||||
Ok(value) => Some(value),
|
||||
Err(_) => {
|
||||
warn!(
|
||||
endpoint = endpoint.as_str(),
|
||||
kind, key, "SpacetimeDB 模型定价配置 3D 段出现契约之外的键,已剔除"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn contract_key<T: Serialize>(
|
||||
value: &T,
|
||||
endpoint: Model3dEndpoint,
|
||||
kind: &str,
|
||||
) -> Result<String, EditorGenerationPricingError> {
|
||||
match wire_str(value) {
|
||||
Ok(key) => Ok(key),
|
||||
Err(error) => Err(EditorGenerationPricingError::Invalid(format!(
|
||||
"3D 定价段 endpoint={} 的{kind}无法写成线上取值:{error}",
|
||||
endpoint.as_str()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tripo3d::pricing::{Model3dBasePrice, Model3dEndpointPricing};
|
||||
|
||||
fn endpoint_pricing(scale: u32) -> Model3dEndpointPricing {
|
||||
Model3dEndpointPricing {
|
||||
version_prices: Model3dModelVersion::ALL
|
||||
.iter()
|
||||
.map(|version| {
|
||||
(
|
||||
*version,
|
||||
Model3dBasePrice {
|
||||
no_texture: scale,
|
||||
texture: scale + 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
add_on_prices: Model3dAddOn::ALL
|
||||
.iter()
|
||||
.map(|add_on| (*add_on, scale))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn config() -> Model3dPricingConfig {
|
||||
Model3dPricingConfig {
|
||||
text_to_model_pricing: endpoint_pricing(10),
|
||||
image_to_model_pricing: endpoint_pricing(20),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_configured_sections_stay_not_configured() {
|
||||
let sections = model3d_sections_from_config(None).expect("段缺失应能写成两列空值");
|
||||
assert_eq!(sections, EditorGenerationModel3dSections::default());
|
||||
assert_eq!(
|
||||
model3d_config_from_sections(sections, None).expect("两段都空应可读"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_endpoints_round_trip_without_local_fallback() {
|
||||
let expected = config();
|
||||
let sections = model3d_sections_from_config(Some(&expected)).expect("完整配置应能写成记录");
|
||||
|
||||
assert_eq!(
|
||||
model3d_config_from_sections(sections, None).expect("完整记录应可读"),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_keys_and_missing_sections_are_backfilled_from_local_config() {
|
||||
let fallback = config();
|
||||
let mut sections =
|
||||
model3d_sections_from_config(Some(&fallback)).expect("完整配置应能写成记录");
|
||||
sections
|
||||
.text_to_model_pricing
|
||||
.as_mut()
|
||||
.expect("文生段应存在")
|
||||
.version_prices
|
||||
.retain(|entry| entry.model_version != "P2-20260801");
|
||||
sections.image_to_model_pricing = None;
|
||||
|
||||
assert_eq!(
|
||||
model3d_config_from_sections(sections, Some(&fallback))
|
||||
.expect("缺键与缺段都应从本地配置补齐"),
|
||||
Some(fallback)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_contract_keys_are_dropped() {
|
||||
let fallback = config();
|
||||
let expected = fallback.clone();
|
||||
let mut sections =
|
||||
model3d_sections_from_config(Some(&fallback)).expect("完整配置应能写成记录");
|
||||
let text = sections
|
||||
.text_to_model_pricing
|
||||
.as_mut()
|
||||
.expect("文生段应存在");
|
||||
text.version_prices
|
||||
.push(EditorGenerationModel3dVersionPriceRecord {
|
||||
model_version: "v9.9-29991231".to_string(),
|
||||
no_texture: 1,
|
||||
texture: 1,
|
||||
});
|
||||
text.add_on_prices
|
||||
.push(EditorGenerationModel3dAddOnPriceRecord {
|
||||
add_on: "teleport".to_string(),
|
||||
price: 1,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
model3d_config_from_sections(sections, Some(&fallback)).expect("契约外键应被剔除"),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_key_without_local_value_fails_closed() {
|
||||
let fallback = config();
|
||||
let mut sections =
|
||||
model3d_sections_from_config(Some(&fallback)).expect("完整配置应能写成记录");
|
||||
sections
|
||||
.text_to_model_pricing
|
||||
.as_mut()
|
||||
.expect("文生段应存在")
|
||||
.version_prices
|
||||
.retain(|entry| entry.model_version != "P2-20260801");
|
||||
let mut incomplete_fallback = fallback.clone();
|
||||
incomplete_fallback
|
||||
.text_to_model_pricing
|
||||
.version_prices
|
||||
.remove(&Model3dModelVersion::P2);
|
||||
|
||||
let error = model3d_config_from_sections(sections, Some(&incomplete_fallback))
|
||||
.expect_err("记录与本地配置都缺键时不能放行");
|
||||
assert!(
|
||||
error.to_string().contains("缺少"),
|
||||
"报错应说明缺键,实际为:{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_configured_sections_are_rejected() {
|
||||
let mut sections =
|
||||
model3d_sections_from_config(Some(&config())).expect("完整配置应能写成记录");
|
||||
sections.image_to_model_pricing = None;
|
||||
|
||||
let error = model3d_config_from_sections(sections, None).expect_err("只配一段时不能放行");
|
||||
assert!(
|
||||
error.to_string().contains("只配了一半"),
|
||||
"报错应说明只配了一半,实际为:{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keys_are_rejected() {
|
||||
let mut sections =
|
||||
model3d_sections_from_config(Some(&config())).expect("完整配置应能写成记录");
|
||||
let text = sections
|
||||
.text_to_model_pricing
|
||||
.as_mut()
|
||||
.expect("文生段应存在");
|
||||
let duplicate = text.version_prices[0].clone();
|
||||
text.version_prices.push(duplicate);
|
||||
|
||||
let error = model3d_config_from_sections(sections, None).expect_err("重复键应被拒绝");
|
||||
assert!(
|
||||
error.to_string().contains("重复模型版本"),
|
||||
"报错应说明重复,实际为:{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_requires_the_complete_contract_key_set() {
|
||||
let mut incomplete = config();
|
||||
incomplete
|
||||
.image_to_model_pricing
|
||||
.add_on_prices
|
||||
.remove(&Model3dAddOn::QuadMesh);
|
||||
|
||||
let error = model3d_sections_from_config(Some(&incomplete))
|
||||
.expect_err("写入方向不做宽松:缺键不能落库");
|
||||
assert!(
|
||||
error.to_string().contains("add-on"),
|
||||
"报错应说明缺 add-on,实际为:{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mod custom_world_asset_prompts;
|
||||
mod editor_agent;
|
||||
mod editor_background_music_prompt_assist;
|
||||
mod editor_generation_config;
|
||||
mod editor_generation_model3d_records;
|
||||
mod editor_generation_queue;
|
||||
mod editor_green_screen;
|
||||
mod editor_project;
|
||||
|
||||
@@ -45,6 +45,9 @@ use crate::editor_generation_config::{
|
||||
EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore,
|
||||
EditorGenerationPricingUnit,
|
||||
};
|
||||
use crate::editor_generation_model3d_records::{
|
||||
EditorGenerationModel3dSections, model3d_config_from_sections, model3d_sections_from_config,
|
||||
};
|
||||
use crate::tracking_outbox::TrackingOutbox;
|
||||
use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox};
|
||||
use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error};
|
||||
@@ -425,13 +428,17 @@ pub enum AppStateInitError {
|
||||
Llm(LlmError),
|
||||
}
|
||||
|
||||
/// 定价配置落库用的完整记录内容:`models` 段与 3D 两段同事务写入。
|
||||
struct EditorGenerationPricingRecordInput {
|
||||
models: Vec<EditorGenerationModelPricingRecord>,
|
||||
model3d: EditorGenerationModel3dSections,
|
||||
}
|
||||
|
||||
fn editor_generation_pricing_to_records(
|
||||
config: &EditorGenerationPricingConfig,
|
||||
) -> Result<Vec<EditorGenerationModelPricingRecord>, EditorGenerationPricingError> {
|
||||
) -> Result<EditorGenerationPricingRecordInput, EditorGenerationPricingError> {
|
||||
config.validate()?;
|
||||
// 只序列化 `models`:3D 定价段不在记录形状里,由文件配置提供,见
|
||||
// editor_generation_pricing_from_record 的说明。
|
||||
Ok(config
|
||||
let models = config
|
||||
.models
|
||||
.iter()
|
||||
.map(|(model, pricing)| EditorGenerationModelPricingRecord {
|
||||
@@ -451,7 +458,11 @@ fn editor_generation_pricing_to_records(
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
Ok(EditorGenerationPricingRecordInput {
|
||||
models,
|
||||
model3d: model3d_sections_from_config(config.model3d.as_ref())?,
|
||||
})
|
||||
}
|
||||
|
||||
fn editor_generation_pricing_from_record(
|
||||
@@ -508,15 +519,16 @@ fn editor_generation_pricing_from_record(
|
||||
})?;
|
||||
models.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing);
|
||||
}
|
||||
// TODO Tripo 3D 定价:后台记录形状还只有 `模型 → 档位 → 单价`,表达不了
|
||||
// endpoint × 模型版本 × 是否有贴图的底价与 add-on 叠加,因此 3D 段暂由文件配置提供,
|
||||
// 后台 payload 的 `model3d` 可空:省略 / null 一律沿用当前值(见保存路径的
|
||||
// `with_previous_model3d`),后台改价不会覆盖它;
|
||||
// 等记录形状补齐后再改为从 SpacetimeDB 读取。
|
||||
let config = EditorGenerationPricingConfig {
|
||||
models,
|
||||
model3d: legacy_fallback.model3d.clone(),
|
||||
};
|
||||
// 3D 两段以 SpacetimeDB 为准;记录里缺键(含整段缺失的老行)用本地配置同段补齐,
|
||||
// 契约之外的遗留键剔除。本地配置在这里只承担种子与兜底,不再是 3D 的权威。
|
||||
let model3d = model3d_config_from_sections(
|
||||
EditorGenerationModel3dSections {
|
||||
text_to_model_pricing: record.text_to_model_pricing,
|
||||
image_to_model_pricing: record.image_to_model_pricing,
|
||||
},
|
||||
legacy_fallback.model3d.as_ref(),
|
||||
)?;
|
||||
let config = EditorGenerationPricingConfig { models, model3d };
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
@@ -524,20 +536,19 @@ fn editor_generation_pricing_from_record(
|
||||
fn editor_generation_pricing_upsert_input(
|
||||
config: &AppConfig,
|
||||
admin_user_id: String,
|
||||
models: Vec<EditorGenerationModelPricingRecord>,
|
||||
records: EditorGenerationPricingRecordInput,
|
||||
updated_at_micros: i64,
|
||||
) -> EditorGenerationPricingConfigUpsertRecordInput {
|
||||
EditorGenerationPricingConfigUpsertRecordInput {
|
||||
admin_user_id,
|
||||
models,
|
||||
models: records.models,
|
||||
updated_at_micros,
|
||||
bootstrap_secret: config
|
||||
.spacetime_runtime_service_bootstrap_secret
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
// 3D 两段列在后续步骤接入真实值,当前保存保持空值,读取仍走本地文件缓存。
|
||||
text_to_model_pricing: None,
|
||||
image_to_model_pricing: None,
|
||||
text_to_model_pricing: records.model3d.text_to_model_pricing,
|
||||
image_to_model_pricing: records.model3d.image_to_model_pricing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,13 +827,13 @@ impl AppState {
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let models = editor_generation_pricing_to_records(&next)?;
|
||||
let records = editor_generation_pricing_to_records(&next)?;
|
||||
let record = self
|
||||
.spacetime_client
|
||||
.upsert_editor_generation_pricing_config(editor_generation_pricing_upsert_input(
|
||||
&self.config,
|
||||
admin_user_id,
|
||||
models,
|
||||
records,
|
||||
crate::editor_project::current_utc_micros(),
|
||||
))
|
||||
.await
|
||||
@@ -846,14 +857,14 @@ impl AppState {
|
||||
&self,
|
||||
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||||
let fallback = self.editor_generation_pricing_store.snapshot()?;
|
||||
let models = editor_generation_pricing_to_records(&fallback)?;
|
||||
let records = editor_generation_pricing_to_records(&fallback)?;
|
||||
let record = self
|
||||
.spacetime_client
|
||||
.initialize_editor_generation_pricing_config_if_missing(
|
||||
editor_generation_pricing_upsert_input(
|
||||
&self.config,
|
||||
"system:editor-generation-pricing".to_string(),
|
||||
models,
|
||||
records,
|
||||
crate::editor_project::current_utc_micros(),
|
||||
),
|
||||
)
|
||||
@@ -2170,14 +2181,14 @@ async fn initialize_editor_generation_runtime_service_identity_for_startup(
|
||||
let fallback = pricing_store
|
||||
.snapshot()
|
||||
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
|
||||
let models = editor_generation_pricing_to_records(&fallback)
|
||||
let records = editor_generation_pricing_to_records(&fallback)
|
||||
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
|
||||
spacetime_client
|
||||
.initialize_editor_generation_pricing_config_if_missing(
|
||||
editor_generation_pricing_upsert_input(
|
||||
config,
|
||||
"system:editor-generation-pricing".to_string(),
|
||||
models,
|
||||
records,
|
||||
crate::editor_project::current_utc_micros(),
|
||||
),
|
||||
)
|
||||
@@ -3005,6 +3016,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn empty_pricing_record_input() -> EditorGenerationPricingRecordInput {
|
||||
EditorGenerationPricingRecordInput {
|
||||
models: Vec::new(),
|
||||
model3d: EditorGenerationModel3dSections::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_pricing_typed_record_round_trips() {
|
||||
let expected = crate::editor_generation_config::parse_editor_generation_pricing_json(
|
||||
@@ -3012,15 +3030,16 @@ mod tests {
|
||||
"test default pricing",
|
||||
)
|
||||
.expect("default pricing should parse");
|
||||
let records =
|
||||
editor_generation_pricing_to_records(&expected).expect("pricing should map to records");
|
||||
let record = EditorGenerationPricingConfigRecord {
|
||||
config_id: "global".to_string(),
|
||||
models: editor_generation_pricing_to_records(&expected)
|
||||
.expect("pricing should map to records"),
|
||||
models: records.models,
|
||||
updated_by_admin_user_id: Some("admin:test".to_string()),
|
||||
updated_at: "2026-07-10T00:00:00Z".to_string(),
|
||||
updated_at_micros: 1,
|
||||
text_to_model_pricing: None,
|
||||
image_to_model_pricing: None,
|
||||
text_to_model_pricing: records.model3d.text_to_model_pricing,
|
||||
image_to_model_pricing: records.model3d.image_to_model_pricing,
|
||||
};
|
||||
|
||||
let actual = editor_generation_pricing_from_record(record, &expected)
|
||||
@@ -3036,17 +3055,19 @@ mod tests {
|
||||
"test default pricing",
|
||||
)
|
||||
.expect("default pricing should parse");
|
||||
let mut models =
|
||||
let mut records =
|
||||
editor_generation_pricing_to_records(&fallback).expect("pricing should map to records");
|
||||
models.retain(|pricing| pricing.model != EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
|
||||
records
|
||||
.models
|
||||
.retain(|pricing| pricing.model != EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
|
||||
let record = EditorGenerationPricingConfigRecord {
|
||||
config_id: "global".to_string(),
|
||||
models,
|
||||
models: records.models,
|
||||
updated_by_admin_user_id: Some("admin:test".to_string()),
|
||||
updated_at: "2026-08-07T00:00:00Z".to_string(),
|
||||
updated_at_micros: 1,
|
||||
text_to_model_pricing: None,
|
||||
image_to_model_pricing: None,
|
||||
text_to_model_pricing: records.model3d.text_to_model_pricing,
|
||||
image_to_model_pricing: records.model3d.image_to_model_pricing,
|
||||
};
|
||||
|
||||
let actual = editor_generation_pricing_from_record(record, &fallback)
|
||||
@@ -3064,7 +3085,7 @@ mod tests {
|
||||
let without_secret = editor_generation_pricing_upsert_input(
|
||||
&config,
|
||||
"admin:test".to_string(),
|
||||
Vec::new(),
|
||||
empty_pricing_record_input(),
|
||||
123,
|
||||
);
|
||||
assert_eq!(without_secret.bootstrap_secret, "");
|
||||
@@ -3073,7 +3094,7 @@ mod tests {
|
||||
let with_secret = editor_generation_pricing_upsert_input(
|
||||
&config,
|
||||
"admin:test".to_string(),
|
||||
Vec::new(),
|
||||
empty_pricing_record_input(),
|
||||
123,
|
||||
);
|
||||
|
||||
@@ -3089,17 +3110,18 @@ mod tests {
|
||||
"test default pricing",
|
||||
)
|
||||
.expect("default pricing should parse");
|
||||
let mut models =
|
||||
let mut records =
|
||||
editor_generation_pricing_to_records(&config).expect("pricing should map to records");
|
||||
models.push(models[0].clone());
|
||||
let duplicate = records.models[0].clone();
|
||||
records.models.push(duplicate);
|
||||
let record = EditorGenerationPricingConfigRecord {
|
||||
config_id: "global".to_string(),
|
||||
models,
|
||||
models: records.models,
|
||||
updated_by_admin_user_id: None,
|
||||
updated_at: "2026-07-10T00:00:00Z".to_string(),
|
||||
updated_at_micros: 1,
|
||||
text_to_model_pricing: None,
|
||||
image_to_model_pricing: None,
|
||||
text_to_model_pricing: records.model3d.text_to_model_pricing,
|
||||
image_to_model_pricing: records.model3d.image_to_model_pricing,
|
||||
};
|
||||
|
||||
let error = editor_generation_pricing_from_record(record, &config)
|
||||
|
||||
@@ -56,7 +56,7 @@ impl Model3dAddOn {
|
||||
}
|
||||
|
||||
/// 同一模型版本在「不带贴图 / 带贴图」两种形态下的底价。
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Model3dBasePrice {
|
||||
pub no_texture: u32,
|
||||
@@ -76,7 +76,7 @@ impl Model3dBasePrice {
|
||||
/// 单个端点的整套 3D 定价:模型版本底价 + 该端点自己的加价项价目。
|
||||
///
|
||||
/// 两段加价项当前数值相同,仍各持一份:Tripo 若对某个端点差异化加价,只改那一段。
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct Model3dEndpointPricing {
|
||||
pub version_prices: BTreeMap<Model3dModelVersion, Model3dBasePrice>,
|
||||
@@ -284,7 +284,7 @@ fn overflow_error() -> String {
|
||||
}
|
||||
|
||||
impl Model3dEndpointPricing {
|
||||
fn validate(&self, endpoint: Model3dEndpoint) -> Result<(), String> {
|
||||
pub(crate) fn validate(&self, endpoint: Model3dEndpoint) -> Result<(), String> {
|
||||
for model_version in Model3dModelVersion::ALL.iter().copied() {
|
||||
if !self.version_prices.contains_key(&model_version) {
|
||||
return Err(format!(
|
||||
|
||||
@@ -54,13 +54,15 @@ pub use self::editor_project::{
|
||||
EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput,
|
||||
EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord,
|
||||
EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
|
||||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorGenerationModelPricingRecord,
|
||||
EditorGenerationPricingConfigRecord, EditorGenerationPricingConfigUpsertRecordInput,
|
||||
EditorGenerationPricingTierRecord, EditorProjectCreateRecordInput,
|
||||
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord,
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectLayoutSaveV2AckRecord,
|
||||
EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
|
||||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorGenerationModel3dAddOnPriceRecord,
|
||||
EditorGenerationModel3dPricingRecord, EditorGenerationModel3dVersionPriceRecord,
|
||||
EditorGenerationModelPricingRecord, EditorGenerationPricingConfigRecord,
|
||||
EditorGenerationPricingConfigUpsertRecordInput, EditorGenerationPricingTierRecord,
|
||||
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||||
EditorProjectLayoutSaveAckRecord, EditorProjectLayoutSaveRecordInput,
|
||||
EditorProjectLayoutSaveV2AckRecord, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord,
|
||||
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
|
||||
EditorProjectResourceMediaRepairRecordInput,
|
||||
EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord,
|
||||
EditorProjectResourceShowcaseUpdateRecordInput, EditorReferenceRecord,
|
||||
EditorReferenceResolveRecordInput, EditorShowcaseAssetAdminListRecordInput,
|
||||
|
||||
Reference in New Issue
Block a user