From 04dfce57e6478e2f19ecb681d02e84fd7c2ec335 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 12:14:07 +0800 Subject: [PATCH] feat: add asset operation wallet ledger --- ...ENERATION_POINTS_CONSUMPTION_2026-04-27.md | 34 +- packages/shared/src/contracts/runtime.ts | 4 +- .../crates/api-server/src/asset_billing.rs | 31 +- server-rs/crates/api-server/src/big_fish.rs | 266 +++++------ .../crates/api-server/src/custom_world.rs | 111 ++--- .../crates/api-server/src/custom_world_ai.rs | 425 ++++++++---------- server-rs/crates/api-server/src/puzzle.rs | 276 ++++++------ .../crates/api-server/src/runtime_profile.rs | 22 +- server-rs/crates/module-runtime/src/lib.rs | 16 +- .../crates/shared-contracts/src/runtime.rs | 15 +- .../crates/spacetime-client/src/big_fish.rs | 2 +- .../crates/spacetime-client/src/mapper.rs | 15 +- ..._profile_wallet_ledger_source_type_type.rs | 4 +- .../spacetime-module/src/runtime/profile.rs | 4 +- .../RpgEntryHomeView.recharge.test.tsx | 52 +++ src/components/rpg-entry/RpgEntryHomeView.tsx | 172 ++++++- 16 files changed, 780 insertions(+), 669 deletions(-) diff --git a/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md index 5daf05d6..c395fb31 100644 --- a/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md +++ b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md @@ -1,15 +1,15 @@ -# 资产生成叙世币消耗接入方案 +# 资产操作叙世币消耗接入方案 ## 背景 -当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成由 Axum API 调用外部模型并写入 OSS,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此扣费需要拆成两层: +当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成和作品发布由 Axum API 调用外部模型或写入业务状态,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此计费需要拆成两层: - SpacetimeDB 负责钱包余额和流水的原子变更。 -- Axum 负责在发起外部生成前扣费,并在生成或持久化失败时补偿退款。 +- Axum 资产操作服务负责在执行业务资产操作前扣费,并在生成、持久化或发布失败时补偿退款。 ## 首期范围 -首期接入带 Bearer 身份、能明确归属真实用户的资产生成与发布入口: +首期接入带 Bearer 身份、能明确归属真实用户的资产操作入口: - `POST /api/custom-world/scene-image` - `POST /api/custom-world/cover-image` @@ -26,28 +26,27 @@ - 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。 - 手动上传封面:不调用外部生成模型,不消耗叙世币。 - 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。 -- 文本实体、NPC 生成:本次需求聚焦资产生成,首期只覆盖图片资产。 +- 文本实体、NPC 生成:本次需求聚焦图片资产和发布资产操作,首期只覆盖可明确归属的入口。 ## 计费规则 -- 每次图片资产生成请求消耗 `1` 枚叙世币。 -- 每次作品发布请求消耗 `1` 枚叙世币;余额不足时禁止发布。 -- 在调用外部图片生成前预扣,余额不足时直接返回业务错误,不调用外部模型。 -- 发布请求在写入发布状态前预扣,余额不足时直接返回业务错误,不调用发布 mutation。 -- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,Axum 自动发起同额退款。 +- 每次可计费资产操作消耗 `1` 枚叙世币。 +- 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行。 +- 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作。 +- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款。 - 如果退款失败,原始错误仍返回给调用方,同时服务端日志记录退款失败,便于后续人工核对。 ## 钱包流水 -新增两个流水来源类型,首期同时覆盖“资产生成”和“资产发布”这两类资产操作: +公开两个流水来源类型,统一覆盖“资产生成”和“资产发布”这两类资产操作: -- `asset_generation_consume`:资产生成预扣,`amount_delta = -1`。 -- `asset_generation_refund`:资产生成失败退款,`amount_delta = +1`。 +- `asset_operation_consume`:资产操作预扣,`amount_delta = -1`。 +- `asset_operation_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}` +- 扣费:`asset_operation_consume:{user_id}:{asset_kind}:{asset_id}` +- 退款:`asset_operation_refund:{user_id}:{asset_kind}:{asset_id}` SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID 已存在,则直接返回当前钱包快照,不重复变更余额。 @@ -56,9 +55,10 @@ 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`:在自定义世界图片生成与发布入口前扣费,错误分支退款。 +- `api-server`:资产操作服务提供统一可计费执行入口,自定义世界、Big Fish、Puzzle 业务 handler 只声明资产操作,不直接调用钱包扣费或退款。 - `shared-contracts`:新增 API 流水来源常量,保证“我的-钱包流水”输出使用稳定契约字符串。 +- `packages/shared` 与前端:统一使用 `asset_operation_consume` / `asset_operation_refund` 展示钱包流水。 ## 非目标 -本次不做分档价格、不做会员免扣、不做前端计费展示改造,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。 +本次不做分档价格、不做会员免扣,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。旧资产生成流水 source 不再作为公开契约兼容。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e11beb06..5570e378 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -56,8 +56,8 @@ export type ProfileWalletLedgerEntry = { | 'invite_inviter_reward' | 'invite_invitee_reward' | 'points_recharge' - | 'asset_generation_consume' - | 'asset_generation_refund'; + | 'asset_operation_consume' + | 'asset_operation_refund'; createdAt: string; }; diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 95ac102f..13d9485a 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -1,3 +1,5 @@ +use std::future::Future; + use axum::http::StatusCode; use serde_json::json; use spacetime_client::SpacetimeClientError; @@ -6,15 +8,36 @@ use crate::{http_error::AppError, state::AppState}; pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1; +/// 资产操作统一执行入口:业务层只声明操作类型与资源 ID,钱包扣退费由服务层收口。 +pub(crate) async fn execute_billable_asset_operation( + state: &AppState, + owner_user_id: &str, + asset_kind: &str, + asset_id: &str, + operation: Fut, +) -> Result +where + Fut: Future>, +{ + consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await?; + match operation.await { + Ok(value) => Ok(value), + Err(error) => { + refund_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await; + Err(error) + } + } +} + /// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。 -pub(crate) async fn consume_asset_operation_points( +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:{}:{}:{}", + "asset_operation_consume:{}:{}:{}", owner_user_id, asset_kind, asset_id ); state @@ -31,14 +54,14 @@ pub(crate) async fn consume_asset_operation_points( } /// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。 -pub(crate) async fn refund_asset_operation_points( +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:{}:{}:{}", + "asset_operation_refund:{}:{}:{}", owner_user_id, asset_kind, asset_id ); if let Err(error) = state diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 3fa14974..93d774dd 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -46,7 +46,7 @@ use crate::{ AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, }, api_response::json_success_body, - asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, @@ -507,182 +507,118 @@ pub async fn execute_big_fish_action( _ => 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( + let session_operation = async { + match action.as_str() { + "big_fish_compile_draft" => 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( - &state, - &owner_user_id, - &session_id, - "level_main_image", - payload.level, - None, - now, - ) - .await - .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 { - 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, - asset_url: Some(asset_url), - generated_at_micros: now, - }) - .await - } - "big_fish_generate_level_motion" => { - let asset_url = generate_big_fish_formal_asset( - &state, - &owner_user_id, - &session_id, - "level_motion", - payload.level, - payload.motion_key.as_deref(), - now, - ) - .await - .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 { - 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, - asset_url: Some(asset_url), - generated_at_micros: now, - }) - .await - } - "big_fish_generate_stage_background" => { - let asset_url = generate_big_fish_formal_asset( - &state, - &owner_user_id, - &session_id, - "stage_background", - None, - None, - now, - ) - .await - .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 { - owner_user_id: owner_user_id.clone(), - session_id: session_id.clone(), - asset_kind: "stage_background".to_string(), - level: None, - motion_key: None, - asset_url: Some(asset_url), - generated_at_micros: now, - }) - .await - } - "big_fish_publish_game" => { - state + .map_err(map_big_fish_client_error), + "big_fish_generate_level_main_image" => { + let asset_url = generate_big_fish_formal_asset( + &state, + &owner_user_id, + &session_id, + "level_main_image", + payload.level, + None, + now, + ) + .await?; + state + .spacetime_client() + .generate_big_fish_asset(BigFishAssetGenerateRecordInput { + 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, + asset_url: Some(asset_url), + generated_at_micros: now, + }) + .await + .map_err(map_big_fish_client_error) + } + "big_fish_generate_level_motion" => { + let asset_url = generate_big_fish_formal_asset( + &state, + &owner_user_id, + &session_id, + "level_motion", + payload.level, + payload.motion_key.as_deref(), + now, + ) + .await?; + state + .spacetime_client() + .generate_big_fish_asset(BigFishAssetGenerateRecordInput { + 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, + asset_url: Some(asset_url), + generated_at_micros: now, + }) + .await + .map_err(map_big_fish_client_error) + } + "big_fish_generate_stage_background" => { + let asset_url = generate_big_fish_formal_asset( + &state, + &owner_user_id, + &session_id, + "stage_background", + None, + None, + now, + ) + .await?; + state + .spacetime_client() + .generate_big_fish_asset(BigFishAssetGenerateRecordInput { + owner_user_id: owner_user_id.clone(), + session_id: session_id.clone(), + asset_kind: "stage_background".to_string(), + level: None, + motion_key: None, + asset_url: Some(asset_url), + generated_at_micros: now, + }) + .await + .map_err(map_big_fish_client_error) + } + "big_fish_publish_game" => state .spacetime_client() .publish_big_fish_game(session_id, owner_user_id.clone(), now) .await - } - other => { - return Err(big_fish_bad_request( - &request_context, - format!("action `{other}` is not supported").as_str(), - )); + .map_err(map_big_fish_client_error), + other => Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "big-fish", + "message": format!("action `{other}` is not supported"), + })), + ), } }; - 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), - )); - } + let session_result = if let Some(asset_kind) = billed_asset_kind { + execute_billable_asset_operation( + &state, + &owner_user_id, + asset_kind, + &billing_asset_id, + session_operation, + ) + .await + } else { + session_operation.await }; + let session = + session_result.map_err(|error| big_fish_error_response(&request_context, 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 a0caaebf..92473e23 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -51,7 +51,7 @@ use crate::{ AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, }, api_response::json_success_body, - asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, character_visual_assets::generate_character_primary_visual_for_profile, custom_world_agent_entities::generate_custom_world_agent_entities, @@ -351,37 +351,31 @@ pub async fn publish_custom_world_library_profile( )); } - 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.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; - 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), - )); - } - }; + let author_public_user_code = + resolve_author_public_user_code(&state, &authenticated, &request_context)?; + let author_display_name = resolve_author_display_name(&state, &authenticated); + let mutation = execute_billable_asset_operation( + &state, + &owner_user_id, + "custom_world_publish", + &profile_id, + async { + state + .spacetime_client() + .publish_custom_world_profile( + profile_id.clone(), + owner_user_id.clone(), + None, + author_public_user_code, + author_display_name, + current_utc_micros(), + ) + .await + .map_err(map_custom_world_client_error) + }, + ) + .await + .map_err(|error| custom_world_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), @@ -1246,46 +1240,33 @@ pub async fn execute_custom_world_agent_action( }; let should_bill_publish = action == "publish_world"; - if should_bill_publish { - consume_asset_operation_points( + let operation_future = async { + state + .spacetime_client() + .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + operation_id: build_prefixed_uuid_id("operation-"), + action: action.clone(), + payload_json: Some(payload_json), + submitted_at_micros, + }) + .await + .map_err(map_custom_world_client_error) + }; + let result = if should_bill_publish { + execute_billable_asset_operation( &state, &owner_user_id, "custom_world_agent_publish", &session_id, + operation_future, ) .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(), - owner_user_id: owner_user_id.clone(), - operation_id: build_prefixed_uuid_id("operation-"), - action: action.clone(), - payload_json: Some(payload_json), - submitted_at_micros, - }) - .await - { - 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), - )); - } + } else { + operation_future.await }; + let result = result.map_err(|error| custom_world_error_response(&request_context, 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 f1b862e1..225a8fee 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -28,6 +28,7 @@ use webp::Encoder as WebpEncoder; use crate::{ api_response::json_success_body, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, custom_world_result_prompts::{ build_result_entity_system_prompt, build_result_entity_user_prompt, @@ -441,126 +442,111 @@ 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()); - crate::asset_billing::consume_asset_operation_points( + let asset = execute_billable_asset_operation( &state, &owner_user_id, "scene_image", asset_id.as_str(), + 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?, + ) + } 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 { + 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 downloaded = download_remote_image( + &http_client, + generated.image_url.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 + }, ) .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)?; - 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?, - ) - } 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 { - 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 downloaded = download_remote_image( - &http_client, - generated.image_url.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 - } - .await; - - let asset = match asset_result { - Ok(asset) => asset, - Err(error) => { - 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)); - } - }; Ok(json_success_body(Some(&request_context), asset)) } @@ -717,127 +703,112 @@ 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()); - crate::asset_billing::consume_asset_operation_points( + let asset = execute_billable_asset_operation( &state, &owner_user_id, "custom_world_cover", asset_id.as_str(), + 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 .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)?; - 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) => { - 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)); - } - }; Ok(json_success_body(Some(&request_context), asset)) } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index fdeed0b2..63b69a89 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -67,7 +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}, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, http_error::AppError, prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, @@ -442,29 +442,29 @@ 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) + let (operation_type, phase_label, phase_detail, session) = match action.as_str() { + "compile_puzzle_draft" => { + let session = execute_billable_asset_operation( + &state, + &owner_user_id, + "puzzle_initial_image", + &billing_asset_id, + async { + compile_puzzle_draft_with_initial_cover( + &state, + session_id.clone(), + owner_user_id.clone(), + now, + ) + .await + .map_err(map_puzzle_client_error) + }, + ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - })?; - } - - let (operation_type, phase_label, phase_detail, session) = match action.as_str() { - "compile_puzzle_draft" => { - let session = compile_puzzle_draft_with_initial_cover( - &state, - session_id.clone(), - owner_user_id.clone(), - now, - ) - .await; + }); ( "compile_puzzle_draft", "完整拼图草稿", @@ -473,75 +473,76 @@ pub async fn execute_puzzle_agent_action( ) } "generate_puzzle_images" => { - let session = state - .spacetime_client() - .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) - .await; - let session = match session { - Ok(session) => { + let session = execute_billable_asset_operation( + &state, + &owner_user_id, + "puzzle_generated_image", + &billing_asset_id, + async { + let session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(map_puzzle_client_error)?; let draft = session.draft.clone().ok_or_else(|| { - SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()) - }); - match draft { - Ok(draft) => { - let prompt = payload - .prompt_text - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| draft.summary.clone()); - // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 - let candidate_count = 1; - let candidate_start_index = draft.candidates.len(); - let candidates = generate_puzzle_image_candidates( - &state, - owner_user_id.as_str(), - &session.session_id, - &draft.level_name, - &prompt, - payload.reference_image_src.as_deref(), - candidate_count, - candidate_start_index, - ) - .await - .map_err(SpacetimeClientError::Runtime); - match candidates { - Ok(candidates) => { - let candidates_json = serde_json::to_string( - &candidates - .iter() - .map(to_puzzle_generated_image_candidate) - .collect::>(), - ) - .map_err(|error| { - SpacetimeClientError::Runtime(format!( - "拼图候选图序列化失败:{error}" - )) - }); - match candidates_json { - Ok(candidates_json) => { - state - .spacetime_client() - .save_puzzle_generated_images( - PuzzleGeneratedImagesSaveRecordInput { - session_id: session.session_id, - owner_user_id: owner_user_id.clone(), - candidates_json, - saved_at_micros: now, - }, - ) - .await - } - Err(error) => Err(error), - } - } - Err(error) => Err(error), - } - } - Err(error) => Err(error), - } - } - Err(error) => Err(error), - }; + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + let prompt = payload + .prompt_text + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| draft.summary.clone()); + // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 + let candidate_count = 1; + let candidate_start_index = draft.candidates.len(); + let candidates = generate_puzzle_image_candidates( + &state, + owner_user_id.as_str(), + &session.session_id, + &draft.level_name, + &prompt, + payload.reference_image_src.as_deref(), + candidate_count, + candidate_start_index, + ) + .await + .map_err(|message| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })) + })?; + let candidates_json = serde_json::to_string( + &candidates + .iter() + .map(to_puzzle_generated_image_candidate) + .collect::>(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图候选图序列化失败:{error}"), + })) + })?; + state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: session.session_id, + owner_user_id: owner_user_id.clone(), + candidates_json, + saved_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); ( "generate_puzzle_images", "拼图图片生成", @@ -569,7 +570,14 @@ pub async fn execute_puzzle_agent_action( candidate_id, selected_at_micros: now, }) - .await; + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + }); ( "select_puzzle_image", "正式图确认", @@ -579,43 +587,35 @@ pub async fn execute_puzzle_agent_action( } "publish_puzzle_work" => { let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); - 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.clone(), - profile_id, - author_display_name: resolve_author_display_name(&state, &authenticated), - level_name: payload.level_name.clone(), - summary: payload.summary.clone(), - theme_tags: payload.theme_tags.clone(), - published_at_micros: now, - }) - .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 author_display_name = resolve_author_display_name(&state, &authenticated); + let profile = execute_billable_asset_operation( + &state, + &owner_user_id, + "puzzle_publish_work", + &work_id, + async { + 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.clone(), + profile_id, + author_display_name, + level_name: payload.level_name.clone(), + summary: payload.summary.clone(), + theme_tags: payload.theme_tags.clone(), + published_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + })?; let session = state .spacetime_client() @@ -654,29 +654,7 @@ 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, - map_puzzle_client_error(error), - ) - })?; + let session = session?; Ok(json_success_body( Some(&request_context), diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 2efe3856..a66a4fec 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -13,8 +13,8 @@ use module_runtime::{ use serde_json::{Value, json}; use shared_contracts::runtime::{ CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, @@ -112,11 +112,11 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::PointsRecharge => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE } - RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => { - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME } - RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND } } } @@ -417,18 +417,18 @@ mod tests { use crate::{app::build_router, config::AppConfig, state::AppState}; #[test] - fn profile_wallet_ledger_source_type_formats_asset_generation_values() { + fn profile_wallet_ledger_source_type_formats_asset_operation_values() { assert_eq!( format_profile_wallet_ledger_source_type( - RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume ), - shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME ); assert_eq!( format_profile_wallet_ledger_source_type( - RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund ), - shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND ); } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 60a4a02a..766238b9 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -259,8 +259,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { InviteInviterReward, InviteInviteeReward, PointsRecharge, - AssetGenerationConsume, - AssetGenerationRefund, + AssetOperationConsume, + AssetOperationRefund, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1506,8 +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", + Self::AssetOperationConsume => "asset_operation_consume", + Self::AssetOperationRefund => "asset_operation_refund", } } } @@ -2008,12 +2008,12 @@ mod tests { "points_recharge" ); assert_eq!( - RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume.as_str(), - "asset_generation_consume" + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume.as_str(), + "asset_operation_consume" ); assert_eq!( - RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund.as_str(), - "asset_generation_refund" + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(), + "asset_operation_refund" ); } diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 57d5671d..bd78d856 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -7,10 +7,9 @@ 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 PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str = + "asset_operation_consume"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_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"; @@ -791,7 +790,7 @@ mod tests { id: "ledger-5".to_string(), amount_delta: -1, balance_after: 199, - source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME .to_string(), created_at: "2026-04-22T10:04:00Z".to_string(), }, @@ -799,7 +798,7 @@ mod tests { id: "ledger-6".to_string(), amount_delta: 1, balance_after: 200, - source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND .to_string(), created_at: "2026-04-22T10:05:00Z".to_string(), }, @@ -827,11 +826,11 @@ mod tests { ); assert_eq!( payload["entries"][4]["sourceType"], - json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME) + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME) ); assert_eq!( payload["entries"][5]["sourceType"], - json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND) + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND) ); assert_eq!( payload["entries"][0]["createdAt"], diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 5544f606..e48decf7 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -148,7 +148,7 @@ impl SpacetimeClient { move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_big_fish_works_procedure_result); + .and_then(|result| map_big_fish_works_procedure_result(result, None)); send_once(&sender, mapped); }, ); diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 92e78bbb..52dc3e11 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -3278,11 +3278,11 @@ 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::AssetOperationConsume => { + module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { - module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => { + module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund } } } @@ -4626,6 +4626,8 @@ struct CompatibleBigFishWorkSummaryRecord { level_main_image_ready_count: u32, level_motion_ready_count: u32, background_ready: bool, + #[serde(default)] + play_count: u32, } impl CompatibleBigFishWorkSummaryRecord { @@ -4650,6 +4652,7 @@ impl CompatibleBigFishWorkSummaryRecord { level_main_image_ready_count: self.level_main_image_ready_count, level_motion_ready_count: self.level_motion_ready_count, background_ready: self.background_ready, + play_count: self.play_count, } } } @@ -4678,7 +4681,7 @@ mod tests { "level_motion_ready_count":0, "background_ready":false }]"# - .to_string(), + .to_string(), ), error_message: None, }; @@ -4710,7 +4713,7 @@ mod tests { "level_motion_ready_count":16, "background_ready":true }]"# - .to_string(), + .to_string(), ), error_message: None, }; 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 fc2093e3..dd93e385 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 @@ -16,9 +16,9 @@ pub enum RuntimeProfileWalletLedgerSourceType { PointsRecharge, - AssetGenerationConsume, + AssetOperationConsume, - AssetGenerationRefund, + AssetOperationRefund, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 09ca0cc7..8dc65212 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -248,7 +248,7 @@ pub fn consume_profile_wallet_points_and_return( apply_profile_wallet_adjustment( tx, input.clone(), - RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume, + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, true, ) }) { @@ -275,7 +275,7 @@ pub fn refund_profile_wallet_points_and_return( apply_profile_wallet_adjustment( tx, input.clone(), - RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund, + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, false, ) }) { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 7e3204f8..75efef9e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -11,7 +11,29 @@ import { } from './RpgEntryHomeView'; import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; +const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({ + mockGetRpgProfileWalletLedger: vi.fn(async () => ({ + entries: [ + { + id: 'ledger-1', + amountDelta: -1, + balanceAfter: 29, + sourceType: 'asset_operation_consume', + createdAt: '2026-04-28T10:00:00Z', + }, + { + id: 'ledger-2', + amountDelta: 30, + balanceAfter: 30, + sourceType: 'invite_invitee_reward', + createdAt: '2026-04-28T09:00:00Z', + }, + ], + })), +})); + vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ + getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger, getRpgProfileRechargeCenter: vi.fn(async () => ({ walletBalance: 0, membership: { @@ -285,6 +307,36 @@ test('opens recharge modal and submits points product', async () => { await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1)); }); +test('opens wallet ledger modal from narrative coin card', async () => { + const user = userEvent.setup(); + + renderProfileView(); + await user.click(screen.getByText('剩余叙世币')); + + expect(await screen.findByText('叙世币账单')).toBeTruthy(); + expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1); + expect(screen.getByText('资产操作消耗')).toBeTruthy(); + expect(screen.getByText('-1')).toBeTruthy(); + expect(screen.getByText('填写邀请码奖励')).toBeTruthy(); + expect(screen.getByText('+30')).toBeTruthy(); +}); + +test('wallet ledger modal shows empty and error states', async () => { + const user = userEvent.setup(); + mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] }); + + renderProfileView(); + await user.click(screen.getByText('剩余叙世币')); + expect(await screen.findByText('暂无账单记录')).toBeTruthy(); + + await user.click(screen.getByLabelText('关闭叙世币账单')); + mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败')); + await user.click(screen.getByText('剩余叙世币')); + + expect(await screen.findByText('加载失败')).toBeTruthy(); + expect(screen.getByText('重新加载')).toBeTruthy(); +}); + test('shows a reachable login entry in logged out mobile shell', async () => { const user = userEvent.setup(); const openLoginModal = vi.fn(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 5b9d370c..3e7edf32 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -34,6 +34,7 @@ import type { PlatformBrowseHistoryEntry, ProfileDashboardCardKey, ProfileDashboardSummary, + ProfileWalletLedgerResponse, ProfileRechargeCenterResponse, ProfileRechargeProduct, ProfileReferralInviteCenterResponse, @@ -46,6 +47,7 @@ import { createRpgProfileRechargeOrder, getRpgProfileRechargeCenter, getRpgProfileReferralInviteCenter, + getRpgProfileWalletLedger, redeemRpgProfileReferralInviteCode, } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; @@ -923,6 +925,128 @@ function formatMembershipDuration(days: number) { return `${days}天`; } +const WALLET_LEDGER_SOURCE_LABELS: Record = { + points_recharge: '叙世币充值', + invite_inviter_reward: '邀请奖励', + invite_invitee_reward: '填写邀请码奖励', + snapshot_sync: '账户同步', + asset_operation_consume: '资产操作消耗', + asset_operation_refund: '资产操作退回', +}; + +function formatWalletLedgerAmount(amountDelta: number) { + return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`; +} + +function WalletLedgerModal({ + ledger, + fallbackBalance, + isLoading, + error, + onClose, + onRetry, +}: { + ledger: ProfileWalletLedgerResponse | null; + fallbackBalance: number; + isLoading: boolean; + error: string | null; + onClose: () => void; + onRetry: () => void; +}) { + const entries = ledger?.entries ?? []; + const balance = entries[0]?.balanceAfter ?? fallbackBalance; + + return ( +
+
+ +
+
+
+ LEDGER +
+
叙世币账单
+
+ + {balance}叙世币 +
+
+ + {error ? ( +
+
{error}
+ +
+ ) : isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ ) : entries.length === 0 ? ( +
+ 暂无账单记录 +
+ ) : ( +
+ {entries.map((entry) => { + const isIncome = entry.amountDelta > 0; + const label = + WALLET_LEDGER_SOURCE_LABELS[entry.sourceType] ?? + entry.sourceType; + + return ( +
+
+
+ {label} +
+
+ {formatPlatformWorldTime(entry.createdAt)} +
+
+
+
+ {formatWalletLedgerAmount(entry.amountDelta)} +
+
+ 余额 {entry.balanceAfter} +
+
+
+ ); + })} +
+ )} +
+
+
+ ); +} + function AccountRechargeModal({ center, activeTab, @@ -1304,6 +1428,13 @@ export function RpgEntryHomeView({ const [isLoadingRecharge, setIsLoadingRecharge] = useState(false); const [submittingRechargeProductId, setSubmittingRechargeProductId] = useState(null); + const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false); + const [walletLedger, setWalletLedger] = + useState(null); + const [walletLedgerError, setWalletLedgerError] = useState( + null, + ); + const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false); const [profilePopupPanel, setProfilePopupPanel] = useState(null); const [referralCenter, setReferralCenter] = @@ -1415,6 +1546,23 @@ export function RpgEntryHomeView({ }) .finally(() => setIsLoadingRecharge(false)); }; + const loadWalletLedger = () => { + setWalletLedgerError(null); + setIsLoadingWalletLedger(true); + void getRpgProfileWalletLedger() + .then(setWalletLedger) + .catch((error: unknown) => { + setWalletLedger(null); + setWalletLedgerError( + error instanceof Error ? error.message : '读取叙世币账单失败', + ); + }) + .finally(() => setIsLoadingWalletLedger(false)); + }; + const openWalletLedgerPanel = () => { + setIsWalletLedgerOpen(true); + loadWalletLedger(); + }; const submitRechargeProduct = (product: ProfileRechargeProduct) => { if (submittingRechargeProductId) { return; @@ -1865,7 +2013,7 @@ export function RpgEntryHomeView({ label="剩余叙世币" value="暂不可用" icon={Coins} - onClick={onOpenProfileDashboardCard} + onClick={openWalletLedgerPanel} /> ) : null} + {isWalletLedgerOpen ? ( + setIsWalletLedgerOpen(false)} + onRetry={loadWalletLedger} + /> + ) : null}
); } @@ -2422,6 +2580,16 @@ export function RpgEntryHomeView({ onSubmitRedeem={submitReferralInviteCode} /> ) : null} + {isWalletLedgerOpen ? ( + setIsWalletLedgerOpen(false)} + onRetry={loadWalletLedger} + /> + ) : null} ); }