后台定价读取带版本、保存整段必填并做事务内乐观锁
- 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:
@@ -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!({
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user