From 050909a0b7f9db48e6796f698c2c4a9a1b9acb5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 15:27:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=AE=9A=E4=BB=B7=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=B8=8D=E5=86=8D=E6=B8=85=E7=A9=BA=203D=20=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - payload 的 model3d 缺席或为 null 统一表示沿用当前 3D 定价,显式给出时才覆盖 - 保存路径先取当前快照合并,避免改图 / 视频价格让 3D 提交一直失败关闭 - 补用例覆盖「省略保持」与「显式覆盖」两种语义 --- .../src/editor_generation_config.rs | 11 ++++ server-rs/crates/api-server/src/state.rs | 51 ++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 0447aa4af..960c77cd3 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -110,6 +110,17 @@ impl EditorGenerationPricingConfig { .price(query) } + /// 后台 payload 的「可省略」语义:`model3d` 缺席或为 null 表示沿用当前 3D 段。 + /// + /// 3D 段目前只由文件配置提供(后台记录形状还表达不了 endpoint × 模型版本 × 是否带贴图 + /// 的底价与 add-on 叠加),因此保存图 / 视频价格时绝不能把它清成 None。 + pub(crate) fn with_previous_model3d(mut self, previous: &Self) -> Self { + if self.model3d.is_none() { + self.model3d = previous.model3d.clone(); + } + self + } + pub(crate) fn image_model_mud_points( &self, model: Option<&str>, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index e488c7e4f..69f07a072 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -510,7 +510,9 @@ fn editor_generation_pricing_from_record( } // TODO Tripo 3D 定价:后台记录形状还只有 `模型 → 档位 → 单价`,表达不了 // endpoint × 模型版本 × 是否有贴图的底价与 add-on 叠加,因此 3D 段暂由文件配置提供, - // 后台改价不会覆盖它;等记录形状补齐后再改为从 SpacetimeDB 读取。 + // 后台 payload 的 `model3d` 可空:省略 / null 一律沿用当前值(见保存路径的 + // `with_previous_model3d`),后台改价不会覆盖它; + // 等记录形状补齐后再改为从 SpacetimeDB 读取。 let config = EditorGenerationPricingConfig { models, model3d: legacy_fallback.model3d.clone(), @@ -797,6 +799,12 @@ impl AppState { admin_user_id: String, next: EditorGenerationPricingConfig, ) -> Result { + // 后台 payload 的 `model3d` 允许缺席或为 null:两种情况都表示沿用当前 3D 定价。 + // 3D 段现在只由文件配置提供,若不合并,改一次图 / 视频价格就会把它清成 None, + // 3D 提交会一直失败关闭到进程重启。 + let previous = self.editor_generation_pricing_store.snapshot()?; + let next = next.with_previous_model3d(&previous); + #[cfg(test)] { let _ = admin_user_id; @@ -2778,6 +2786,47 @@ mod tests { use spacetime_client::SpacetimeClientStage; use super::*; + use crate::tripo3d::pricing::Model3dAddOn; + + /// 后台 payload 省略 / 置空 3D 段时必须沿用当前 3D 定价: + /// 改一次图 / 视频价格不能把文件提供的 3D 段清掉。 + #[tokio::test] + async fn saving_pricing_without_model3d_keeps_existing_model3d() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let before = state + .editor_generation_pricing() + .await + .expect("默认定价必须可读"); + let default_model3d = before.model3d.clone().expect("默认配置必须带 3D 定价段"); + + let mut omitted = before.clone(); + omitted.model3d = None; + let saved = state + .save_editor_generation_pricing("admin:test".to_string(), omitted) + .await + .expect("省略 3D 段的保存必须成功"); + assert_eq!(saved.model3d.as_ref(), Some(&default_model3d)); + + // 显式给出 3D 段时按 payload 生效,而不是无脑沿用旧值。 + let mut custom = default_model3d.clone(); + custom.add_on_prices.insert(Model3dAddOn::QuadMesh, 7); + let mut explicit = before.clone(); + explicit.model3d = Some(custom.clone()); + let saved = state + .save_editor_generation_pricing("admin:test".to_string(), explicit) + .await + .expect("显式 3D 段的保存必须成功"); + assert_eq!(saved.model3d.as_ref(), Some(&custom)); + assert_eq!( + state + .editor_generation_pricing() + .await + .expect("定价必须可读") + .model3d + .as_ref(), + Some(&custom) + ); + } #[test] fn template_library_credentials_never_mix_dedicated_and_general_pairs() {