后台定价读取带版本、保存整段必填并做事务内乐观锁

- shared-contracts 新增定价版本冲突标记,module 与 api-server 共用一处声明
- SpacetimeDB upsert input 增加期望版本,事务内比对不一致即整笔拒绝(种子写入显式不比对)
- 定价 store 记录当前版本,读取路径带出 updated_at 微秒值
- 后台 GET 返回 models / 3D 两段 / updatedAtMicros,POST 要求两段显式给出且带期望版本
- 缺 3D 段 400、版本不匹配 409,退役 with_previous_model3d 与「省略即沿用」用例
- 绑定按 input 新字段重新生成,模块补乐观锁比对用例
This commit is contained in:
2026-09-23 11:38:23 +08:00
parent 952bc1ccaa
commit ee4a9434fa
8 changed files with 497 additions and 118 deletions
+83 -9
View File
@@ -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<String, EditorGenerationModelPricing>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model3d: Option<Model3dPricingConfig>,
pub updated_at_micros: i64,
}
/// 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,不接受「省略即沿用」。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertEditorGenerationPricingRequest {
pub models: BTreeMap<String, EditorGenerationModelPricing>,
pub model3d: Option<Model3dPricingConfig>,
pub expected_updated_at_micros: Option<i64>,
}
/// 后台读取画布生成模型定价配置。
pub async fn admin_get_editor_generation_pricing(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<EditorGenerationPricingConfig>,
Json(payload): Json<AdminUpsertEditorGenerationPricingRequest>,
) -> Result<Json<Value>, 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!({
+219 -51
View File
@@ -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())
);
}
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
@@ -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<RwLock<EditorGenerationPricingConfig>>,
/// 当前配置的定价版本:SpacetimeDB 行的 `updated_at` 微秒值。0 表示还没读到过行
/// (表为空或读不到),后台保存的乐观锁就比对它。
version_micros: Arc<AtomicI64>,
}
#[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<EditorGenerationPricingConfig, EditorGenerationPricingError> {
@@ -338,6 +341,7 @@ impl EditorGenerationPricingStore {
pub(crate) fn replace(
&self,
next: EditorGenerationPricingConfig,
version_micros: i64,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
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}"),
}
+111 -46
View File
@@ -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<i64>,
) -> 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<Self, AppStateInitError> {
@@ -779,9 +795,21 @@ impl AppState {
pub(crate) async fn editor_generation_pricing(
&self,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
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<EditorGenerationPricingConfig, EditorGenerationPricingError> {
// 后台 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<EditorGenerationPricingConfig, EditorGenerationPricingError> {
) -> 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));
@@ -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,
@@ -587,6 +587,8 @@ pub struct EditorGenerationPricingConfigUpsertRecordInput {
pub bootstrap_secret: String,
pub text_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
pub image_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
/// 期望的当前定价版本;后台保存必须带上读取时拿到的版本做乐观锁比对。
pub expected_updated_at_micros: Option<i64>,
}
impl From<EditorProjectCreateRecordInput> for crate::module_bindings::EditorProjectCreateInput {
@@ -1049,6 +1051,7 @@ impl From<EditorGenerationPricingConfigUpsertRecordInput>
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,
}
}
}
@@ -16,6 +16,7 @@ pub struct EditorGenerationPricingConfigUpsertInput {
pub bootstrap_secret: String,
pub text_to_model_pricing: Option<EditorGenerationModel3DPricing>,
pub image_to_model_pricing: Option<EditorGenerationModel3DPricing>,
pub expected_updated_at_micros: Option<i64>,
}
impl __sdk::InModule for EditorGenerationPricingConfigUpsertInput {
@@ -1334,6 +1334,9 @@ pub struct EditorGenerationPricingConfigUpsertInput {
pub bootstrap_secret: String,
pub text_to_model_pricing: Option<EditorGenerationModel3dPricing>,
pub image_to_model_pricing: Option<EditorGenerationModel3dPricing>,
/// 期望的当前定价版本(行内 `updated_at` 微秒值)。`None` 表示不比对,只用于
/// 首次种子写入;后台保存必须带上读取时拿到的版本,不一致就整体拒绝。
pub expected_updated_at_micros: Option<i64>,
}
#[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<i64>,
expected_updated_at_micros: Option<i64>,
) -> 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!(