From ee4a9434fa98bdf07c4d581d3dc8746c31eb7abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 11:38:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=AE=9A=E4=BB=B7=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=B8=A6=E7=89=88=E6=9C=AC=E3=80=81=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E6=95=B4=E6=AE=B5=E5=BF=85=E5=A1=AB=E5=B9=B6=E5=81=9A=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=E5=86=85=E4=B9=90=E8=A7=82=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 新增定价版本冲突标记,module 与 api-server 共用一处声明 - SpacetimeDB upsert input 增加期望版本,事务内比对不一致即整笔拒绝(种子写入显式不比对) - 定价 store 记录当前版本,读取路径带出 updated_at 微秒值 - 后台 GET 返回 models / 3D 两段 / updatedAtMicros,POST 要求两段显式给出且带期望版本 - 缺 3D 段 400、版本不匹配 409,退役 with_previous_model3d 与「省略即沿用」用例 - 绑定按 input 新字段重新生成,模块补乐观锁比对用例 --- server-rs/crates/api-server/src/admin.rs | 92 +++++- server-rs/crates/api-server/src/app.rs | 270 ++++++++++++++---- .../src/editor_generation_config.rs | 30 +- server-rs/crates/api-server/src/state.rs | 157 +++++++--- .../shared-contracts/src/editor_generation.rs | 7 + .../src/active/mapper/editor_project.rs | 3 + ...ration_pricing_config_upsert_input_type.rs | 1 + .../src/editor_project_storage.rs | 55 ++++ 8 files changed, 497 insertions(+), 118 deletions(-) diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index c05c18084..077a776ac 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -18,7 +18,7 @@ use axum::{ }; use platform_auth::{hash_password, verify_password}; use reqwest::Client; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use shared_contracts::admin::{ ADMIN_ACTION_PERMISSIONS, ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, @@ -78,7 +78,7 @@ use crate::{ AssetReadAuthorization, confirm_asset_object_for_owner, create_direct_upload_ticket_for_owner, get_asset_read_url_with_query, }, - editor_generation_config::EditorGenerationPricingConfig, + editor_generation_config::{EditorGenerationModelPricing, EditorGenerationPricingConfig}, editor_project::{ current_utc_micros, normalize_editor_image_sequence_frames_value, resolve_editor_asset_kind, sanitize_editor_generation_inputs, @@ -87,6 +87,7 @@ use crate::{ request_context::RequestContext, state::{AdminRuntime, AppState}, tracking::{TrackingEventDraft, record_tracking_event_after_success}, + tripo3d::pricing::Model3dPricingConfig, work_author::resolve_work_author_by_user_id, }; @@ -948,17 +949,41 @@ pub async fn admin_upsert_feature_gate_config( )) } +/// 后台定价响应:`models` 与 3D 两段原样给出,外加当前定价版本。 +/// +/// 版本是后台保存的乐观锁凭据:页面保存时把读到的值原样回传,服务端比对不上就整体拒绝。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminEditorGenerationPricingResponse { + pub models: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub model3d: Option, + pub updated_at_micros: i64, +} + +/// 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,不接受「省略即沿用」。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertEditorGenerationPricingRequest { + pub models: BTreeMap, + pub model3d: Option, + pub expected_updated_at_micros: Option, +} + /// 后台读取画布生成模型定价配置。 pub async fn admin_get_editor_generation_pricing( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { - let pricing = state - .editor_generation_pricing() + let (pricing, updated_at_micros) = state + .editor_generation_pricing_with_version() .await .map_err(map_admin_editor_generation_pricing_error)?; - Ok(json_success_body(Some(&request_context), pricing)) + Ok(json_success_body( + Some(&request_context), + build_admin_editor_generation_pricing_response(pricing, updated_at_micros), + )) } /// 后台保存画布生成模型定价配置,写入 SpacetimeDB 全局配置表。 @@ -966,13 +991,58 @@ pub async fn admin_upsert_editor_generation_pricing( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, - Json(payload): Json, + Json(payload): Json, ) -> Result, AppError> { - let pricing = state - .save_editor_generation_pricing(admin.session().subject.clone(), payload) + let (pricing, expected_updated_at_micros) = + validate_admin_editor_generation_pricing_payload(payload)?; + let (pricing, updated_at_micros) = state + .save_editor_generation_pricing( + admin.session().subject.clone(), + pricing, + expected_updated_at_micros, + ) .await .map_err(map_admin_editor_generation_pricing_error)?; - Ok(json_success_body(Some(&request_context), pricing)) + Ok(json_success_body( + Some(&request_context), + build_admin_editor_generation_pricing_response(pricing, updated_at_micros), + )) +} + +fn build_admin_editor_generation_pricing_response( + pricing: EditorGenerationPricingConfig, + updated_at_micros: i64, +) -> AdminEditorGenerationPricingResponse { + AdminEditorGenerationPricingResponse { + models: pricing.models, + model3d: pricing.model3d, + updated_at_micros, + } +} + +/// 保存前的形状校验:3D 两段与定价版本都必须显式给出。 +/// +/// 3D 段不能省略或置空——那正是「改一次图 / 视频价就把 3D 段清掉」这类静默覆盖的来源; +/// 键集合由契约决定,后台只能改数值,因此这里不接受的只是「没给」和「给不齐」。 +fn validate_admin_editor_generation_pricing_payload( + payload: AdminUpsertEditorGenerationPricingRequest, +) -> Result<(EditorGenerationPricingConfig, i64), AppError> { + let Some(model3d) = payload.model3d else { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message( + "model3d 定价段必须整段给出(含 textToModelPricing 与 imageToModelPricing),不能省略或置空", + )); + }; + let Some(expected_updated_at_micros) = payload.expected_updated_at_micros else { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("缺少 expectedUpdatedAtMicros,请重新读取当前定价后再保存")); + }; + Ok(( + EditorGenerationPricingConfig { + models: payload.models, + model3d: Some(model3d), + }, + expected_updated_at_micros, + )) } #[cfg(any())] @@ -2022,6 +2092,10 @@ fn map_admin_editor_generation_pricing_error( crate::editor_generation_config::EditorGenerationPricingError::Invalid(_) => { StatusCode::BAD_REQUEST } + // 乐观锁冲突:客户端必须重新读取,不能重试同一个 payload。 + crate::editor_generation_config::EditorGenerationPricingError::Conflict => { + StatusCode::CONFLICT + } _ => StatusCode::INTERNAL_SERVER_ERROR, }; AppError::from_status(status).with_details(serde_json::json!({ diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 135e54a6b..d2b9c946d 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -5946,72 +5946,102 @@ mod tests { assert!(payload["model3d"]["textToModelPricing"].is_null()); } - #[tokio::test] - async fn admin_editor_generation_pricing_route_saves_config_and_updates_public_route() { + /// 后台定价读取与保存共用的登录态:测试里返回应用与管理员 token。 + async fn admin_pricing_test_app() -> (Router, String) { let mut config = AppConfig::default(); config.admin_username = Some("root".to_string()); config.admin_password = Some("secret123".to_string()); let app = build_router(AppState::new(config).expect("state should build")); let admin_token = read_admin_access_token(app.clone()).await; + (app, admin_token) + } + /// 后台定价读取:返回完整响应体,包含定价版本与 3D 两段。 + async fn read_admin_editor_generation_pricing(app: &Router, admin_token: &str) -> Value { let response = app .clone() + .oneshot( + Request::builder() + .uri("/admin/api/editor-generation-pricing") + .header("authorization", format!("Bearer {admin_token}")) + .body(Body::empty()) + .expect("admin pricing request should build"), + ) + .await + .expect("admin pricing request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("admin pricing body should collect") + .to_bytes(); + serde_json::from_slice(&body).expect("admin pricing payload should be json") + } + + /// 后台保存用的完整模型矩阵:数值都改成与默认值不同,便于断言真的落库了。 + fn admin_pricing_models_payload() -> Value { + serde_json::json!({ + "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 } + }) + } + + async fn save_admin_editor_generation_pricing( + app: &Router, + admin_token: &str, + payload: Value, + ) -> axum::response::Response { + app.clone() .oneshot( Request::builder() .method("POST") .uri("/admin/api/editor-generation-pricing") .header("authorization", format!("Bearer {admin_token}")) .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "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 } - } - }) - .to_string(), - )) + .body(Body::from(payload.to_string())) .expect("pricing save request should build"), ) .await - .expect("pricing save request should succeed"); + .expect("pricing save request should succeed") + } - assert_eq!(response.status(), StatusCode::OK); - - let public_response = app + async fn read_public_editor_generation_pricing(app: &Router) -> Value { + let response = app + .clone() .oneshot( Request::builder() .uri("/api/editor/generation-pricing") @@ -6020,15 +6050,60 @@ mod tests { ) .await .expect("public pricing request should succeed"); - let body = public_response + let body = response .into_body() .collect() .await .expect("public pricing body should collect") .to_bytes(); - let payload: Value = - serde_json::from_slice(&body).expect("public pricing payload should be json"); + serde_json::from_slice(&body).expect("public pricing payload should be json") + } + #[tokio::test] + async fn admin_editor_generation_pricing_route_saves_config_and_updates_public_route() { + let (app, admin_token) = admin_pricing_test_app().await; + + // 与后台页面同一套流程:先读旧值与版本,改完原样回传版本做乐观锁。 + let before = read_admin_editor_generation_pricing(&app, &admin_token).await; + let version = before["updatedAtMicros"] + .as_i64() + .expect("后台定价必须返回版本"); + let mut model3d = before["model3d"].clone(); + assert!(!model3d.is_null(), "默认定价必须带 3D 两段"); + model3d["textToModelPricing"]["addOnPrices"]["quadMesh"] = Value::Number(9.into()); + model3d["imageToModelPricing"]["versionPrices"]["P2-20260801"]["texture"] = + Value::Number(99.into()); + + let response = save_admin_editor_generation_pricing( + &app, + &admin_token, + serde_json::json!({ + "models": admin_pricing_models_payload(), + "model3d": model3d, + "expectedUpdatedAtMicros": version, + }), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("pricing save body should collect") + .to_bytes(); + let saved: Value = + serde_json::from_slice(&body).expect("pricing save payload should be json"); + let saved_version = saved["updatedAtMicros"] + .as_i64() + .expect("保存回包必须带新版本"); + assert!(saved_version > version, "保存必须推进定价版本"); + assert_eq!( + saved["model3d"]["textToModelPricing"]["addOnPrices"]["quadMesh"], + Value::Number(9.into()) + ); + + let payload = read_public_editor_generation_pricing(&app).await; assert_eq!( payload["models"]["gpt-image-2"]["prices"]["2K"], Value::Number(62.into()) @@ -6045,6 +6120,99 @@ mod tests { payload["models"]["seedance2.0"]["prices"]["720p"], Value::Number(26.into()) ); + // 公开读模型仍是旧形状,但数值必须已经切到后台保存的新价。 + assert_eq!( + payload["model3d"]["addOnPrices"]["quadMesh"], + Value::Number(9.into()) + ); + assert_eq!( + payload["model3d"]["basePrices"]["image-to-model"]["P2-20260801"]["texture"], + Value::Number(99.into()) + ); + } + + #[tokio::test] + async fn admin_editor_generation_pricing_route_rejects_missing_model3d_section() { + let (app, admin_token) = admin_pricing_test_app().await; + let before = read_admin_editor_generation_pricing(&app, &admin_token).await; + let version = before["updatedAtMicros"] + .as_i64() + .expect("后台定价必须返回版本"); + + let response = save_admin_editor_generation_pricing( + &app, + &admin_token, + serde_json::json!({ + "models": admin_pricing_models_payload(), + "expectedUpdatedAtMicros": version, + }), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("pricing body should collect") + .to_bytes(); + let payload: Value = serde_json::from_slice(&body).expect("pricing payload should be json"); + assert!( + payload["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("model3d")), + "缺 3D 段必须以中文文案拒绝,实际为 {payload}" + ); + // 拒绝的保存不能落库:旧价与旧版本都必须原样保留。 + let after = read_admin_editor_generation_pricing(&app, &admin_token).await; + assert_eq!(after["updatedAtMicros"], before["updatedAtMicros"]); + assert_eq!( + after["models"]["gpt-image-2"]["prices"]["2K"], + before["models"]["gpt-image-2"]["prices"]["2K"] + ); + } + + #[tokio::test] + async fn admin_editor_generation_pricing_route_rejects_stale_version() { + let (app, admin_token) = admin_pricing_test_app().await; + let before = read_admin_editor_generation_pricing(&app, &admin_token).await; + let version = before["updatedAtMicros"] + .as_i64() + .expect("后台定价必须返回版本"); + let model3d = before["model3d"].clone(); + + let first = save_admin_editor_generation_pricing( + &app, + &admin_token, + serde_json::json!({ + "models": admin_pricing_models_payload(), + "model3d": model3d, + "expectedUpdatedAtMicros": version, + }), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + + // 第二个客户端拿着过期版本保存:必须 409,且不能把已保存的价格覆盖回去。 + let mut stale_models = admin_pricing_models_payload(); + stale_models["gpt-image-2"]["prices"]["2K"] = Value::Number(1.into()); + let stale = save_admin_editor_generation_pricing( + &app, + &admin_token, + serde_json::json!({ + "models": stale_models, + "model3d": model3d, + "expectedUpdatedAtMicros": version, + }), + ) + .await; + assert_eq!(stale.status(), StatusCode::CONFLICT); + + let after = read_admin_editor_generation_pricing(&app, &admin_token).await; + assert_eq!( + after["models"]["gpt-image-2"]["prices"]["2K"], + Value::Number(62.into()) + ); } /// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。 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 4371cb2b9..614411d3b 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -2,7 +2,10 @@ use std::{ collections::BTreeMap, fmt, fs, io, path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::{ + Arc, RwLock, + atomic::{AtomicI64, Ordering}, + }, }; use serde::{Deserialize, Serialize}; @@ -99,6 +102,9 @@ pub(crate) struct EditorGenerationPricingPublicResponse { #[derive(Clone, Debug)] pub(crate) struct EditorGenerationPricingStore { current: Arc>, + /// 当前配置的定价版本:SpacetimeDB 行的 `updated_at` 微秒值。0 表示还没读到过行 + /// (表为空或读不到),后台保存的乐观锁就比对它。 + version_micros: Arc, } #[derive(Debug)] @@ -108,6 +114,8 @@ pub(crate) enum EditorGenerationPricingError { Invalid(String), /// 3D 定价查询失败:结构化保留「未配置 / 缺底价 / 缺加价项」三种语义。 Model3d(Model3dPricingError), + /// 保存时发现定价已被他人改动:后台必须重新读取后再保存,不能静默覆盖。 + Conflict, #[cfg_attr(test, allow(dead_code))] Persistence(String), LockPoisoned, @@ -144,17 +152,6 @@ impl EditorGenerationPricingConfig { .map_err(Into::into) } - /// 后台 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>, @@ -323,9 +320,15 @@ impl EditorGenerationPricingStore { 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 { @@ -338,6 +341,7 @@ impl EditorGenerationPricingStore { pub(crate) fn replace( &self, next: EditorGenerationPricingConfig, + version_micros: i64, ) -> Result { next.validate()?; let mut guard = self @@ -345,6 +349,7 @@ impl EditorGenerationPricingStore { .write() .map_err(|_| EditorGenerationPricingError::LockPoisoned)?; *guard = next.clone(); + self.version_micros.store(version_micros, Ordering::Release); Ok(next) } } @@ -711,6 +716,7 @@ impl fmt::Display for EditorGenerationPricingError { 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}"), } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 82a70ee5c..30928e4ec 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -538,6 +538,7 @@ fn editor_generation_pricing_upsert_input( admin_user_id: String, records: EditorGenerationPricingRecordInput, updated_at_micros: i64, + expected_updated_at_micros: Option, ) -> EditorGenerationPricingConfigUpsertRecordInput { EditorGenerationPricingConfigUpsertRecordInput { admin_user_id, @@ -549,9 +550,24 @@ fn editor_generation_pricing_upsert_input( .unwrap_or_default(), text_to_model_pricing: records.model3d.text_to_model_pricing, image_to_model_pricing: records.model3d.image_to_model_pricing, + expected_updated_at_micros, } } +/// procedure 只能返回字符串错误,乐观锁冲突靠共享常量识别,其余仍按持久化失败处理。 +fn map_editor_generation_pricing_persistence_error( + error: SpacetimeClientError, +) -> EditorGenerationPricingError { + if matches!( + &error, + SpacetimeClientError::Procedure(message) + if message == shared_contracts::editor_generation::EDITOR_GENERATION_PRICING_VERSION_CONFLICT + ) { + return EditorGenerationPricingError::Conflict; + } + EditorGenerationPricingError::Persistence(error.to_string()) +} + impl AppState { #[cfg(test)] pub fn new(config: AppConfig) -> Result { @@ -779,9 +795,21 @@ impl AppState { pub(crate) async fn editor_generation_pricing( &self, ) -> Result { + self.editor_generation_pricing_with_version() + .await + .map(|(pricing, _)| pricing) + } + + /// 定价配置 + 当前定价版本(SpacetimeDB 行的 `updated_at` 微秒值)。 + /// + /// 后台读取走这里:前端保存时要把版本原样回传,服务端在同一事务里比对,不一致就整笔拒绝。 + pub(crate) async fn editor_generation_pricing_with_version( + &self, + ) -> Result<(EditorGenerationPricingConfig, i64), EditorGenerationPricingError> { #[cfg(test)] { - return self.editor_generation_pricing_store.snapshot(); + let store = &self.editor_generation_pricing_store; + return Ok((store.snapshot()?, store.version_micros())); } #[cfg(not(test))] @@ -791,11 +819,12 @@ impl AppState { .await { Ok(Some(record)) => { + let version_micros = record.updated_at_micros; let legacy_fallback = self.editor_generation_pricing_store.snapshot()?; let pricing = editor_generation_pricing_from_record(record, &legacy_fallback)?; self.editor_generation_pricing_store - .replace(pricing.clone())?; - Ok(pricing) + .replace(pricing.clone(), version_micros)?; + Ok((pricing, version_micros)) } Ok(None) => self.seed_editor_generation_pricing_config().await, Err(error) => { @@ -803,45 +832,59 @@ impl AppState { error = %error, "读取 SpacetimeDB 模型定价配置失败,使用本地缓存兜底" ); - self.editor_generation_pricing_store.snapshot() + let store = &self.editor_generation_pricing_store; + Ok((store.snapshot()?, store.version_micros())) } } } + /// 保存完整定价配置(`models` 与 3D 两段)并做乐观锁比对。 + /// + /// `expected_updated_at_micros` 是前端读取时拿到的版本;与库内当前版本不一致时保存整体 + /// 拒绝,返回 [`EditorGenerationPricingError::Conflict`],绝不静默覆盖他人改动。 pub(crate) async fn save_editor_generation_pricing( &self, 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); - + expected_updated_at_micros: i64, + ) -> Result<(EditorGenerationPricingConfig, i64), EditorGenerationPricingError> { #[cfg(test)] { let _ = admin_user_id; - return self.editor_generation_pricing_store.replace(next); + let store = &self.editor_generation_pricing_store; + if store.version_micros() != expected_updated_at_micros { + return Err(EditorGenerationPricingError::Conflict); + } + let next_version_micros = store.version_micros().saturating_add(1); + return Ok(( + store.replace(next, next_version_micros)?, + next_version_micros, + )); } #[cfg(not(test))] { let records = editor_generation_pricing_to_records(&next)?; + // 版本取「当前时间」与「期望版本 + 1」的较大值:同一微秒内的连续保存也必须推进版本, + // 否则旧的期望版本会在下一次保存时被误判为仍然有效。 + let updated_at_micros = + crate::editor_project::current_utc_micros().max(expected_updated_at_micros + 1); let record = self .spacetime_client .upsert_editor_generation_pricing_config(editor_generation_pricing_upsert_input( &self.config, admin_user_id, records, - crate::editor_project::current_utc_micros(), + updated_at_micros, + Some(expected_updated_at_micros), )) .await - .map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?; + .map_err(map_editor_generation_pricing_persistence_error)?; + let version_micros = record.updated_at_micros; let pricing = editor_generation_pricing_from_record(record, &next)?; self.editor_generation_pricing_store - .replace(pricing.clone())?; - Ok(pricing) + .replace(pricing.clone(), version_micros)?; + Ok((pricing, version_micros)) } } @@ -855,7 +898,7 @@ impl AppState { #[cfg_attr(test, allow(dead_code))] async fn seed_editor_generation_pricing_config( &self, - ) -> Result { + ) -> Result<(EditorGenerationPricingConfig, i64), EditorGenerationPricingError> { let fallback = self.editor_generation_pricing_store.snapshot()?; let records = editor_generation_pricing_to_records(&fallback)?; let record = self @@ -866,14 +909,17 @@ impl AppState { "system:editor-generation-pricing".to_string(), records, crate::editor_project::current_utc_micros(), + // 首次种子写入没有可比的版本,显式声明不比对。 + None, ), ) .await .map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?; + let version_micros = record.updated_at_micros; let pricing = editor_generation_pricing_from_record(record, &fallback)?; self.editor_generation_pricing_store - .replace(pricing.clone())?; - Ok(pricing) + .replace(pricing.clone(), version_micros)?; + Ok((pricing, version_micros)) } pub fn is_ready(&self) -> bool { @@ -2190,6 +2236,8 @@ async fn initialize_editor_generation_runtime_service_identity_for_startup( "system:editor-generation-pricing".to_string(), records, crate::editor_project::current_utc_micros(), + // 启动种子只在表为空时写入,没有可比的版本。 + None, ), ) .await @@ -2802,47 +2850,62 @@ mod tests { use super::*; use crate::tripo3d::pricing::Model3dAddOn; - /// 后台 payload 省略 / 置空 3D 段时必须沿用当前 3D 定价: - /// 改一次图 / 视频价格不能把文件提供的 3D 段清掉。 + /// 3D 段按 payload 生效(不再有「省略即沿用」),且每次保存都把定价版本推进。 #[tokio::test] - async fn saving_pricing_without_model3d_keeps_existing_model3d() { + async fn saving_pricing_applies_explicit_model3d_and_bumps_version() { let state = AppState::new(AppConfig::default()).expect("state should build"); - let before = state - .editor_generation_pricing() + let (before, version) = state + .editor_generation_pricing_with_version() .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(); + let mut custom = default_model3d; custom .text_to_model_pricing .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) + let mut next = before; + next.model3d = Some(custom.clone()); + + let (saved, saved_version) = state + .save_editor_generation_pricing("admin:test".to_string(), next, version) .await .expect("显式 3D 段的保存必须成功"); assert_eq!(saved.model3d.as_ref(), Some(&custom)); - assert_eq!( - state - .editor_generation_pricing() - .await - .expect("定价必须可读") - .model3d - .as_ref(), - Some(&custom) + assert!(saved_version > version, "保存必须推进定价版本"); + + let (reread, reread_version) = state + .editor_generation_pricing_with_version() + .await + .expect("定价必须可读"); + assert_eq!(reread.model3d.as_ref(), Some(&custom)); + assert_eq!(reread_version, saved_version); + } + + /// 带着读旧的版本保存时必须整体拒绝,不能静默覆盖别人的改动。 + #[tokio::test] + async fn saving_pricing_with_stale_version_is_rejected() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let (before, version) = state + .editor_generation_pricing_with_version() + .await + .expect("默认定价必须可读"); + + let (_, bumped_version) = state + .save_editor_generation_pricing("admin:test".to_string(), before.clone(), version) + .await + .expect("首次保存必须成功"); + + let error = state + .save_editor_generation_pricing("admin:test".to_string(), before, version) + .await + .expect_err("用被覆盖的版本保存必须失败"); + assert!( + matches!(error, EditorGenerationPricingError::Conflict), + "应判定为定价版本冲突,实际为 {error:?}" ); + assert!(bumped_version > version); } #[test] @@ -3087,6 +3150,7 @@ mod tests { "admin:test".to_string(), empty_pricing_record_input(), 123, + None, ); assert_eq!(without_secret.bootstrap_secret, ""); @@ -3096,6 +3160,7 @@ mod tests { "admin:test".to_string(), empty_pricing_record_input(), 123, + None, ); assert_eq!(with_secret.bootstrap_secret, "11".repeat(32)); diff --git a/server-rs/crates/shared-contracts/src/editor_generation.rs b/server-rs/crates/shared-contracts/src/editor_generation.rs index ca541cf2e..e8a3df598 100644 --- a/server-rs/crates/shared-contracts/src/editor_generation.rs +++ b/server-rs/crates/shared-contracts/src/editor_generation.rs @@ -26,6 +26,13 @@ pub const EDITOR_GENERATION_OPERATION_KINDS: [&str; 12] = [ "model3d_image_to_model", ]; +/// 定价后台保存的乐观锁冲突信号。 +/// +/// procedure 只能返回字符串错误,所以 api-server 与 spacetime-module 共用这一个常量来区分 +/// 「定价已被他人改过」和其它保存失败;两侧各写一份文案会静默分叉,这里只允许一处声明。 +pub const EDITOR_GENERATION_PRICING_VERSION_CONFLICT: &str = + "EDITOR_GENERATION_PRICING_VERSION_CONFLICT"; + pub fn editor_generation_request_fingerprint( operation_kind: &str, request_payload_json: &str, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index 9072075f0..12b1a814f 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -587,6 +587,8 @@ pub struct EditorGenerationPricingConfigUpsertRecordInput { pub bootstrap_secret: String, pub text_to_model_pricing: Option, pub image_to_model_pricing: Option, + /// 期望的当前定价版本;后台保存必须带上读取时拿到的版本做乐观锁比对。 + pub expected_updated_at_micros: Option, } impl From for crate::module_bindings::EditorProjectCreateInput { @@ -1049,6 +1051,7 @@ impl From image_to_model_pricing: input .image_to_model_pricing .map(map_editor_generation_model3d_pricing_record), + expected_updated_at_micros: input.expected_updated_at_micros, } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs index 38046ada2..dddd6406f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs @@ -16,6 +16,7 @@ pub struct EditorGenerationPricingConfigUpsertInput { pub bootstrap_secret: String, pub text_to_model_pricing: Option, pub image_to_model_pricing: Option, + pub expected_updated_at_micros: Option, } impl __sdk::InModule for EditorGenerationPricingConfigUpsertInput { diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 3d2d72083..989f90a8d 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1334,6 +1334,9 @@ pub struct EditorGenerationPricingConfigUpsertInput { pub bootstrap_secret: String, pub text_to_model_pricing: Option, pub image_to_model_pricing: Option, + /// 期望的当前定价版本(行内 `updated_at` 微秒值)。`None` 表示不比对,只用于 + /// 首次种子写入;后台保存必须带上读取时拿到的版本,不一致就整体拒绝。 + pub expected_updated_at_micros: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -6811,6 +6814,13 @@ fn upsert_editor_generation_pricing_config( )?; } + require_expected_editor_generation_pricing_version( + existing + .as_ref() + .map(|row| row.updated_at.to_micros_since_unix_epoch()), + input.expected_updated_at_micros, + )?; + let admin_user_id = normalize_required( &input.admin_user_id, "editor_generation_pricing_config.updated_by_admin_user_id", @@ -7978,6 +7988,23 @@ fn editor_generation_pricing_config_snapshot_from_row( } } +/// 定价保存的乐观锁比对:后台带上读取时拿到的版本,与当前行不一致就整笔拒绝。 +/// +/// `expected` 为 `None` 表示不比对(首次种子写入);行不存在而调用方声称有版本,同样算冲突, +/// 避免「读到兜底缓存 → 直接覆盖」的路径绕开检查。 +fn require_expected_editor_generation_pricing_version( + current_updated_at_micros: Option, + expected_updated_at_micros: Option, +) -> Result<(), String> { + let Some(expected) = expected_updated_at_micros else { + return Ok(()); + }; + if current_updated_at_micros == Some(expected) { + return Ok(()); + } + Err(shared_contracts::editor_generation::EDITOR_GENERATION_PRICING_VERSION_CONFLICT.to_string()) +} + /// 3D 生成定价两个端点的段级校验:段允许整体缺失,非空时必须同时给出底价与加价项、 /// 键唯一且价格为正。契约支持的模型版本与加价项集合由 api-server 侧的枚举负责, /// 模块这里只守存储形状,避免把模型版本清单复制成第二份真相。 @@ -20123,6 +20150,34 @@ mod tests { assert!(!format!("{snapshot:?}").contains("writer_identity")); } + #[test] + fn editor_generation_pricing_expected_version_must_match_current_row() { + assert_eq!( + require_expected_editor_generation_pricing_version(Some(7), Some(7)), + Ok(()) + ); + assert_eq!( + require_expected_editor_generation_pricing_version(Some(7), None), + Ok(()), + "不带期望版本只用于首次种子写入" + ); + assert_eq!( + require_expected_editor_generation_pricing_version(Some(8), Some(7)), + Err( + shared_contracts::editor_generation::EDITOR_GENERATION_PRICING_VERSION_CONFLICT + .to_string() + ) + ); + assert_eq!( + require_expected_editor_generation_pricing_version(None, Some(0)), + Err( + shared_contracts::editor_generation::EDITOR_GENERATION_PRICING_VERSION_CONFLICT + .to_string() + ), + "表内没有行时不能凭兜底版本直接创建" + ); + } + #[test] fn normalize_editor_generation_model3d_pricing_accepts_absent_and_complete_sections() { assert_eq!(