SpacetimeDB 定价表追加 3D 两段列,记录形状先能表达端点 × 版本 × 贴图与加价项

- editor_generation_pricing_config 末尾追加 text_to_model_pricing / image_to_model_pricing 两个 Option 列并带显式默认值
- 新增模型版本底价、加价项、端点定价三个 SpacetimeType 行类型,模块侧按段校验空段、缺半段、重复键与非正价格
- upsert 输入、快照、生成绑定与客户端记录映射同步两段字段
- api-server 保存路径本次先写空值,读取仍走本地文件缓存;补模块段级归一化用例
This commit is contained in:
2026-09-23 10:54:01 +08:00
parent 1327cd53b7
commit 774b74d3d6
11 changed files with 335 additions and 0 deletions
+9
View File
@@ -535,6 +535,9 @@ fn editor_generation_pricing_upsert_input(
.spacetime_runtime_service_bootstrap_secret
.clone()
.unwrap_or_default(),
// 3D 两段列在后续步骤接入真实值,当前保存保持空值,读取仍走本地文件缓存。
text_to_model_pricing: None,
image_to_model_pricing: None,
}
}
@@ -3013,6 +3016,8 @@ mod tests {
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,
};
let actual = editor_generation_pricing_from_record(record, &expected)
@@ -3037,6 +3042,8 @@ mod tests {
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,
};
let actual = editor_generation_pricing_from_record(record, &fallback)
@@ -3088,6 +3095,8 @@ mod tests {
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,
};
let error = editor_generation_pricing_from_record(record, &config)
@@ -251,6 +251,27 @@ pub struct EditorGenerationPricingConfigRecord {
pub updated_by_admin_user_id: Option<String>,
pub updated_at: String,
pub updated_at_micros: i64,
pub text_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
pub image_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EditorGenerationModel3dVersionPriceRecord {
pub model_version: String,
pub no_texture: u32,
pub texture: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EditorGenerationModel3dAddOnPriceRecord {
pub add_on: String,
pub price: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EditorGenerationModel3dPricingRecord {
pub version_prices: Vec<EditorGenerationModel3dVersionPriceRecord>,
pub add_on_prices: Vec<EditorGenerationModel3dAddOnPriceRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -564,6 +585,8 @@ pub struct EditorGenerationPricingConfigUpsertRecordInput {
pub models: Vec<EditorGenerationModelPricingRecord>,
pub updated_at_micros: i64,
pub bootstrap_secret: String,
pub text_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
pub image_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
}
impl From<EditorProjectCreateRecordInput> for crate::module_bindings::EditorProjectCreateInput {
@@ -1020,10 +1043,68 @@ impl From<EditorGenerationPricingConfigUpsertRecordInput>
.collect(),
updated_at_micros: input.updated_at_micros,
bootstrap_secret: input.bootstrap_secret,
text_to_model_pricing: input
.text_to_model_pricing
.map(map_editor_generation_model3d_pricing_record),
image_to_model_pricing: input
.image_to_model_pricing
.map(map_editor_generation_model3d_pricing_record),
}
}
}
fn map_editor_generation_model3d_pricing_record(
pricing: EditorGenerationModel3dPricingRecord,
) -> crate::module_bindings::EditorGenerationModel3DPricing {
crate::module_bindings::EditorGenerationModel3DPricing {
version_prices: pricing
.version_prices
.into_iter()
.map(
|entry| crate::module_bindings::EditorGenerationModel3DVersionPrice {
model_version: entry.model_version,
no_texture: entry.no_texture,
texture: entry.texture,
},
)
.collect(),
add_on_prices: pricing
.add_on_prices
.into_iter()
.map(
|entry| crate::module_bindings::EditorGenerationModel3DAddOnPrice {
add_on: entry.add_on,
price: entry.price,
},
)
.collect(),
}
}
fn map_editor_generation_model3d_pricing_snapshot(
pricing: crate::module_bindings::EditorGenerationModel3DPricing,
) -> EditorGenerationModel3dPricingRecord {
EditorGenerationModel3dPricingRecord {
version_prices: pricing
.version_prices
.into_iter()
.map(|entry| EditorGenerationModel3dVersionPriceRecord {
model_version: entry.model_version,
no_texture: entry.no_texture,
texture: entry.texture,
})
.collect(),
add_on_prices: pricing
.add_on_prices
.into_iter()
.map(|entry| EditorGenerationModel3dAddOnPriceRecord {
add_on: entry.add_on,
price: entry.price,
})
.collect(),
}
}
pub(crate) fn map_editor_project_optional_procedure_result(
result: EditorProjectProcedureResult,
) -> Result<Option<EditorProjectRecord>, SpacetimeClientError> {
@@ -1627,6 +1708,12 @@ fn map_editor_generation_pricing_config_snapshot(
updated_by_admin_user_id: snapshot.updated_by_admin_user_id,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
text_to_model_pricing: snapshot
.text_to_model_pricing
.map(map_editor_generation_model3d_pricing_snapshot),
image_to_model_pricing: snapshot
.image_to_model_pricing
.map(map_editor_generation_model3d_pricing_snapshot),
}
}
@@ -302,6 +302,9 @@ pub mod editor_canvas_table;
pub mod editor_canvas_type;
pub mod editor_character_animation_normalization_input_type;
pub mod editor_character_animation_normalization_procedure_result_type;
pub mod editor_generation_model_3_d_add_on_price_type;
pub mod editor_generation_model_3_d_pricing_type;
pub mod editor_generation_model_3_d_version_price_type;
pub mod editor_generation_model_pricing_type;
pub mod editor_generation_operation_table;
pub mod editor_generation_operation_type;
@@ -1213,6 +1216,9 @@ pub use editor_canvas_table::*;
pub use editor_canvas_type::EditorCanvas;
pub use editor_character_animation_normalization_input_type::EditorCharacterAnimationNormalizationInput;
pub use editor_character_animation_normalization_procedure_result_type::EditorCharacterAnimationNormalizationProcedureResult;
pub use editor_generation_model_3_d_add_on_price_type::EditorGenerationModel3DAddOnPrice;
pub use editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing;
pub use editor_generation_model_3_d_version_price_type::EditorGenerationModel3DVersionPrice;
pub use editor_generation_model_pricing_type::EditorGenerationModelPricing;
pub use editor_generation_operation_table::*;
pub use editor_generation_operation_type::EditorGenerationOperation;
@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorGenerationModel3DAddOnPrice {
pub add_on: String,
pub price: u32,
}
impl __sdk::InModule for EditorGenerationModel3DAddOnPrice {
type Module = super::RemoteModule;
}
@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_generation_model_3_d_add_on_price_type::EditorGenerationModel3DAddOnPrice;
use super::editor_generation_model_3_d_version_price_type::EditorGenerationModel3DVersionPrice;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorGenerationModel3DPricing {
pub version_prices: Vec<EditorGenerationModel3DVersionPrice>,
pub add_on_prices: Vec<EditorGenerationModel3DAddOnPrice>,
}
impl __sdk::InModule for EditorGenerationModel3DPricing {
type Module = super::RemoteModule;
}
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorGenerationModel3DVersionPrice {
pub model_version: String,
pub no_texture: u32,
pub texture: u32,
}
impl __sdk::InModule for EditorGenerationModel3DVersionPrice {
type Module = super::RemoteModule;
}
@@ -4,6 +4,7 @@
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing;
use super::editor_generation_model_pricing_type::EditorGenerationModelPricing;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
@@ -13,6 +14,8 @@ pub struct EditorGenerationPricingConfigSnapshot {
pub models: Vec<EditorGenerationModelPricing>,
pub updated_by_admin_user_id: Option<String>,
pub updated_at_micros: i64,
pub text_to_model_pricing: Option<EditorGenerationModel3DPricing>,
pub image_to_model_pricing: Option<EditorGenerationModel3DPricing>,
}
impl __sdk::InModule for EditorGenerationPricingConfigSnapshot {
@@ -2,6 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing;
use super::editor_generation_model_pricing_type::EditorGenerationModelPricing;
use super::editor_generation_pricing_config_type::EditorGenerationPricingConfig;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
@@ -4,6 +4,7 @@
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing;
use super::editor_generation_model_pricing_type::EditorGenerationModelPricing;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
@@ -14,6 +15,8 @@ pub struct EditorGenerationPricingConfig {
pub updated_by_admin_user_id: Option<String>,
pub updated_at: __sdk::Timestamp,
pub writer_identity: __sdk::Identity,
pub text_to_model_pricing: Option<EditorGenerationModel3DPricing>,
pub image_to_model_pricing: Option<EditorGenerationModel3DPricing>,
}
impl __sdk::InModule for EditorGenerationPricingConfig {
@@ -34,6 +37,14 @@ pub struct EditorGenerationPricingConfigCols {
pub updated_at: __sdk::__query_builder::Col<EditorGenerationPricingConfig, __sdk::Timestamp>,
pub writer_identity:
__sdk::__query_builder::Col<EditorGenerationPricingConfig, __sdk::Identity>,
pub text_to_model_pricing: __sdk::__query_builder::Col<
EditorGenerationPricingConfig,
Option<EditorGenerationModel3DPricing>,
>,
pub image_to_model_pricing: __sdk::__query_builder::Col<
EditorGenerationPricingConfig,
Option<EditorGenerationModel3DPricing>,
>,
}
impl __sdk::__query_builder::HasCols for EditorGenerationPricingConfig {
@@ -48,6 +59,14 @@ impl __sdk::__query_builder::HasCols for EditorGenerationPricingConfig {
),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
writer_identity: __sdk::__query_builder::Col::new(table_name, "writer_identity"),
text_to_model_pricing: __sdk::__query_builder::Col::new(
table_name,
"text_to_model_pricing",
),
image_to_model_pricing: __sdk::__query_builder::Col::new(
table_name,
"image_to_model_pricing",
),
}
}
}
@@ -4,6 +4,7 @@
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing;
use super::editor_generation_model_pricing_type::EditorGenerationModelPricing;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
@@ -13,6 +14,8 @@ pub struct EditorGenerationPricingConfigUpsertInput {
pub models: Vec<EditorGenerationModelPricing>,
pub updated_at_micros: i64,
pub bootstrap_secret: String,
pub text_to_model_pricing: Option<EditorGenerationModel3DPricing>,
pub image_to_model_pricing: Option<EditorGenerationModel3DPricing>,
}
impl __sdk::InModule for EditorGenerationPricingConfigUpsertInput {
@@ -108,6 +108,28 @@ pub struct EditorGenerationModelPricing {
pub prices: Vec<EditorGenerationPricingTier>,
}
/// 一个模型版本在「无贴图 / 带贴图」两种形态下的 3D 生成底价。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorGenerationModel3dVersionPrice {
pub model_version: String,
pub no_texture: u32,
pub texture: u32,
}
/// 叠加在 3D 生成底价之上的加价项单价。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorGenerationModel3dAddOnPrice {
pub add_on: String,
pub price: u32,
}
/// 单个 3D 生成端点的整套定价:模型版本底价与该端点自己的加价项价目。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorGenerationModel3dPricing {
pub version_prices: Vec<EditorGenerationModel3dVersionPrice>,
pub add_on_prices: Vec<EditorGenerationModel3dAddOnPrice>,
}
#[spacetimedb::table(
accessor = editor_project,
index(accessor = by_editor_project_owner_user_id, btree(columns = [owner_user_id]))
@@ -455,6 +477,10 @@ pub struct EditorGenerationPricingConfig {
updated_by_admin_user_id: Option<String>,
updated_at: Timestamp,
writer_identity: Identity,
#[default(None::<EditorGenerationModel3dPricing>)]
text_to_model_pricing: Option<EditorGenerationModel3dPricing>,
#[default(None::<EditorGenerationModel3dPricing>)]
image_to_model_pricing: Option<EditorGenerationModel3dPricing>,
}
#[spacetimedb::table(accessor = editor_generation_runtime_identity_rotation)]
@@ -957,6 +983,8 @@ pub struct EditorGenerationPricingConfigSnapshot {
pub models: Vec<EditorGenerationModelPricing>,
pub updated_by_admin_user_id: Option<String>,
pub updated_at_micros: i64,
pub text_to_model_pricing: Option<EditorGenerationModel3dPricing>,
pub image_to_model_pricing: Option<EditorGenerationModel3dPricing>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -1304,6 +1332,8 @@ pub struct EditorGenerationPricingConfigUpsertInput {
pub models: Vec<EditorGenerationModelPricing>,
pub updated_at_micros: i64,
pub bootstrap_secret: String,
pub text_to_model_pricing: Option<EditorGenerationModel3dPricing>,
pub image_to_model_pricing: Option<EditorGenerationModel3dPricing>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -6786,6 +6816,14 @@ fn upsert_editor_generation_pricing_config(
"editor_generation_pricing_config.updated_by_admin_user_id",
)?;
let models = normalize_editor_generation_pricing_models(input.models)?;
let text_to_model_pricing = normalize_editor_generation_model3d_pricing(
input.text_to_model_pricing,
"editor_generation_pricing_config.text_to_model_pricing",
)?;
let image_to_model_pricing = normalize_editor_generation_model3d_pricing(
input.image_to_model_pricing,
"editor_generation_pricing_config.image_to_model_pricing",
)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let config_id = EDITOR_GENERATION_PRICING_CONFIG_ID.to_string();
let writer_identity = resolve_editor_generation_pricing_writer_identity(
@@ -6806,6 +6844,8 @@ fn upsert_editor_generation_pricing_config(
updated_by_admin_user_id: Some(admin_user_id),
updated_at,
writer_identity,
text_to_model_pricing,
image_to_model_pricing,
});
ctx.db
.editor_generation_pricing_config()
@@ -6844,6 +6884,14 @@ fn initialize_editor_generation_pricing_config_if_missing(
"editor_generation_pricing_config.updated_by_admin_user_id",
)?;
let models = normalize_editor_generation_pricing_models(input.models)?;
let text_to_model_pricing = normalize_editor_generation_model3d_pricing(
input.text_to_model_pricing,
"editor_generation_pricing_config.text_to_model_pricing",
)?;
let image_to_model_pricing = normalize_editor_generation_model3d_pricing(
input.image_to_model_pricing,
"editor_generation_pricing_config.image_to_model_pricing",
)?;
let row = ctx
.db
.editor_generation_pricing_config()
@@ -6853,6 +6901,8 @@ fn initialize_editor_generation_pricing_config_if_missing(
updated_by_admin_user_id: Some(admin_user_id),
updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros),
writer_identity: caller,
text_to_model_pricing,
image_to_model_pricing,
});
Ok(editor_generation_pricing_config_snapshot_from_row(row))
}
@@ -7923,9 +7973,52 @@ fn editor_generation_pricing_config_snapshot_from_row(
models: row.models,
updated_by_admin_user_id: row.updated_by_admin_user_id,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
text_to_model_pricing: row.text_to_model_pricing,
image_to_model_pricing: row.image_to_model_pricing,
}
}
/// 3D 生成定价两个端点的段级校验:段允许整体缺失,非空时必须同时给出底价与加价项、
/// 键唯一且价格为正。契约支持的模型版本与加价项集合由 api-server 侧的枚举负责,
/// 模块这里只守存储形状,避免把模型版本清单复制成第二份真相。
fn normalize_editor_generation_model3d_pricing(
pricing: Option<EditorGenerationModel3dPricing>,
label: &str,
) -> Result<Option<EditorGenerationModel3dPricing>, String> {
let Some(mut pricing) = pricing else {
return Ok(None);
};
if pricing.version_prices.is_empty() || pricing.add_on_prices.is_empty() {
return Err(format!("{label} 必须同时给出模型版本底价与加价项"));
}
let mut seen_versions = BTreeSet::new();
for entry in pricing.version_prices.iter_mut() {
let model_version = normalize_required(&entry.model_version, label)?;
if !seen_versions.insert(model_version.clone()) {
return Err(format!("{label} 包含重复模型版本 {model_version}"));
}
if entry.no_texture == 0 || entry.texture == 0 {
return Err(format!("{label} 的模型版本 {model_version} 价格必须大于 0"));
}
entry.model_version = model_version;
}
let mut seen_add_ons = BTreeSet::new();
for entry in pricing.add_on_prices.iter_mut() {
let add_on = normalize_required(&entry.add_on, label)?;
if !seen_add_ons.insert(add_on.clone()) {
return Err(format!("{label} 包含重复加价项 {add_on}"));
}
if entry.price == 0 {
return Err(format!("{label} 的加价项 {add_on} 价格必须大于 0"));
}
entry.add_on = add_on;
}
Ok(Some(pricing))
}
fn normalize_editor_generation_pricing_models(
models: Vec<EditorGenerationModelPricing>,
) -> Result<Vec<EditorGenerationModelPricing>, String> {
@@ -20023,11 +20116,73 @@ mod tests {
models: valid_editor_generation_pricing_models(),
updated_by_admin_user_id: Some("admin:test".to_string()),
updated_at_micros: 1,
text_to_model_pricing: None,
image_to_model_pricing: None,
};
assert!(!format!("{snapshot:?}").contains("writer_identity"));
}
#[test]
fn normalize_editor_generation_model3d_pricing_accepts_absent_and_complete_sections() {
assert_eq!(
normalize_editor_generation_model3d_pricing(None, ""),
Ok(None)
);
let pricing = EditorGenerationModel3dPricing {
version_prices: vec![EditorGenerationModel3dVersionPrice {
model_version: "v3.1-20260211".to_string(),
no_texture: 8,
texture: 16,
}],
add_on_prices: vec![EditorGenerationModel3dAddOnPrice {
add_on: " hdTexture ".to_string(),
price: 8,
}],
};
let normalized = normalize_editor_generation_model3d_pricing(Some(pricing), "")
.expect("complete section should normalize");
let normalized = normalized.expect("section should stay present");
assert_eq!(normalized.add_on_prices[0].add_on, "hdTexture");
}
#[test]
fn normalize_editor_generation_model3d_pricing_rejects_partial_and_duplicate_keys() {
let partial = EditorGenerationModel3dPricing {
version_prices: vec![EditorGenerationModel3dVersionPrice {
model_version: "v3.1-20260211".to_string(),
no_texture: 8,
texture: 16,
}],
add_on_prices: Vec::new(),
};
assert!(normalize_editor_generation_model3d_pricing(Some(partial), "").is_err());
let duplicate = EditorGenerationModel3dPricing {
version_prices: vec![
EditorGenerationModel3dVersionPrice {
model_version: "v3.1-20260211".to_string(),
no_texture: 8,
texture: 16,
},
EditorGenerationModel3dVersionPrice {
model_version: "v3.1-20260211".to_string(),
no_texture: 10,
texture: 18,
},
],
add_on_prices: vec![EditorGenerationModel3dAddOnPrice {
add_on: "hdTexture".to_string(),
price: 8,
}],
};
let error = normalize_editor_generation_model3d_pricing(Some(duplicate), "")
.expect_err("duplicate model version should fail");
assert!(error.contains("重复模型版本"));
}
#[test]
fn normalize_editor_generation_pricing_models_rejects_missing_required_model() {
let mut models = valid_editor_generation_pricing_models();