From abe44948eed21543dec3510aa4eb17d34832b4c7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 27 Apr 2026 23:32:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E8=B5=84=E4=BA=A7=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=8F=91=E5=B8=83=E5=8F=99=E4=B8=96=E5=B8=81=E6=B6=88?= =?UTF-8?q?=E8=80=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ENERATION_POINTS_CONSUMPTION_2026-04-27.md | 64 +++++++++ .../crates/api-server/src/asset_billing.rs | 81 ++++++++++++ server-rs/crates/api-server/src/big_fish.rs | 121 ++++++++++++++++-- .../crates/api-server/src/custom_world.rs | 67 ++++++++-- .../crates/api-server/src/custom_world_ai.rs | 106 +++------------ server-rs/crates/api-server/src/main.rs | 1 + server-rs/crates/api-server/src/puzzle.rs | 83 +++++++++--- ...file_wallet_points_and_return_procedure.rs | 59 +++++++++ ...file_wallet_points_and_return_procedure.rs | 59 +++++++++ ...me_profile_wallet_adjustment_input_type.rs | 18 +++ ...wallet_adjustment_procedure_result_type.rs | 19 +++ 11 files changed, 548 insertions(+), 130 deletions(-) create mode 100644 docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md create mode 100644 server-rs/crates/api-server/src/asset_billing.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_procedure_result_type.rs diff --git a/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md new file mode 100644 index 00000000..5daf05d6 --- /dev/null +++ b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md @@ -0,0 +1,64 @@ +# 资产生成叙世币消耗接入方案 + +## 背景 + +当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成由 Axum API 调用外部模型并写入 OSS,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此扣费需要拆成两层: + +- SpacetimeDB 负责钱包余额和流水的原子变更。 +- Axum 负责在发起外部生成前扣费,并在生成或持久化失败时补偿退款。 + +## 首期范围 + +首期接入带 Bearer 身份、能明确归属真实用户的资产生成与发布入口: + +- `POST /api/custom-world/scene-image` +- `POST /api/custom-world/cover-image` +- `POST /api/runtime/custom-world/cover-image` +- `POST /api/runtime/custom-world-library/{profile_id}/publish` +- 自定义世界 Agent 动作 `publish_world` +- Big Fish Agent 正式图片生成动作 `big_fish_generate_level_main_image`、`big_fish_generate_level_motion`、`big_fish_generate_stage_background` +- Big Fish Agent 动作 `big_fish_publish_game` +- Puzzle Agent 图片生成动作 `compile_puzzle_draft`、`generate_puzzle_images` +- Puzzle Agent 动作 `publish_puzzle_work` + +暂不接入以下入口: + +- 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。 +- 手动上传封面:不调用外部生成模型,不消耗叙世币。 +- 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。 +- 文本实体、NPC 生成:本次需求聚焦资产生成,首期只覆盖图片资产。 + +## 计费规则 + +- 每次图片资产生成请求消耗 `1` 枚叙世币。 +- 每次作品发布请求消耗 `1` 枚叙世币;余额不足时禁止发布。 +- 在调用外部图片生成前预扣,余额不足时直接返回业务错误,不调用外部模型。 +- 发布请求在写入发布状态前预扣,余额不足时直接返回业务错误,不调用发布 mutation。 +- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,Axum 自动发起同额退款。 +- 如果退款失败,原始错误仍返回给调用方,同时服务端日志记录退款失败,便于后续人工核对。 + +## 钱包流水 + +新增两个流水来源类型,首期同时覆盖“资产生成”和“资产发布”这两类资产操作: + +- `asset_generation_consume`:资产生成预扣,`amount_delta = -1`。 +- `asset_generation_refund`:资产生成失败退款,`amount_delta = +1`。 + +`wallet_ledger_id` 由 Axum 传入,格式: + +- 扣费:`asset_generation_consume:{user_id}:{asset_kind}:{asset_id}` +- 退款:`asset_generation_refund:{user_id}:{asset_kind}:{asset_id}` + +SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID 已存在,则直接返回当前钱包快照,不重复变更余额。 + +## 工程落点 + +- `module-runtime`:新增钱包调整输入、钱包调整结果、流水来源枚举。 +- `spacetime-module`:新增 `consume_profile_wallet_points_and_return` 与 `refund_profile_wallet_points_and_return` procedure,并扩展钱包变更 helper 支持负数。 +- `spacetime-client`:新增对应调用方法和绑定类型。 +- `api-server`:在自定义世界图片生成与发布入口前扣费,错误分支退款。 +- `shared-contracts`:新增 API 流水来源常量,保证“我的-钱包流水”输出使用稳定契约字符串。 + +## 非目标 + +本次不做分档价格、不做会员免扣、不做前端计费展示改造,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。 diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs new file mode 100644 index 00000000..95ac102f --- /dev/null +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -0,0 +1,81 @@ +use axum::http::StatusCode; +use serde_json::json; +use spacetime_client::SpacetimeClientError; + +use crate::{http_error::AppError, state::AppState}; + +pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1; + +/// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。 +pub(crate) async fn consume_asset_operation_points( + state: &AppState, + owner_user_id: &str, + asset_kind: &str, + asset_id: &str, +) -> Result<(), AppError> { + let ledger_id = format!( + "asset_generation_consume:{}:{}:{}", + owner_user_id, asset_kind, asset_id + ); + state + .spacetime_client() + .consume_profile_wallet_points( + owner_user_id.to_string(), + ASSET_OPERATION_POINTS_COST, + ledger_id, + current_utc_micros(), + ) + .await + .map(|_| ()) + .map_err(map_asset_operation_wallet_error) +} + +/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。 +pub(crate) async fn refund_asset_operation_points( + state: &AppState, + owner_user_id: &str, + asset_kind: &str, + asset_id: &str, +) { + let ledger_id = format!( + "asset_generation_refund:{}:{}:{}", + owner_user_id, asset_kind, asset_id + ); + if let Err(error) = state + .spacetime_client() + .refund_profile_wallet_points( + owner_user_id.to_string(), + ASSET_OPERATION_POINTS_COST, + ledger_id, + current_utc_micros(), + ) + .await + { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + error = %error, + "资产操作失败后的叙世币退款失败" + ); + } +} + +pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + SpacetimeClientError::Procedure(message) if message.contains("叙世币余额不足") => { + StatusCode::CONFLICT + } + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": "profile-wallet", + "message": error.to_string(), + })) +} + +fn current_utc_micros() -> i64 { + time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000 +} diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 8ab50e46..b5f804e3 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -46,6 +46,7 @@ use crate::{ AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, }, api_response::json_success_body, + asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, @@ -471,9 +472,30 @@ pub async fn execute_big_fish_action( let owner_user_id = authenticated.claims().user_id().to_string(); let now = current_utc_micros(); - let session = match payload.action.trim() { + let action = payload.action.trim().to_string(); + let billed_asset_kind = match action.as_str() { + "big_fish_generate_level_main_image" => Some("big_fish_level_main_image"), + "big_fish_generate_level_motion" => Some("big_fish_level_motion"), + "big_fish_generate_stage_background" => Some("big_fish_stage_background"), + "big_fish_publish_game" => Some("big_fish_publish_game"), + _ => None, + }; + let billing_asset_id = format!("{session_id}:{now}"); + if let Some(asset_kind) = billed_asset_kind { + consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id) + .await + .map_err(|error| big_fish_error_response(&request_context, error))?; + } + + let session_result = match action.as_str() { "big_fish_compile_draft" => { - compile_big_fish_draft_with_all_assets(&state, session_id, owner_user_id, now).await + compile_big_fish_draft_with_all_assets( + &state, + session_id.clone(), + owner_user_id.clone(), + now, + ) + .await } "big_fish_generate_level_main_image" => { let asset_url = generate_big_fish_formal_asset( @@ -486,12 +508,30 @@ pub async fn execute_big_fish_action( now, ) .await - .map_err(|error| big_fish_error_response(&request_context, error))?; + .map_err(|error| { + if let Some(asset_kind) = billed_asset_kind { + tokio::spawn({ + let state = state.clone(); + let owner_user_id = owner_user_id.clone(); + let billing_asset_id = billing_asset_id.clone(); + async move { + refund_asset_operation_points( + &state, + &owner_user_id, + asset_kind, + &billing_asset_id, + ) + .await; + } + }); + } + big_fish_error_response(&request_context, error) + })?; state .spacetime_client() .generate_big_fish_asset(BigFishAssetGenerateRecordInput { - session_id, - owner_user_id, + owner_user_id: owner_user_id.clone(), + session_id: session_id.clone(), asset_kind: "level_main_image".to_string(), level: payload.level, motion_key: None, @@ -511,12 +551,30 @@ pub async fn execute_big_fish_action( now, ) .await - .map_err(|error| big_fish_error_response(&request_context, error))?; + .map_err(|error| { + if let Some(asset_kind) = billed_asset_kind { + tokio::spawn({ + let state = state.clone(); + let owner_user_id = owner_user_id.clone(); + let billing_asset_id = billing_asset_id.clone(); + async move { + refund_asset_operation_points( + &state, + &owner_user_id, + asset_kind, + &billing_asset_id, + ) + .await; + } + }); + } + big_fish_error_response(&request_context, error) + })?; state .spacetime_client() .generate_big_fish_asset(BigFishAssetGenerateRecordInput { - session_id, - owner_user_id, + owner_user_id: owner_user_id.clone(), + session_id: session_id.clone(), asset_kind: "level_motion".to_string(), level: payload.level, motion_key: payload.motion_key, @@ -536,12 +594,30 @@ pub async fn execute_big_fish_action( now, ) .await - .map_err(|error| big_fish_error_response(&request_context, error))?; + .map_err(|error| { + if let Some(asset_kind) = billed_asset_kind { + tokio::spawn({ + let state = state.clone(); + let owner_user_id = owner_user_id.clone(); + let billing_asset_id = billing_asset_id.clone(); + async move { + refund_asset_operation_points( + &state, + &owner_user_id, + asset_kind, + &billing_asset_id, + ) + .await; + } + }); + } + big_fish_error_response(&request_context, error) + })?; state .spacetime_client() .generate_big_fish_asset(BigFishAssetGenerateRecordInput { - session_id, - owner_user_id, + owner_user_id: owner_user_id.clone(), + session_id: session_id.clone(), asset_kind: "stage_background".to_string(), level: None, motion_key: None, @@ -553,7 +629,7 @@ pub async fn execute_big_fish_action( "big_fish_publish_game" => { state .spacetime_client() - .publish_big_fish_game(session_id, owner_user_id, now) + .publish_big_fish_game(session_id, owner_user_id.clone(), now) .await } other => { @@ -562,8 +638,25 @@ pub async fn execute_big_fish_action( format!("action `{other}` is not supported").as_str(), )); } - } - .map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?; + }; + let session = match session_result { + Ok(session) => session, + Err(error) => { + if let Some(asset_kind) = billed_asset_kind { + refund_asset_operation_points( + &state, + &owner_user_id, + asset_kind, + &billing_asset_id, + ) + .await; + } + return Err(big_fish_error_response( + &request_context, + map_big_fish_client_error(error), + )); + } + }; Ok(json_success_body( Some(&request_context), diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 721d49d1..a0caaebf 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -51,6 +51,7 @@ use crate::{ AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, }, api_response::json_success_body, + asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, auth::AuthenticatedAccessToken, character_visual_assets::generate_character_primary_visual_for_profile, custom_world_agent_entities::generate_custom_world_agent_entities, @@ -350,20 +351,37 @@ pub async fn publish_custom_world_library_profile( )); } - let mutation = state + consume_asset_operation_points(&state, &owner_user_id, "custom_world_publish", &profile_id) + .await + .map_err(|error| custom_world_error_response(&request_context, error))?; + + let mutation_result = state .spacetime_client() .publish_custom_world_profile( - profile_id, - owner_user_id, + profile_id.clone(), + owner_user_id.clone(), None, resolve_author_public_user_code(&state, &authenticated, &request_context)?, resolve_author_display_name(&state, &authenticated), current_utc_micros(), ) - .await - .map_err(|error| { - custom_world_error_response(&request_context, map_custom_world_client_error(error)) - })?; + .await; + let mutation = match mutation_result { + Ok(mutation) => mutation, + Err(error) => { + refund_asset_operation_points( + &state, + &owner_user_id, + "custom_world_publish", + &profile_id, + ) + .await; + return Err(custom_world_error_response( + &request_context, + map_custom_world_client_error(error), + )); + } + }; Ok(json_success_body( Some(&request_context), @@ -1227,7 +1245,19 @@ pub async fn execute_custom_world_agent_action( })? }; - let result = state + let should_bill_publish = action == "publish_world"; + if should_bill_publish { + consume_asset_operation_points( + &state, + &owner_user_id, + "custom_world_agent_publish", + &session_id, + ) + .await + .map_err(|error| custom_world_error_response(&request_context, error))?; + } + + let result = match state .spacetime_client() .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { session_id: session_id.clone(), @@ -1238,9 +1268,24 @@ pub async fn execute_custom_world_agent_action( submitted_at_micros, }) .await - .map_err(|error| { - custom_world_error_response(&request_context, map_custom_world_client_error(error)) - })?; + { + Ok(result) => result, + Err(error) => { + if should_bill_publish { + refund_asset_operation_points( + &state, + &owner_user_id, + "custom_world_agent_publish", + &session_id, + ) + .await; + } + return Err(custom_world_error_response( + &request_context, + map_custom_world_client_error(error), + )); + } + }; if matches!( action.as_str(), diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 2838724d..f1b862e1 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -42,8 +42,6 @@ use crate::{ state::AppState, }; -const ASSET_GENERATION_POINTS_COST: u64 = 1; - #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomWorldEntityRequest { @@ -443,14 +441,14 @@ pub async fn generate_custom_world_scene_image( let normalized = normalize_scene_image_request(payload) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-scene-{}", current_utc_millis()); - consume_asset_generation_points( + crate::asset_billing::consume_asset_operation_points( &state, &owner_user_id, "scene_image", asset_id.as_str(), - &request_context, ) - .await?; + .await + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_result = async { let settings = require_dashscope_settings(&state)?; let http_client = build_dashscope_http_client(&settings)?; @@ -553,7 +551,13 @@ pub async fn generate_custom_world_scene_image( let asset = match asset_result { Ok(asset) => asset, Err(error) => { - refund_asset_generation_points(&state, &owner_user_id, "scene_image", &asset_id).await; + crate::asset_billing::refund_asset_operation_points( + &state, + &owner_user_id, + "scene_image", + &asset_id, + ) + .await; return Err(custom_world_ai_error_response(&request_context, error)); } }; @@ -713,14 +717,14 @@ pub async fn generate_custom_world_cover_image( let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone()); let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string()); let asset_id = format!("custom-cover-{}", current_utc_millis()); - consume_asset_generation_points( + crate::asset_billing::consume_asset_operation_points( &state, &owner_user_id, "custom_world_cover", asset_id.as_str(), - &request_context, ) - .await?; + .await + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_result = async { let settings = require_dashscope_settings(&state)?; let http_client = build_dashscope_http_client(&settings)?; @@ -824,8 +828,13 @@ pub async fn generate_custom_world_cover_image( let asset = match asset_result { Ok(asset) => asset, Err(error) => { - refund_asset_generation_points(&state, &owner_user_id, "custom_world_cover", &asset_id) - .await; + crate::asset_billing::refund_asset_operation_points( + &state, + &owner_user_id, + "custom_world_cover", + &asset_id, + ) + .await; return Err(custom_world_ai_error_response(&request_context, error)); } }; @@ -903,81 +912,6 @@ pub async fn upload_custom_world_cover_image( Ok(json_success_body(Some(&request_context), asset)) } -async fn consume_asset_generation_points( - state: &AppState, - owner_user_id: &str, - asset_kind: &str, - asset_id: &str, - request_context: &RequestContext, -) -> Result<(), Response> { - let ledger_id = format!( - "asset_generation_consume:{}:{}:{}", - owner_user_id, asset_kind, asset_id - ); - let created_at_micros = current_utc_micros(); - state - .spacetime_client() - .consume_profile_wallet_points( - owner_user_id.to_string(), - ASSET_GENERATION_POINTS_COST, - ledger_id, - created_at_micros, - ) - .await - .map(|_| ()) - .map_err(|error| { - custom_world_ai_error_response( - request_context, - map_asset_generation_wallet_error(error), - ) - }) -} - -async fn refund_asset_generation_points( - state: &AppState, - owner_user_id: &str, - asset_kind: &str, - asset_id: &str, -) { - let ledger_id = format!( - "asset_generation_refund:{}:{}:{}", - owner_user_id, asset_kind, asset_id - ); - if let Err(error) = state - .spacetime_client() - .refund_profile_wallet_points( - owner_user_id.to_string(), - ASSET_GENERATION_POINTS_COST, - ledger_id, - current_utc_micros(), - ) - .await - { - tracing::error!( - owner_user_id, - asset_kind, - asset_id, - error = %error, - "资产生成失败后的叙世币退款失败" - ); - } -} - -fn map_asset_generation_wallet_error(error: SpacetimeClientError) -> AppError { - let status = match &error { - SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, - SpacetimeClientError::Procedure(message) if message.contains("叙世币余额不足") => { - StatusCode::CONFLICT - } - _ => StatusCode::BAD_GATEWAY, - }; - - AppError::from_status(status).with_details(json!({ - "provider": "profile-wallet", - "message": error.to_string(), - })) -} - async fn persist_custom_world_asset( state: &AppState, owner_user_id: &str, diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index e3480c7d..5358a69d 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -3,6 +3,7 @@ mod ai_generation_drafts; mod ai_tasks; mod api_response; mod app; +mod asset_billing; mod assets; mod auth; mod auth_me; diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 7f18575d..fdeed0b2 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -37,10 +37,10 @@ use shared_contracts::{ puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse}, puzzle_runtime::{ AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, - PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, - PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse, - PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, - SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, + PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, + PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse, + PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, + SwapPuzzlePiecesRequest, }, puzzle_works::{ PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, @@ -55,12 +55,11 @@ use spacetime_client::{ PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord, - PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, - PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, - SpacetimeClientError, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, + PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, + PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; @@ -68,6 +67,7 @@ use tokio::time::sleep; use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, api_response::json_success_body, + asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, auth::AuthenticatedAccessToken, http_error::AppError, prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, @@ -441,8 +441,22 @@ pub async fn execute_puzzle_agent_action( let owner_user_id = authenticated.claims().user_id().to_string(); let now = current_utc_micros(); + let action = payload.action.trim().to_string(); + let billed_asset_kind = match action.as_str() { + "compile_puzzle_draft" => Some("puzzle_initial_image"), + "generate_puzzle_images" => Some("puzzle_generated_image"), + _ => None, + }; + let billing_asset_id = format!("{session_id}:{now}"); + if let Some(asset_kind) = billed_asset_kind { + consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + })?; + } - let (operation_type, phase_label, phase_detail, session) = match payload.action.trim() { + let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { let session = compile_puzzle_draft_with_initial_cover( &state, @@ -510,7 +524,7 @@ pub async fn execute_puzzle_agent_action( .save_puzzle_generated_images( PuzzleGeneratedImagesSaveRecordInput { session_id: session.session_id, - owner_user_id, + owner_user_id: owner_user_id.clone(), candidates_json, saved_at_micros: now, }, @@ -565,13 +579,18 @@ pub async fn execute_puzzle_agent_action( } "publish_puzzle_work" => { let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); - let profile = state + consume_asset_operation_points(&state, &owner_user_id, "puzzle_publish_work", &work_id) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + })?; + let profile_result = state .spacetime_client() .publish_puzzle_work(PuzzlePublishRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), // 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。 - work_id, + work_id: work_id.clone(), profile_id, author_display_name: resolve_author_display_name(&state, &authenticated), level_name: payload.level_name.clone(), @@ -579,14 +598,24 @@ pub async fn execute_puzzle_agent_action( theme_tags: payload.theme_tags.clone(), published_at_micros: now, }) - .await - .map_err(|error| { - puzzle_error_response( + .await; + let profile = match profile_result { + Ok(profile) => profile, + Err(error) => { + refund_asset_operation_points( + &state, + &owner_user_id, + "puzzle_publish_work", + &work_id, + ) + .await; + return Err(puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), - ) - })?; + )); + } + }; let session = state .spacetime_client() @@ -626,6 +655,22 @@ pub async fn execute_puzzle_agent_action( }; let session = session.map_err(|error| { + if let Some(asset_kind) = billed_asset_kind { + tokio::spawn({ + let state = state.clone(); + let owner_user_id = owner_user_id.clone(); + let billing_asset_id = billing_asset_id.clone(); + async move { + refund_asset_operation_points( + &state, + &owner_user_id, + asset_kind, + &billing_asset_id, + ) + .await; + } + }); + } puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs new file mode 100644 index 00000000..3d3e47a2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput; +use super::runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ConsumeProfileWalletPointsAndReturnArgs { + pub input: RuntimeProfileWalletAdjustmentInput, +} + +impl __sdk::InModule for ConsumeProfileWalletPointsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `consume_profile_wallet_points_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait consume_profile_wallet_points_and_return { + fn consume_profile_wallet_points_and_return(&self, input: RuntimeProfileWalletAdjustmentInput) { + self.consume_profile_wallet_points_and_return_then(input, |_, _| {}); + } + + fn consume_profile_wallet_points_and_return_then( + &self, + input: RuntimeProfileWalletAdjustmentInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl consume_profile_wallet_points_and_return for super::RemoteProcedures { + fn consume_profile_wallet_points_and_return_then( + &self, + input: RuntimeProfileWalletAdjustmentInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>( + "consume_profile_wallet_points_and_return", + ConsumeProfileWalletPointsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs new file mode 100644 index 00000000..fb86172c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput; +use super::runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RefundProfileWalletPointsAndReturnArgs { + pub input: RuntimeProfileWalletAdjustmentInput, +} + +impl __sdk::InModule for RefundProfileWalletPointsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `refund_profile_wallet_points_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait refund_profile_wallet_points_and_return { + fn refund_profile_wallet_points_and_return(&self, input: RuntimeProfileWalletAdjustmentInput) { + self.refund_profile_wallet_points_and_return_then(input, |_, _| {}); + } + + fn refund_profile_wallet_points_and_return_then( + &self, + input: RuntimeProfileWalletAdjustmentInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl refund_profile_wallet_points_and_return for super::RemoteProcedures { + fn refund_profile_wallet_points_and_return_then( + &self, + input: RuntimeProfileWalletAdjustmentInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>( + "refund_profile_wallet_points_and_return", + RefundProfileWalletPointsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_input_type.rs new file mode 100644 index 00000000..65315693 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletAdjustmentInput { + pub user_id: String, + pub amount: u64, + pub ledger_id: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileWalletAdjustmentInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_procedure_result_type.rs new file mode 100644 index 00000000..6633088e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletAdjustmentProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileWalletAdjustmentProcedureResult { + type Module = super::RemoteModule; +}