From 1348b2e9406434fbfb38544013486ed814c11230 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 27 Apr 2026 22:49:13 +0800 Subject: [PATCH 1/2] add public work share links --- ...AND_COMMUNITY_IMPLEMENTATION_2026-04-25.md | 1 + ..._CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md | 13 + packages/shared/src/contracts/runtime.ts | 4 +- .../crates/api-server/src/custom_world_ai.rs | 486 +++++++++++------- .../crates/api-server/src/runtime_profile.rs | 69 ++- server-rs/crates/module-runtime/src/lib.rs | 53 ++ .../crates/shared-contracts/src/runtime.rs | 84 ++- .../crates/spacetime-client/src/big_fish.rs | 1 - server-rs/crates/spacetime-client/src/lib.rs | 2 +- .../crates/spacetime-client/src/mapper.rs | 41 ++ .../src/module_bindings/mod.rs | 8 + ..._profile_wallet_ledger_source_type_type.rs | 4 + .../crates/spacetime-client/src/runtime.rs | 61 +++ .../spacetime-module/src/runtime/profile.rs | 138 ++++- src/App.tsx | 5 + .../big-fish-runtime/BigFishRuntimeShell.tsx | 56 +- .../PlatformEntryFlowShellImpl.tsx | 80 ++- .../platform-entry/platformEntryTypes.ts | 1 + .../PuzzleGalleryDetailView.tsx | 35 +- .../rpg-entry/RpgEntryWorldDetailView.tsx | 66 ++- .../rpg-entry/useRpgEntryLibraryDetail.ts | 14 +- src/routing/appPageRoutes.test.ts | 22 + src/routing/appPageRoutes.ts | 42 +- 23 files changed, 1038 insertions(+), 248 deletions(-) diff --git a/docs/technical/MY_TAB_REFERRAL_AND_COMMUNITY_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_REFERRAL_AND_COMMUNITY_IMPLEMENTATION_2026-04-25.md index cb9330b3..a8ff771c 100644 --- a/docs/technical/MY_TAB_REFERRAL_AND_COMMUNITY_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_REFERRAL_AND_COMMUNITY_IMPLEMENTATION_2026-04-25.md @@ -19,6 +19,7 @@ - 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型: - `invite_inviter_reward` - `invite_invitee_reward` +- API 返回钱包流水时,`sourceType` 必须复用 `server-rs/crates/shared-contracts/src/runtime.rs` 中的常量,避免 SpacetimeDB 枚举映射和前端合同字符串漂移。 ## SpacetimeDB 表设计 diff --git a/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md index e36dafc2..53d24314 100644 --- a/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md +++ b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md @@ -14,6 +14,17 @@ 6. 作品详情返回必须恢复打开详情前的平台来源 Tab;从分类进入回分类,从首页进入回首页,从创作中心进入回创作中心。 7. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。 +## 作品分享路由补充 + +1. 公开作品入口路由统一使用当前作品页面路径加 `work=作品号`:RPG 为 `/worlds/detail?work=CW-00000001`,拼图为 `/gallery/puzzle/detail?work=PZ-00000001`,大鱼玩法为 `/runtime/big-fish?work=BF-00000001`。 +2. 从公开广场、最近浏览、创作中心打开已发布作品详情或玩法时,若当前作品有公开作品号,地址栏必须同步追加 `work=作品号`;没有作品号的草稿详情仍保持无查询参数路径。 +3. 首次进入主应用时若 URL 带 `work` 查询参数,平台入口自动复用现有公开编号搜索逻辑打开对应作品详情,不新增独立详情系统。 +4. 详情页必须保留“复制作品号”和“分享作品”两个独立动作: + - 复制作品号只复制 `CW / PZ / BF` 编号。 + - 分享作品复制一段邀请好友来玩的中文文本,文本内必须包含作品名、作品号和带 `work` 查询参数的完整网址。 +5. 分享复制使用现有剪切板兼容工具,Clipboard API 权限失败时走降级复制,并在按钮内短暂反馈 `已复制` 或 `复制失败`。 +6. UI 中只保留按钮级短文案,不写规则说明,不在详情页新增大段分享说明。 + ## 验收 1. 399px 竖屏首页能直接看到并使用搜索入口。 @@ -23,3 +34,5 @@ 5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。 6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。 7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。 +8. 打开 `/?work=CW-00000001`、`/worlds/detail?work=CW-00000001`、`/gallery/puzzle/detail?work=PZ-00000001` 或 `/runtime/big-fish?work=BF-00000001` 后能自动进入对应公开作品详情或玩法。 +9. 点击详情页“分享作品”后,剪切板内容包含邀请文本、作品号和当前站点下带 `work=作品号` 的完整网址。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e446da25..e11beb06 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -55,7 +55,9 @@ export type ProfileWalletLedgerEntry = { | 'snapshot_sync' | 'invite_inviter_reward' | 'invite_invitee_reward' - | 'points_recharge'; + | 'points_recharge' + | 'asset_generation_consume' + | 'asset_generation_refund'; createdAt: string; }; 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 5dda2b94..2838724d 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -42,6 +42,8 @@ use crate::{ state::AppState, }; +const ASSET_GENERATION_POINTS_COST: u64 = 1; + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomWorldEntityRequest { @@ -440,108 +442,121 @@ pub async fn generate_custom_world_scene_image( let owner_user_id = authenticated.claims().user_id().to_string(); let normalized = normalize_scene_image_request(payload) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let settings = require_dashscope_settings(&state) - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let http_client = build_dashscope_http_client(&settings) - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let reference_image = - if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { - Some( - resolve_reference_image_as_data_url( - &state, - &http_client, - reference_image_src, - "referenceImageSrc", + let asset_id = format!("custom-scene-{}", current_utc_millis()); + consume_asset_generation_points( + &state, + &owner_user_id, + "scene_image", + asset_id.as_str(), + &request_context, + ) + .await?; + let asset_result = async { + let settings = require_dashscope_settings(&state)?; + let http_client = build_dashscope_http_client(&settings)?; + let reference_image = + if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { + Some( + resolve_reference_image_as_data_url( + &state, + &http_client, + reference_image_src, + "referenceImageSrc", + ) + .await?, ) - .await - .map_err(|error| custom_world_ai_error_response(&request_context, error))?, + } else { + None + }; + let generated = if let Some(reference_image) = reference_image.as_deref() { + create_reference_image_generation( + &http_client, + &settings, + state.config.dashscope_reference_image_model.as_str(), + normalized.prompt.as_str(), + normalized.size.as_str(), + &[reference_image.to_string()], + Some(normalized.negative_prompt.as_str()), + "创建参考图场景编辑任务失败", + "参考图场景编辑未返回图片地址", + "scene-edit", ) + .await } else { - None + create_text_to_image_generation( + &http_client, + &settings, + state.config.dashscope_scene_image_model.as_str(), + normalized.prompt.as_str(), + Some(normalized.negative_prompt.as_str()), + normalized.size.as_str(), + "创建场景图片生成任务失败", + "查询场景图片任务失败", + "场景图片生成任务失败", + "场景图片生成超时或未返回图片地址", + ) + .await + }?; + let scene_model = if reference_image.is_some() { + state.config.dashscope_reference_image_model.clone() + } else { + state.config.dashscope_scene_image_model.clone() }; - let generated = if let Some(reference_image) = reference_image.as_deref() { - create_reference_image_generation( + let downloaded = download_remote_image( &http_client, - &settings, - state.config.dashscope_reference_image_model.as_str(), - normalized.prompt.as_str(), - normalized.size.as_str(), - &[reference_image.to_string()], - Some(normalized.negative_prompt.as_str()), - "创建参考图场景编辑任务失败", - "参考图场景编辑未返回图片地址", - "scene-edit", + generated.image_url.as_str(), + "下载生成图片失败", ) - .await - } else { - create_text_to_image_generation( - &http_client, - &settings, - state.config.dashscope_scene_image_model.as_str(), - normalized.prompt.as_str(), - Some(normalized.negative_prompt.as_str()), - normalized.size.as_str(), - "创建场景图片生成任务失败", - "查询场景图片任务失败", - "场景图片生成任务失败", - "场景图片生成超时或未返回图片地址", + .await?; + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldScenes, + path_segments: vec![ + sanitize_storage_segment( + normalized + .profile_id + .as_deref() + .unwrap_or(normalized.world_name.as_str()), + "world", + ), + sanitize_storage_segment(normalized.entity_id.as_str(), "scene"), + asset_id.clone(), + ], + file_name: format!("scene.{}", downloaded.extension), + content_type: downloaded.mime_type, + body: downloaded.bytes, + asset_kind: "scene_image", + entity_kind: "custom_world_landmark", + entity_id: normalized.entity_id.clone(), + profile_id: normalized.profile_id.clone(), + slot: "scene_image", + source_job_id: Some(generated.task_id.clone()), + }; + persist_custom_world_asset( + &state, + &owner_user_id, + upload, + GeneratedAssetResponse { + image_src: String::new(), + asset_id: asset_id.clone(), + source_type: "generated".to_string(), + model: Some(scene_model), + size: Some(normalized.size), + task_id: Some(generated.task_id), + prompt: Some(normalized.prompt), + actual_prompt: generated.actual_prompt, + }, ) .await } - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let scene_model = if reference_image.is_some() { - state.config.dashscope_reference_image_model.clone() - } else { - state.config.dashscope_scene_image_model.clone() + .await; + + let asset = match asset_result { + Ok(asset) => asset, + Err(error) => { + refund_asset_generation_points(&state, &owner_user_id, "scene_image", &asset_id).await; + return Err(custom_world_ai_error_response(&request_context, error)); + } }; - let downloaded = download_remote_image( - &http_client, - generated.image_url.as_str(), - "下载生成图片失败", - ) - .await - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let asset_id = format!("custom-scene-{}", current_utc_millis()); - let upload = PreparedAssetUpload { - prefix: LegacyAssetPrefix::CustomWorldScenes, - path_segments: vec![ - sanitize_storage_segment( - normalized - .profile_id - .as_deref() - .unwrap_or(normalized.world_name.as_str()), - "world", - ), - sanitize_storage_segment(normalized.entity_id.as_str(), "scene"), - asset_id.clone(), - ], - file_name: format!("scene.{}", downloaded.extension), - content_type: downloaded.mime_type, - body: downloaded.bytes, - asset_kind: "scene_image", - entity_kind: "custom_world_landmark", - entity_id: normalized.entity_id.clone(), - profile_id: normalized.profile_id.clone(), - slot: "scene_image", - source_job_id: Some(generated.task_id.clone()), - }; - let asset = persist_custom_world_asset( - &state, - &owner_user_id, - upload, - GeneratedAssetResponse { - image_src: String::new(), - asset_id: asset_id.clone(), - source_type: "generated".to_string(), - model: Some(scene_model), - size: Some(normalized.size), - task_id: Some(generated.task_id), - prompt: Some(normalized.prompt), - actual_prompt: generated.actual_prompt, - }, - ) - .await - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; Ok(json_success_body(Some(&request_context), asset)) } @@ -697,109 +712,123 @@ pub async fn generate_custom_world_cover_image( trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string()); 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 settings = require_dashscope_settings(&state) - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let http_client = build_dashscope_http_client(&settings) - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let reference_sources = collect_cover_reference_image_sources( - &payload.profile, - &payload.character_role_ids, - payload.reference_image_src.as_deref().unwrap_or_default(), - ); - let prompt = build_custom_world_cover_image_prompt( - &payload.profile, - &payload.character_role_ids, - payload.user_prompt.as_deref().unwrap_or_default(), - !reference_sources.is_empty(), - ); - let mut reference_images = Vec::with_capacity(reference_sources.len()); - for source in &reference_sources { - reference_images.push( - resolve_reference_image_as_data_url( - &state, - &http_client, - source.as_str(), - "referenceImageSrc", - ) - .await - .map_err(|error| custom_world_ai_error_response(&request_context, error))?, - ); - } - let generated = if reference_images.is_empty() { - create_text_to_image_generation( - &http_client, - &settings, - state.config.dashscope_cover_image_model.as_str(), - prompt.as_str(), - None, - size.as_str(), - "创建作品封面生成任务失败", - "查询作品封面任务失败", - "作品封面生成任务失败", - "作品封面生成超时或未返回图片地址", - ) - .await - } else { - create_reference_image_generation( - &http_client, - &settings, - state.config.dashscope_reference_image_model.as_str(), - prompt.as_str(), - size.as_str(), - &reference_images, - None, - "创建参考图封面任务失败", - "封面生成未返回图片地址", - "cover-edit", - ) - .await - } - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let downloaded = download_remote_image( - &http_client, - generated.image_url.as_str(), - "下载作品封面失败", - ) - .await - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-cover-{}", current_utc_millis()); - let upload = PreparedAssetUpload { - prefix: LegacyAssetPrefix::CustomWorldCovers, - path_segments: vec![ - sanitize_storage_segment(entity_id.as_str(), "world"), - asset_id.clone(), - ], - file_name: format!("cover.{}", downloaded.extension), - content_type: downloaded.mime_type, - body: downloaded.bytes, - asset_kind: "custom_world_cover", - entity_kind: "custom_world_profile", - entity_id, - profile_id, - slot: "cover", - source_job_id: Some(generated.task_id.clone()), - }; - let asset = persist_custom_world_asset( + consume_asset_generation_points( &state, &owner_user_id, - upload, - GeneratedAssetResponse { - image_src: String::new(), - asset_id: asset_id.clone(), - source_type: "generated".to_string(), - model: Some(if reference_images.is_empty() { - state.config.dashscope_cover_image_model.clone() - } else { - state.config.dashscope_reference_image_model.clone() - }), - size: Some(size), - task_id: Some(generated.task_id), - prompt: Some(prompt), - actual_prompt: generated.actual_prompt, - }, + "custom_world_cover", + asset_id.as_str(), + &request_context, ) - .await - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + .await?; + let asset_result = async { + let settings = require_dashscope_settings(&state)?; + let http_client = build_dashscope_http_client(&settings)?; + let reference_sources = collect_cover_reference_image_sources( + &payload.profile, + &payload.character_role_ids, + payload.reference_image_src.as_deref().unwrap_or_default(), + ); + let prompt = build_custom_world_cover_image_prompt( + &payload.profile, + &payload.character_role_ids, + payload.user_prompt.as_deref().unwrap_or_default(), + !reference_sources.is_empty(), + ); + let mut reference_images = Vec::with_capacity(reference_sources.len()); + for source in &reference_sources { + reference_images.push( + resolve_reference_image_as_data_url( + &state, + &http_client, + source.as_str(), + "referenceImageSrc", + ) + .await?, + ); + } + let generated = if reference_images.is_empty() { + create_text_to_image_generation( + &http_client, + &settings, + state.config.dashscope_cover_image_model.clone().as_str(), + prompt.as_str(), + None, + size.as_str(), + "创建作品封面生成任务失败", + "查询作品封面任务失败", + "作品封面生成任务失败", + "作品封面生成超时或未返回图片地址", + ) + .await + } else { + create_reference_image_generation( + &http_client, + &settings, + state.config.dashscope_reference_image_model.as_str(), + prompt.as_str(), + size.as_str(), + &reference_images, + None, + "创建参考图封面任务失败", + "封面生成未返回图片地址", + "cover-edit", + ) + .await + }?; + let downloaded = download_remote_image( + &http_client, + generated.image_url.as_str(), + "下载作品封面失败", + ) + .await?; + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldCovers, + path_segments: vec![ + sanitize_storage_segment(entity_id.as_str(), "world"), + asset_id.clone(), + ], + file_name: format!("cover.{}", downloaded.extension), + content_type: downloaded.mime_type, + body: downloaded.bytes, + asset_kind: "custom_world_cover", + entity_kind: "custom_world_profile", + entity_id, + profile_id, + slot: "cover", + source_job_id: Some(generated.task_id.clone()), + }; + persist_custom_world_asset( + &state, + &owner_user_id, + upload, + GeneratedAssetResponse { + image_src: String::new(), + asset_id: asset_id.clone(), + source_type: "generated".to_string(), + model: Some(if reference_images.is_empty() { + state.config.dashscope_cover_image_model.clone() + } else { + state.config.dashscope_reference_image_model.clone() + }), + size: Some(size), + task_id: Some(generated.task_id), + prompt: Some(prompt), + actual_prompt: generated.actual_prompt, + }, + ) + .await + } + .await; + + let asset = match asset_result { + Ok(asset) => asset, + Err(error) => { + refund_asset_generation_points(&state, &owner_user_id, "custom_world_cover", &asset_id) + .await; + return Err(custom_world_ai_error_response(&request_context, error)); + } + }; Ok(json_success_body(Some(&request_context), asset)) } @@ -874,6 +903,81 @@ 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/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 3f0b402b..2efe3856 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -7,18 +7,23 @@ use axum::{ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductRecord, RuntimeReferralInviteCenterRecord, - RuntimeReferralRedeemRecord, + RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, + RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, }; use serde_json::{Value, json}; use shared_contracts::runtime::{ CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, - ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, - ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, - ProfileRechargeOrderResponse, ProfileRechargeProductResponse, - ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse, - ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, - RedeemProfileReferralInviteCodeResponse, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, + ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, + ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, + ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse, + ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, + RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, }; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; @@ -82,7 +87,8 @@ pub async fn get_profile_wallet_ledger( id: entry.wallet_ledger_id, amount_delta: entry.amount_delta, balance_after: entry.balance_after, - source_type: entry.source_type.as_str().to_string(), + source_type: format_profile_wallet_ledger_source_type(entry.source_type) + .to_string(), created_at: entry.created_at, }) .collect(), @@ -90,6 +96,31 @@ pub async fn get_profile_wallet_ledger( )) } +fn format_profile_wallet_ledger_source_type( + source_type: RuntimeProfileWalletLedgerSourceType, +) -> &'static str { + match source_type { + RuntimeProfileWalletLedgerSourceType::SnapshotSync => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC + } + RuntimeProfileWalletLedgerSourceType::InviteInviterReward => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD + } + RuntimeProfileWalletLedgerSourceType::InviteInviteeReward => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD + } + RuntimeProfileWalletLedgerSourceType::PointsRecharge => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE + } + RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME + } + RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND + } + } +} + pub async fn get_profile_recharge_center( State(state): State, Extension(request_context): Extension, @@ -367,6 +398,10 @@ fn build_redeem_profile_referral_invite_code_response( #[cfg(test)] mod tests { + use module_runtime::RuntimeProfileWalletLedgerSourceType; + + use super::format_profile_wallet_ledger_source_type; + use axum::{ body::Body, http::{Request, StatusCode}, @@ -381,6 +416,22 @@ mod tests { use crate::{app::build_router, config::AppConfig, state::AppState}; + #[test] + fn profile_wallet_ledger_source_type_formats_asset_generation_values() { + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume + ), + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME + ); + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund + ), + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND + ); + } + #[tokio::test] async fn profile_dashboard_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index ca103503..60a4a02a 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -259,6 +259,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { InviteInviterReward, InviteInviteeReward, PointsRecharge, + AssetGenerationConsume, + AssetGenerationRefund, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -399,12 +401,29 @@ pub struct RuntimeProfileWalletLedgerProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletAdjustmentProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileWalletLedgerListInput { pub user_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletAdjustmentInput { + pub user_id: String, + pub amount: u64, + pub ledger_id: String, + pub created_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralInviteCenterSnapshot { @@ -515,6 +534,8 @@ pub enum RuntimeBrowseHistoryFieldError { #[derive(Clone, Debug, PartialEq, Eq)] pub enum RuntimeProfileFieldError { MissingUserId, + MissingLedgerId, + InvalidWalletAmount, MissingInviteCode, MissingProductId, MissingWorldKey, @@ -877,6 +898,26 @@ pub fn build_runtime_profile_wallet_ledger_list_input( Ok(RuntimeProfileWalletLedgerListInput { user_id }) } +pub fn build_runtime_profile_wallet_adjustment_input( + user_id: String, + amount: u64, + ledger_id: String, + created_at_micros: i64, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + let ledger_id = + normalize_required_string(ledger_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?; + if amount == 0 || amount > i64::MAX as u64 { + return Err(RuntimeProfileFieldError::InvalidWalletAmount); + } + Ok(RuntimeProfileWalletAdjustmentInput { + user_id, + amount, + ledger_id, + created_at_micros, + }) +} + pub fn build_runtime_profile_recharge_center_get_input( user_id: String, ) -> Result { @@ -1465,6 +1506,8 @@ impl RuntimeProfileWalletLedgerSourceType { Self::InviteInviterReward => "invite_inviter_reward", Self::InviteInviteeReward => "invite_invitee_reward", Self::PointsRecharge => "points_recharge", + Self::AssetGenerationConsume => "asset_generation_consume", + Self::AssetGenerationRefund => "asset_generation_refund", } } } @@ -1697,6 +1740,8 @@ impl std::fmt::Display for RuntimeProfileFieldError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingUserId => f.write_str("profile.user_id 不能为空"), + Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"), + Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"), Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), @@ -1962,6 +2007,14 @@ mod tests { RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(), "points_recharge" ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume.as_str(), + "asset_generation_consume" + ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund.as_str(), + "asset_generation_refund" + ); } #[test] diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 19eeebb7..57d5671d 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -7,6 +7,10 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str = + "asset_generation_consume"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str = + "asset_generation_refund"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; @@ -752,19 +756,83 @@ mod tests { #[test] fn profile_wallet_ledger_response_uses_camel_case_fields() { let payload = serde_json::to_value(ProfileWalletLedgerResponse { - entries: vec![ProfileWalletLedgerEntryResponse { - id: "ledger-1".to_string(), - amount_delta: 12, - balance_after: 80, - source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(), - created_at: "2026-04-22T10:00:00Z".to_string(), - }], + entries: vec![ + ProfileWalletLedgerEntryResponse { + id: "ledger-1".to_string(), + amount_delta: 12, + balance_after: 80, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(), + created_at: "2026-04-22T10:00:00Z".to_string(), + }, + ProfileWalletLedgerEntryResponse { + id: "ledger-2".to_string(), + amount_delta: 30, + balance_after: 110, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD + .to_string(), + created_at: "2026-04-22T10:01:00Z".to_string(), + }, + ProfileWalletLedgerEntryResponse { + id: "ledger-3".to_string(), + amount_delta: 30, + balance_after: 140, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD + .to_string(), + created_at: "2026-04-22T10:02:00Z".to_string(), + }, + ProfileWalletLedgerEntryResponse { + id: "ledger-4".to_string(), + amount_delta: 60, + balance_after: 200, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(), + created_at: "2026-04-22T10:03:00Z".to_string(), + }, + ProfileWalletLedgerEntryResponse { + id: "ledger-5".to_string(), + amount_delta: -1, + balance_after: 199, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME + .to_string(), + created_at: "2026-04-22T10:04:00Z".to_string(), + }, + ProfileWalletLedgerEntryResponse { + id: "ledger-6".to_string(), + amount_delta: 1, + balance_after: 200, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND + .to_string(), + created_at: "2026-04-22T10:05:00Z".to_string(), + }, + ], }) .expect("payload should serialize"); assert_eq!(payload["entries"][0]["amountDelta"], json!(12)); assert_eq!(payload["entries"][0]["balanceAfter"], json!(80)); - assert_eq!(payload["entries"][0]["sourceType"], json!("snapshot_sync")); + assert_eq!( + payload["entries"][0]["sourceType"], + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC) + ); + assert_eq!( + payload["entries"][1]["sourceType"], + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD) + ); + assert_eq!( + payload["entries"][2]["sourceType"], + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD) + ); + assert_eq!( + payload["entries"][3]["sourceType"], + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE) + ); + assert_eq!( + payload["entries"][4]["sourceType"], + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME) + ); + assert_eq!( + payload["entries"][5]["sourceType"], + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND) + ); assert_eq!( payload["entries"][0]["createdAt"], json!("2026-04-22T10:00:00Z") diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 01311d6b..fb8272ac 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -250,5 +250,4 @@ impl SpacetimeClient { }) .await } - } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index d39563d4..f990651d 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -130,7 +130,7 @@ use module_runtime::{ build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_create_input, build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record, - build_runtime_profile_save_archive_resume_input, + build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input, build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 0eb0fdef..22d85795 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -125,6 +125,19 @@ impl From } } +impl From + for RuntimeProfileWalletAdjustmentInput +{ + fn from(input: module_runtime::RuntimeProfileWalletAdjustmentInput) -> Self { + Self { + user_id: input.user_id, + amount: input.amount, + ledger_id: input.ledger_id, + created_at_micros: input.created_at_micros, + } + } +} + impl From for RuntimeProfileRechargeCenterGetInput { @@ -663,6 +676,28 @@ pub(crate) fn map_runtime_profile_wallet_ledger_procedure_result( .collect()) } +pub(crate) fn map_runtime_profile_wallet_adjustment_procedure_result( + result: RuntimeProfileWalletAdjustmentProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 profile dashboard 快照".to_string(), + ) + })?; + + Ok(build_runtime_profile_dashboard_record( + map_runtime_profile_dashboard_snapshot(snapshot), + )) +} + pub(crate) fn map_runtime_profile_recharge_center_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result { @@ -3236,6 +3271,12 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => { module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => { + module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { + module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund + } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index c7f242ea..76bf4e34 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -118,6 +118,7 @@ pub mod complete_ai_task_and_return_procedure; pub mod confirm_asset_object_and_return_procedure; pub mod confirm_asset_object_reducer; pub mod consume_inventory_item_input_type; +pub mod consume_profile_wallet_points_and_return_procedure; pub mod continue_story_and_return_procedure; pub mod continue_story_reducer; pub mod create_ai_task_and_return_procedure; @@ -343,6 +344,7 @@ pub mod quest_step_snapshot_type; pub mod quest_treasure_inspected_signal_type; pub mod quest_turn_in_input_type; pub mod redeem_profile_referral_invite_code_procedure; +pub mod refund_profile_wallet_points_and_return_procedure; pub mod refresh_session_type; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; @@ -405,6 +407,8 @@ pub mod runtime_profile_save_archive_list_input_type; pub mod runtime_profile_save_archive_procedure_result_type; pub mod runtime_profile_save_archive_resume_input_type; pub mod runtime_profile_save_archive_snapshot_type; +pub mod runtime_profile_wallet_adjustment_input_type; +pub mod runtime_profile_wallet_adjustment_procedure_result_type; pub mod runtime_profile_wallet_ledger_entry_snapshot_type; pub mod runtime_profile_wallet_ledger_list_input_type; pub mod runtime_profile_wallet_ledger_procedure_result_type; @@ -583,6 +587,7 @@ pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return; pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return; pub use confirm_asset_object_reducer::confirm_asset_object; pub use consume_inventory_item_input_type::ConsumeInventoryItemInput; +pub use consume_profile_wallet_points_and_return_procedure::consume_profile_wallet_points_and_return; pub use continue_story_and_return_procedure::continue_story_and_return; pub use continue_story_reducer::continue_story; pub use create_ai_task_and_return_procedure::create_ai_task_and_return; @@ -808,6 +813,7 @@ pub use quest_step_snapshot_type::QuestStepSnapshot; pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal; pub use quest_turn_in_input_type::QuestTurnInInput; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; +pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; pub use refresh_session_type::RefreshSession; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; @@ -870,6 +876,8 @@ pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveL pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult; pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput; pub use runtime_profile_save_archive_snapshot_type::RuntimeProfileSaveArchiveSnapshot; +pub use runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput; +pub use runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult; pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot; pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput; pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index 7f9f63a4..fc2093e3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -15,6 +15,10 @@ pub enum RuntimeProfileWalletLedgerSourceType { InviteInviteeReward, PointsRecharge, + + AssetGenerationConsume, + + AssetGenerationRefund, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 336ef98e..67560b74 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -89,6 +89,67 @@ impl SpacetimeClient { .await } + pub async fn consume_profile_wallet_points( + &self, + user_id: String, + amount: u64, + ledger_id: String, + created_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_wallet_adjustment_input( + user_id, + amount, + ledger_id, + created_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .consume_profile_wallet_points_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_wallet_adjustment_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn refund_profile_wallet_points( + &self, + user_id: String, + amount: u64, + ledger_id: String, + created_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_wallet_adjustment_input( + user_id, + amount, + ledger_id, + created_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .refund_profile_wallet_points_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_wallet_adjustment_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn get_profile_recharge_center( &self, user_id: String, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 8db7107a..09ca0cc7 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -238,6 +238,60 @@ pub fn list_profile_wallet_ledger( } } +// 资产生成由 Axum 调用外部模型,钱包扣费必须先在 SpacetimeDB 内原子落账。 +#[spacetimedb::procedure] +pub fn consume_profile_wallet_points_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileWalletAdjustmentInput, +) -> RuntimeProfileWalletAdjustmentProcedureResult { + match ctx.try_with_tx(|tx| { + apply_profile_wallet_adjustment( + tx, + input.clone(), + RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume, + true, + ) + }) { + Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileWalletAdjustmentProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 生成链路失败时由 Axum 调用退款,ledger_id 幂等保证重复补偿不会重复加钱。 +#[spacetimedb::procedure] +pub fn refund_profile_wallet_points_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileWalletAdjustmentInput, +) -> RuntimeProfileWalletAdjustmentProcedureResult { + match ctx.try_with_tx(|tx| { + apply_profile_wallet_adjustment( + tx, + input.clone(), + RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund, + false, + ) + }) { + Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileWalletAdjustmentProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + // play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。 #[spacetimedb::procedure] pub fn get_profile_play_stats( @@ -1370,15 +1424,91 @@ fn apply_profile_wallet_delta( ledger_id: &str, created_at: Timestamp, ) -> Result { + let amount_delta = + i64::try_from(amount_delta).map_err(|_| "profile.wallet_amount 超出上限".to_string())?; + apply_profile_wallet_signed_delta( + ctx, + user_id, + amount_delta, + source_type, + ledger_id, + created_at, + false, + ) +} + +fn apply_profile_wallet_adjustment( + ctx: &ReducerContext, + input: RuntimeProfileWalletAdjustmentInput, + source_type: RuntimeProfileWalletLedgerSourceType, + consume: bool, +) -> Result { + let validated_input = build_runtime_profile_wallet_adjustment_input( + input.user_id, + input.amount, + input.ledger_id, + input.created_at_micros, + ) + .map_err(|error| error.to_string())?; + let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); + let amount_delta = if consume { + -(validated_input.amount as i64) + } else { + validated_input.amount as i64 + }; + + apply_profile_wallet_signed_delta( + ctx, + &validated_input.user_id, + amount_delta, + source_type, + &validated_input.ledger_id, + created_at, + true, + )?; + get_profile_dashboard_snapshot( + ctx, + RuntimeProfileDashboardGetInput { + user_id: validated_input.user_id, + }, + ) +} + +fn apply_profile_wallet_signed_delta( + ctx: &ReducerContext, + user_id: &str, + amount_delta: i64, + source_type: RuntimeProfileWalletLedgerSourceType, + ledger_id: &str, + created_at: Timestamp, + idempotent: bool, +) -> Result { + if idempotent + && ctx + .db + .profile_wallet_ledger() + .wallet_ledger_id() + .find(&ledger_id.to_string()) + .is_some() + { + return Ok(profile_wallet_balance(ctx, user_id)); + } + let current = ctx .db .profile_dashboard_state() .user_id() .find(&user_id.to_string()); let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let next_balance = previous_balance - .checked_add(amount_delta) - .ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?; + let next_balance = if amount_delta >= 0 { + previous_balance + .checked_add(amount_delta as u64) + .ok_or_else(|| "profile.wallet_balance 超出上限".to_string())? + } else { + previous_balance + .checked_sub(amount_delta.unsigned_abs()) + .ok_or_else(|| "叙世币余额不足".to_string())? + }; let created_state_at = current .as_ref() .map(|row| row.created_at) @@ -1413,7 +1543,7 @@ fn apply_profile_wallet_delta( ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { wallet_ledger_id: ledger_id.to_string(), user_id: user_id.to_string(), - amount_delta: amount_delta as i64, + amount_delta, balance_after: next_balance, source_type, created_at, diff --git a/src/App.tsx b/src/App.tsx index 08428da2..62ae9f3b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { APP_RUNTIME_ROUTES, normalizeAppPath, pushAppHistoryPath, + readPublicWorkCodeFromLocationSearch, resolvePathForSelectionStage, resolveSelectionStageFromPath, } from './routing/appPageRoutes'; @@ -45,6 +46,9 @@ export default function App() { ); const [runtimeReturnStage, setRuntimeReturnStage] = useState('platform'); + const [initialPublicWorkCode] = useState(() => + readPublicWorkCodeFromLocationSearch(window.location.search), + ); const setSelectionStage = useCallback((stage: SelectionStage) => { setRawSelectionStage(stage); @@ -132,6 +136,7 @@ export default function App() { void; @@ -219,6 +223,8 @@ function BigFishEntityDot({ export function BigFishRuntimeShell({ run, assetSlots = [], + shareTitle = null, + sharePublicWorkCode = null, isBusy = false, error = null, onBack, @@ -230,6 +236,9 @@ export function BigFishRuntimeShell({ const currentTouchRef = useRef(null); const lastTouchSampleRef = useRef(null); const [isRuleModalOpen, setIsRuleModalOpen] = useState(false); + const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); const [stick, setStick] = useState({ x: 0, y: 0 }); const stickRef = useRef(stick); @@ -282,6 +291,28 @@ export function BigFishRuntimeShell({ setStick(direction); onSubmitInput(direction); }; + const sharePublicWork = () => { + const publicWorkCode = sharePublicWorkCode?.trim(); + if (!publicWorkCode) { + return; + } + + const sharePath = buildPublicWorkStagePath( + 'big-fish-runtime', + publicWorkCode, + ); + const shareUrl = + typeof window === 'undefined' + ? sharePath + : new URL(sharePath, window.location.origin).href; + const title = shareTitle?.trim() || '大鱼吃小鱼'; + const shareText = `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${shareUrl}`; + + void copyTextToClipboard(shareText).then((copied) => { + setShareState(copied ? 'copied' : 'failed'); + window.setTimeout(() => setShareState('idle'), 1400); + }); + }; const beginTouchControl = (event: PointerEvent) => { if (event.target instanceof HTMLElement && event.target.closest('button')) { @@ -373,6 +404,29 @@ export function BigFishRuntimeShell({
+ {sharePublicWorkCode?.trim() ? ( + + ) : null} ) : null} + + <> + + + ) : null}
diff --git a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts index d3d5c2f4..16273c4c 100644 --- a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts +++ b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts @@ -9,6 +9,11 @@ import type { CustomWorldLibraryEntry, PlatformBrowseHistoryWriteEntry, } from '../../../packages/shared/src/contracts/runtime'; +import { + buildPublicWorkDetailPath, + pushAppHistoryPath, +} from '../../routing/appPageRoutes'; +import { ApiClientError } from '../../services/apiClient'; import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetail, @@ -16,7 +21,6 @@ import { publishRpgEntryWorldProfile, unpublishRpgEntryWorldProfile, } from '../../services/rpg-entry/rpgEntryLibraryClient'; -import { ApiClientError } from '../../services/apiClient'; import type { CustomWorldProfile } from '../../types'; import { normalizeRpgEntryAgentBackedProfile, @@ -167,6 +171,9 @@ export function useRpgEntryLibraryDetail( setSelectedDetailEntry(entry); setDetailError(null); setSelectionStage('detail'); + if (entry.publicWorkCode?.trim()) { + pushAppHistoryPath(buildPublicWorkDetailPath(entry.publicWorkCode)); + } }, [appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage], ); @@ -183,6 +190,11 @@ export function useRpgEntryLibraryDetail( entry.profileId, ); setSelectedDetailEntry(detailEntry); + if (detailEntry.publicWorkCode?.trim()) { + pushAppHistoryPath( + buildPublicWorkDetailPath(detailEntry.publicWorkCode), + ); + } void appendBrowseHistoryEntry({ ownerUserId: detailEntry.ownerUserId, profileId: detailEntry.profileId, diff --git a/src/routing/appPageRoutes.test.ts b/src/routing/appPageRoutes.test.ts index b3c68e8f..bde8fb7f 100644 --- a/src/routing/appPageRoutes.test.ts +++ b/src/routing/appPageRoutes.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest'; import { APP_RUNTIME_ROUTES, + buildPublicWorkDetailPath, + buildPublicWorkDetailUrl, + buildPublicWorkStagePath, isKnownMainAppPagePath, normalizeAppPath, + readPublicWorkCodeFromLocationSearch, resolvePathForSelectionStage, resolveSelectionStageFromPath, } from './appPageRoutes'; @@ -45,4 +49,22 @@ describe('appPageRoutes', () => { ).toBe(true); expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).toBe(true); }); + + it('builds and reads public work detail query routes', () => { + expect(buildPublicWorkDetailPath('CW-00000001')).toBe( + '/worlds/detail?work=CW-00000001', + ); + expect(buildPublicWorkDetailUrl('CW-00000001', 'https://example.test')).toBe( + 'https://example.test/worlds/detail?work=CW-00000001', + ); + expect(readPublicWorkCodeFromLocationSearch('?work=CW-00000001')).toBe( + 'CW-00000001', + ); + expect( + buildPublicWorkStagePath('puzzle-gallery-detail', 'PZ-00000002'), + ).toBe('/gallery/puzzle/detail?work=PZ-00000002'); + expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe( + '/runtime/big-fish?work=BF-00000003', + ); + }); }); diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index f8f0f413..c8474c2b 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -2,6 +2,8 @@ import type { SelectionStage } from '../components/platform-entry'; export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure'; +export const PUBLIC_WORK_QUERY_PARAM = 'work'; + const STAGE_ROUTE_ENTRIES = [ ['platform', '/'], ['detail', '/worlds/detail'], @@ -49,6 +51,37 @@ export function resolvePathForSelectionStage(stage: SelectionStage) { return APP_STAGE_ROUTES[stage] ?? APP_STAGE_ROUTES.platform; } +export function readPublicWorkCodeFromLocationSearch(search: string) { + const params = new URLSearchParams(search); + return params.get(PUBLIC_WORK_QUERY_PARAM)?.trim() || null; +} + +export function buildPublicWorkDetailPath(publicWorkCode: string) { + return buildPublicWorkStagePath('detail', publicWorkCode); +} + +export function buildPublicWorkStagePath( + stage: SelectionStage, + publicWorkCode: string, +) { + const code = publicWorkCode.trim(); + const stagePath = resolvePathForSelectionStage(stage); + if (!code) { + return stagePath; + } + + const params = new URLSearchParams(); + params.set(PUBLIC_WORK_QUERY_PARAM, code); + return `${stagePath}?${params.toString()}`; +} + +export function buildPublicWorkDetailUrl( + publicWorkCode: string, + origin = window.location.origin, +) { + return new URL(buildPublicWorkDetailPath(publicWorkCode), origin).href; +} + export function isKnownMainAppPagePath(pathname: string) { const normalizedPath = normalizeAppPath(pathname); const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES); @@ -59,11 +92,14 @@ export function isKnownMainAppPagePath(pathname: string) { } export function pushAppHistoryPath(path: string) { - const normalizedPath = normalizeAppPath(path); - if (normalizeAppPath(window.location.pathname) === normalizedPath) { + const nextUrl = new URL(path, window.location.origin); + const normalizedPath = normalizeAppPath(nextUrl.pathname); + const nextRelativeUrl = `${normalizedPath}${nextUrl.search}`; + const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`; + if (currentRelativeUrl === nextRelativeUrl) { return; } // 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。 - window.history.pushState(null, '', normalizedPath); + window.history.pushState(null, '', nextRelativeUrl); } From abe44948eed21543dec3510aa4eb17d34832b4c7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 27 Apr 2026 23:32:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8F=91=E5=B8=83=E5=8F=99=E4=B8=96=E5=B8=81?= =?UTF-8?q?=E6=B6=88=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; +}