feat: add asset operation wallet ledger
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-04-28 12:14:07 +08:00
parent 3cdbf36859
commit 04dfce57e6
16 changed files with 780 additions and 669 deletions

View File

@@ -1,15 +1,15 @@
# 资产生成叙世币消耗接入方案 # 资产操作叙世币消耗接入方案
## 背景 ## 背景
当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成由 Axum API 调用外部模型写入 OSSSpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此费需要拆成两层: 当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成和作品发布由 Axum API 调用外部模型写入业务状态SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此费需要拆成两层:
- SpacetimeDB 负责钱包余额和流水的原子变更。 - SpacetimeDB 负责钱包余额和流水的原子变更。
- Axum 负责在发起外部生成前扣费,并在生成持久化失败时补偿退款。 - Axum 资产操作服务负责在执行业务资产操作前扣费,并在生成持久化或发布失败时补偿退款。
## 首期范围 ## 首期范围
首期接入带 Bearer 身份、能明确归属真实用户的资产生成与发布入口: 首期接入带 Bearer 身份、能明确归属真实用户的资产操作入口:
- `POST /api/custom-world/scene-image` - `POST /api/custom-world/scene-image`
- `POST /api/custom-world/cover-image` - `POST /api/custom-world/cover-image`
@@ -26,28 +26,27 @@
- 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。 - 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。
- 手动上传封面:不调用外部生成模型,不消耗叙世币。 - 手动上传封面:不调用外部生成模型,不消耗叙世币。
- 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。 - 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。
- 文本实体、NPC 生成:本次需求聚焦资产生成,首期只覆盖图片资产。 - 文本实体、NPC 生成:本次需求聚焦图片资产和发布资产操作,首期只覆盖可明确归属的入口
## 计费规则 ## 计费规则
- 每次图片资产生成请求消耗 `1` 枚叙世币。 - 每次可计费资产操作消耗 `1` 枚叙世币。
- 每次作品发布请求消耗 `1` 枚叙世币;余额不足时禁止发布 - 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行
- 在调用外部图片生成前预扣,余额不足时直接返回业务错误,不调用外部模型 - 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作
- 发布请求在写入发布状态前预扣,余额不足时直接返回业务错误,不调用发布 mutation - 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款
- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败Axum 自动发起同额退款。
- 如果退款失败,原始错误仍返回给调用方,同时服务端日志记录退款失败,便于后续人工核对。 - 如果退款失败,原始错误仍返回给调用方,同时服务端日志记录退款失败,便于后续人工核对。
## 钱包流水 ## 钱包流水
新增两个流水来源类型,首期同时覆盖“资产生成”和“资产发布”这两类资产操作: 公开两个流水来源类型,统一覆盖“资产生成”和“资产发布”这两类资产操作:
- `asset_generation_consume`:资产生成预扣,`amount_delta = -1` - `asset_operation_consume`:资产操作预扣,`amount_delta = -1`
- `asset_generation_refund`:资产生成失败退款,`amount_delta = +1` - `asset_operation_refund`:资产操作失败退款,`amount_delta = +1`
`wallet_ledger_id` 由 Axum 传入,格式: `wallet_ledger_id` 由 Axum 传入,格式:
- 扣费:`asset_generation_consume:{user_id}:{asset_kind}:{asset_id}` - 扣费:`asset_operation_consume:{user_id}:{asset_kind}:{asset_id}`
- 退款:`asset_generation_refund:{user_id}:{asset_kind}:{asset_id}` - 退款:`asset_operation_refund:{user_id}:{asset_kind}:{asset_id}`
SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID 已存在,则直接返回当前钱包快照,不重复变更余额。 SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID 已存在,则直接返回当前钱包快照,不重复变更余额。
@@ -56,9 +55,10 @@ SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID
- `module-runtime`:新增钱包调整输入、钱包调整结果、流水来源枚举。 - `module-runtime`:新增钱包调整输入、钱包调整结果、流水来源枚举。
- `spacetime-module`:新增 `consume_profile_wallet_points_and_return``refund_profile_wallet_points_and_return` procedure并扩展钱包变更 helper 支持负数。 - `spacetime-module`:新增 `consume_profile_wallet_points_and_return``refund_profile_wallet_points_and_return` procedure并扩展钱包变更 helper 支持负数。
- `spacetime-client`:新增对应调用方法和绑定类型。 - `spacetime-client`:新增对应调用方法和绑定类型。
- `api-server`在自定义世界图片生成与发布入口前扣费,错误分支退款。 - `api-server`资产操作服务提供统一可计费执行入口自定义世界、Big Fish、Puzzle 业务 handler 只声明资产操作,不直接调用钱包扣费或退款。
- `shared-contracts`:新增 API 流水来源常量,保证“我的-钱包流水”输出使用稳定契约字符串。 - `shared-contracts`:新增 API 流水来源常量,保证“我的-钱包流水”输出使用稳定契约字符串。
- `packages/shared` 与前端:统一使用 `asset_operation_consume` / `asset_operation_refund` 展示钱包流水。
## 非目标 ## 非目标
本次不做分档价格、不做会员免扣、不做前端计费展示改造,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。 本次不做分档价格、不做会员免扣,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。旧资产生成流水 source 不再作为公开契约兼容。

View File

@@ -56,8 +56,8 @@ export type ProfileWalletLedgerEntry = {
| 'invite_inviter_reward' | 'invite_inviter_reward'
| 'invite_invitee_reward' | 'invite_invitee_reward'
| 'points_recharge' | 'points_recharge'
| 'asset_generation_consume' | 'asset_operation_consume'
| 'asset_generation_refund'; | 'asset_operation_refund';
createdAt: string; createdAt: string;
}; };

View File

@@ -1,3 +1,5 @@
use std::future::Future;
use axum::http::StatusCode; use axum::http::StatusCode;
use serde_json::json; use serde_json::json;
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
@@ -6,15 +8,36 @@ use crate::{http_error::AppError, state::AppState};
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1; pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
/// 资产操作统一执行入口:业务层只声明操作类型与资源 ID钱包扣退费由服务层收口。
pub(crate) async fn execute_billable_asset_operation<T, Fut>(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
operation: Fut,
) -> Result<T, AppError>
where
Fut: Future<Output = Result<T, AppError>>,
{
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 参与构造,保证重试幂等。 /// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
pub(crate) async fn consume_asset_operation_points( async fn consume_asset_operation_points(
state: &AppState, state: &AppState,
owner_user_id: &str, owner_user_id: &str,
asset_kind: &str, asset_kind: &str,
asset_id: &str, asset_id: &str,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let ledger_id = format!( let ledger_id = format!(
"asset_generation_consume:{}:{}:{}", "asset_operation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id owner_user_id, asset_kind, asset_id
); );
state state
@@ -31,14 +54,14 @@ pub(crate) async fn consume_asset_operation_points(
} }
/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。 /// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。
pub(crate) async fn refund_asset_operation_points( async fn refund_asset_operation_points(
state: &AppState, state: &AppState,
owner_user_id: &str, owner_user_id: &str,
asset_kind: &str, asset_kind: &str,
asset_id: &str, asset_id: &str,
) { ) {
let ledger_id = format!( let ledger_id = format!(
"asset_generation_refund:{}:{}:{}", "asset_operation_refund:{}:{}:{}",
owner_user_id, asset_kind, asset_id owner_user_id, asset_kind, asset_id
); );
if let Err(error) = state if let Err(error) = state

View File

@@ -46,7 +46,7 @@ use crate::{
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
}, },
api_response::json_success_body, api_response::json_success_body,
asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken, auth::AuthenticatedAccessToken,
http_error::AppError, http_error::AppError,
request_context::RequestContext, request_context::RequestContext,
@@ -507,182 +507,118 @@ pub async fn execute_big_fish_action(
_ => None, _ => None,
}; };
let billing_asset_id = format!("{session_id}:{now}"); let billing_asset_id = format!("{session_id}:{now}");
if let Some(asset_kind) = billed_asset_kind { let session_operation = async {
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id) match action.as_str() {
.await "big_fish_compile_draft" => compile_big_fish_draft_with_all_assets(
.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, &state,
session_id.clone(), session_id.clone(),
owner_user_id.clone(), owner_user_id.clone(),
now, now,
) )
.await .await
} .map_err(map_big_fish_client_error),
"big_fish_generate_level_main_image" => { "big_fish_generate_level_main_image" => {
let asset_url = generate_big_fish_formal_asset( let asset_url = generate_big_fish_formal_asset(
&state, &state,
&owner_user_id, &owner_user_id,
&session_id, &session_id,
"level_main_image", "level_main_image",
payload.level, payload.level,
None, None,
now, now,
) )
.await .await?;
.map_err(|error| { state
if let Some(asset_kind) = billed_asset_kind { .spacetime_client()
tokio::spawn({ .generate_big_fish_asset(BigFishAssetGenerateRecordInput {
let state = state.clone(); owner_user_id: owner_user_id.clone(),
let owner_user_id = owner_user_id.clone(); session_id: session_id.clone(),
let billing_asset_id = billing_asset_id.clone(); asset_kind: "level_main_image".to_string(),
async move { level: payload.level,
refund_asset_operation_points( motion_key: None,
&state, asset_url: Some(asset_url),
&owner_user_id, generated_at_micros: now,
asset_kind, })
&billing_asset_id, .await
) .map_err(map_big_fish_client_error)
.await; }
} "big_fish_generate_level_motion" => {
}); let asset_url = generate_big_fish_formal_asset(
} &state,
big_fish_error_response(&request_context, error) &owner_user_id,
})?; &session_id,
state "level_motion",
.spacetime_client() payload.level,
.generate_big_fish_asset(BigFishAssetGenerateRecordInput { payload.motion_key.as_deref(),
owner_user_id: owner_user_id.clone(), now,
session_id: session_id.clone(), )
asset_kind: "level_main_image".to_string(), .await?;
level: payload.level, state
motion_key: None, .spacetime_client()
asset_url: Some(asset_url), .generate_big_fish_asset(BigFishAssetGenerateRecordInput {
generated_at_micros: now, owner_user_id: owner_user_id.clone(),
}) session_id: session_id.clone(),
.await asset_kind: "level_motion".to_string(),
} level: payload.level,
"big_fish_generate_level_motion" => { motion_key: payload.motion_key,
let asset_url = generate_big_fish_formal_asset( asset_url: Some(asset_url),
&state, generated_at_micros: now,
&owner_user_id, })
&session_id, .await
"level_motion", .map_err(map_big_fish_client_error)
payload.level, }
payload.motion_key.as_deref(), "big_fish_generate_stage_background" => {
now, let asset_url = generate_big_fish_formal_asset(
) &state,
.await &owner_user_id,
.map_err(|error| { &session_id,
if let Some(asset_kind) = billed_asset_kind { "stage_background",
tokio::spawn({ None,
let state = state.clone(); None,
let owner_user_id = owner_user_id.clone(); now,
let billing_asset_id = billing_asset_id.clone(); )
async move { .await?;
refund_asset_operation_points( state
&state, .spacetime_client()
&owner_user_id, .generate_big_fish_asset(BigFishAssetGenerateRecordInput {
asset_kind, owner_user_id: owner_user_id.clone(),
&billing_asset_id, session_id: session_id.clone(),
) asset_kind: "stage_background".to_string(),
.await; level: None,
} motion_key: None,
}); asset_url: Some(asset_url),
} generated_at_micros: now,
big_fish_error_response(&request_context, error) })
})?; .await
state .map_err(map_big_fish_client_error)
.spacetime_client() }
.generate_big_fish_asset(BigFishAssetGenerateRecordInput { "big_fish_publish_game" => state
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
.spacetime_client() .spacetime_client()
.publish_big_fish_game(session_id, owner_user_id.clone(), now) .publish_big_fish_game(session_id, owner_user_id.clone(), now)
.await .await
} .map_err(map_big_fish_client_error),
other => { other => Err(
return Err(big_fish_bad_request( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
&request_context, "provider": "big-fish",
format!("action `{other}` is not supported").as_str(), "message": format!("action `{other}` is not supported"),
)); })),
),
} }
}; };
let session = match session_result { let session_result = if let Some(asset_kind) = billed_asset_kind {
Ok(session) => session, execute_billable_asset_operation(
Err(error) => { &state,
if let Some(asset_kind) = billed_asset_kind { &owner_user_id,
refund_asset_operation_points( asset_kind,
&state, &billing_asset_id,
&owner_user_id, session_operation,
asset_kind, )
&billing_asset_id, .await
) } else {
.await; session_operation.await
}
return Err(big_fish_error_response(
&request_context,
map_big_fish_client_error(error),
));
}
}; };
let session =
session_result.map_err(|error| big_fish_error_response(&request_context, error))?;
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),

View File

@@ -51,7 +51,7 @@ use crate::{
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
}, },
api_response::json_success_body, api_response::json_success_body,
asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken, auth::AuthenticatedAccessToken,
character_visual_assets::generate_character_primary_visual_for_profile, character_visual_assets::generate_character_primary_visual_for_profile,
custom_world_agent_entities::generate_custom_world_agent_entities, 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) let author_public_user_code =
.await resolve_author_public_user_code(&state, &authenticated, &request_context)?;
.map_err(|error| custom_world_error_response(&request_context, error))?; let author_display_name = resolve_author_display_name(&state, &authenticated);
let mutation = execute_billable_asset_operation(
let mutation_result = state &state,
.spacetime_client() &owner_user_id,
.publish_custom_world_profile( "custom_world_publish",
profile_id.clone(), &profile_id,
owner_user_id.clone(), async {
None, state
resolve_author_public_user_code(&state, &authenticated, &request_context)?, .spacetime_client()
resolve_author_display_name(&state, &authenticated), .publish_custom_world_profile(
current_utc_micros(), profile_id.clone(),
) owner_user_id.clone(),
.await; None,
let mutation = match mutation_result { author_public_user_code,
Ok(mutation) => mutation, author_display_name,
Err(error) => { current_utc_micros(),
refund_asset_operation_points( )
&state, .await
&owner_user_id, .map_err(map_custom_world_client_error)
"custom_world_publish", },
&profile_id, )
) .await
.await; .map_err(|error| custom_world_error_response(&request_context, error))?;
return Err(custom_world_error_response(
&request_context,
map_custom_world_client_error(error),
));
}
};
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),
@@ -1246,46 +1240,33 @@ pub async fn execute_custom_world_agent_action(
}; };
let should_bill_publish = action == "publish_world"; let should_bill_publish = action == "publish_world";
if should_bill_publish { let operation_future = async {
consume_asset_operation_points( 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, &state,
&owner_user_id, &owner_user_id,
"custom_world_agent_publish", "custom_world_agent_publish",
&session_id, &session_id,
operation_future,
) )
.await .await
.map_err(|error| custom_world_error_response(&request_context, error))?; } else {
} operation_future.await
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),
));
}
}; };
let result = result.map_err(|error| custom_world_error_response(&request_context, error))?;
if matches!( if matches!(
action.as_str(), action.as_str(),

View File

@@ -28,6 +28,7 @@ use webp::Encoder as WebpEncoder;
use crate::{ use crate::{
api_response::json_success_body, api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken, auth::AuthenticatedAccessToken,
custom_world_result_prompts::{ custom_world_result_prompts::{
build_result_entity_system_prompt, build_result_entity_user_prompt, 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) let normalized = normalize_scene_image_request(payload)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?; .map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-scene-{}", current_utc_millis()); let asset_id = format!("custom-scene-{}", current_utc_millis());
crate::asset_billing::consume_asset_operation_points( let asset = execute_billable_asset_operation(
&state, &state,
&owner_user_id, &owner_user_id,
"scene_image", "scene_image",
asset_id.as_str(), 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 .await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?; .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)) 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 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 size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
let asset_id = format!("custom-cover-{}", current_utc_millis()); let asset_id = format!("custom-cover-{}", current_utc_millis());
crate::asset_billing::consume_asset_operation_points( let asset = execute_billable_asset_operation(
&state, &state,
&owner_user_id, &owner_user_id,
"custom_world_cover", "custom_world_cover",
asset_id.as_str(), 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 .await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?; .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)) Ok(json_success_body(Some(&request_context), asset))
} }

View File

@@ -67,7 +67,7 @@ use tokio::time::sleep;
use crate::{ use crate::{
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
api_response::json_success_body, api_response::json_success_body,
asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken, auth::AuthenticatedAccessToken,
http_error::AppError, http_error::AppError,
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, 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 owner_user_id = authenticated.claims().user_id().to_string();
let now = current_utc_micros(); let now = current_utc_micros();
let action = payload.action.trim().to_string(); 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}"); let billing_asset_id = format!("{session_id}:{now}");
if let Some(asset_kind) = billed_asset_kind { let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id) "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 .await
.map_err(|error| { .map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, 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", "compile_puzzle_draft",
"完整拼图草稿", "完整拼图草稿",
@@ -473,75 +473,76 @@ pub async fn execute_puzzle_agent_action(
) )
} }
"generate_puzzle_images" => { "generate_puzzle_images" => {
let session = state let session = execute_billable_asset_operation(
.spacetime_client() &state,
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) &owner_user_id,
.await; "puzzle_generated_image",
let session = match session { &billing_asset_id,
Ok(session) => { 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(|| { let draft = session.draft.clone().ok_or_else(|| {
SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()) AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
}); "provider": PUZZLE_AGENT_API_BASE_PROVIDER,
match draft { "message": "拼图结果页草稿尚未生成",
Ok(draft) => { }))
let prompt = payload })?;
.prompt_text let prompt = payload
.clone() .prompt_text
.filter(|value| !value.trim().is_empty()) .clone()
.unwrap_or_else(|| draft.summary.clone()); .filter(|value| !value.trim().is_empty())
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 .unwrap_or_else(|| draft.summary.clone());
let candidate_count = 1; // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_start_index = draft.candidates.len(); let candidate_count = 1;
let candidates = generate_puzzle_image_candidates( let candidate_start_index = draft.candidates.len();
&state, let candidates = generate_puzzle_image_candidates(
owner_user_id.as_str(), &state,
&session.session_id, owner_user_id.as_str(),
&draft.level_name, &session.session_id,
&prompt, &draft.level_name,
payload.reference_image_src.as_deref(), &prompt,
candidate_count, payload.reference_image_src.as_deref(),
candidate_start_index, candidate_count,
) candidate_start_index,
.await )
.map_err(SpacetimeClientError::Runtime); .await
match candidates { .map_err(|message| {
Ok(candidates) => { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
let candidates_json = serde_json::to_string( "provider": PUZZLE_AGENT_API_BASE_PROVIDER,
&candidates "message": message,
.iter() }))
.map(to_puzzle_generated_image_candidate) })?;
.collect::<Vec<_>>(), let candidates_json = serde_json::to_string(
) &candidates
.map_err(|error| { .iter()
SpacetimeClientError::Runtime(format!( .map(to_puzzle_generated_image_candidate)
"拼图候选图序列化失败:{error}" .collect::<Vec<_>>(),
)) )
}); .map_err(|error| {
match candidates_json { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
Ok(candidates_json) => { "provider": PUZZLE_AGENT_API_BASE_PROVIDER,
state "message": format!("拼图候选图序列化失败:{error}"),
.spacetime_client() }))
.save_puzzle_generated_images( })?;
PuzzleGeneratedImagesSaveRecordInput { state
session_id: session.session_id, .spacetime_client()
owner_user_id: owner_user_id.clone(), .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
candidates_json, session_id: session.session_id,
saved_at_micros: now, owner_user_id: owner_user_id.clone(),
}, candidates_json,
) saved_at_micros: now,
.await })
} .await
Err(error) => Err(error), .map_err(map_puzzle_client_error)
} },
} )
Err(error) => Err(error), .await
} .map_err(|error| {
} puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
Err(error) => Err(error), });
}
}
Err(error) => Err(error),
};
( (
"generate_puzzle_images", "generate_puzzle_images",
"拼图图片生成", "拼图图片生成",
@@ -569,7 +570,14 @@ pub async fn execute_puzzle_agent_action(
candidate_id, candidate_id,
selected_at_micros: now, 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", "select_puzzle_image",
"正式图确认", "正式图确认",
@@ -579,43 +587,35 @@ pub async fn execute_puzzle_agent_action(
} }
"publish_puzzle_work" => { "publish_puzzle_work" => {
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); 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) let author_display_name = resolve_author_display_name(&state, &authenticated);
.await let profile = execute_billable_asset_operation(
.map_err(|error| { &state,
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) &owner_user_id,
})?; "puzzle_publish_work",
let profile_result = state &work_id,
.spacetime_client() async {
.publish_puzzle_work(PuzzlePublishRecordInput { state
session_id: session_id.clone(), .spacetime_client()
owner_user_id: owner_user_id.clone(), .publish_puzzle_work(PuzzlePublishRecordInput {
// 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。 session_id: session_id.clone(),
work_id: work_id.clone(), owner_user_id: owner_user_id.clone(),
profile_id, // 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。
author_display_name: resolve_author_display_name(&state, &authenticated), work_id: work_id.clone(),
level_name: payload.level_name.clone(), profile_id,
summary: payload.summary.clone(), author_display_name,
theme_tags: payload.theme_tags.clone(), level_name: payload.level_name.clone(),
published_at_micros: now, summary: payload.summary.clone(),
}) theme_tags: payload.theme_tags.clone(),
.await; published_at_micros: now,
let profile = match profile_result { })
Ok(profile) => profile, .await
Err(error) => { .map_err(map_puzzle_client_error)
refund_asset_operation_points( },
&state, )
&owner_user_id, .await
"puzzle_publish_work", .map_err(|error| {
&work_id, puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
) })?;
.await;
return Err(puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
));
}
};
let session = state let session = state
.spacetime_client() .spacetime_client()
@@ -654,29 +654,7 @@ pub async fn execute_puzzle_agent_action(
} }
}; };
let session = session.map_err(|error| { let session = session?;
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),
)
})?;
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),

View File

@@ -13,8 +13,8 @@ use module_runtime::{
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::runtime::{ use shared_contracts::runtime::{
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
@@ -112,11 +112,11 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::PointsRecharge => { RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE
} }
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => { RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
} }
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
} }
} }
} }
@@ -417,18 +417,18 @@ mod tests {
use crate::{app::build_router, config::AppConfig, state::AppState}; use crate::{app::build_router, config::AppConfig, state::AppState};
#[test] #[test]
fn profile_wallet_ledger_source_type_formats_asset_generation_values() { fn profile_wallet_ledger_source_type_formats_asset_operation_values() {
assert_eq!( assert_eq!(
format_profile_wallet_ledger_source_type( 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!( assert_eq!(
format_profile_wallet_ledger_source_type( 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
); );
} }

View File

@@ -259,8 +259,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
InviteInviterReward, InviteInviterReward,
InviteInviteeReward, InviteInviteeReward,
PointsRecharge, PointsRecharge,
AssetGenerationConsume, AssetOperationConsume,
AssetGenerationRefund, AssetOperationRefund,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -1506,8 +1506,8 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::InviteInviterReward => "invite_inviter_reward", Self::InviteInviterReward => "invite_inviter_reward",
Self::InviteInviteeReward => "invite_invitee_reward", Self::InviteInviteeReward => "invite_invitee_reward",
Self::PointsRecharge => "points_recharge", Self::PointsRecharge => "points_recharge",
Self::AssetGenerationConsume => "asset_generation_consume", Self::AssetOperationConsume => "asset_operation_consume",
Self::AssetGenerationRefund => "asset_generation_refund", Self::AssetOperationRefund => "asset_operation_refund",
} }
} }
} }
@@ -2008,12 +2008,12 @@ mod tests {
"points_recharge" "points_recharge"
); );
assert_eq!( assert_eq!(
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume.as_str(), RuntimeProfileWalletLedgerSourceType::AssetOperationConsume.as_str(),
"asset_generation_consume" "asset_operation_consume"
); );
assert_eq!( assert_eq!(
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund.as_str(), RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
"asset_generation_refund" "asset_operation_refund"
); );
} }

View File

@@ -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_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_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_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str = pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str =
"asset_generation_consume"; "asset_operation_consume";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str = pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund";
"asset_generation_refund";
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -791,7 +790,7 @@ mod tests {
id: "ledger-5".to_string(), id: "ledger-5".to_string(),
amount_delta: -1, amount_delta: -1,
balance_after: 199, 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(), .to_string(),
created_at: "2026-04-22T10:04:00Z".to_string(), created_at: "2026-04-22T10:04:00Z".to_string(),
}, },
@@ -799,7 +798,7 @@ mod tests {
id: "ledger-6".to_string(), id: "ledger-6".to_string(),
amount_delta: 1, amount_delta: 1,
balance_after: 200, 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(), .to_string(),
created_at: "2026-04-22T10:05:00Z".to_string(), created_at: "2026-04-22T10:05:00Z".to_string(),
}, },
@@ -827,11 +826,11 @@ mod tests {
); );
assert_eq!( assert_eq!(
payload["entries"][4]["sourceType"], payload["entries"][4]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME) json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME)
); );
assert_eq!( assert_eq!(
payload["entries"][5]["sourceType"], payload["entries"][5]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND) json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)
); );
assert_eq!( assert_eq!(
payload["entries"][0]["createdAt"], payload["entries"][0]["createdAt"],

View File

@@ -148,7 +148,7 @@ impl SpacetimeClient {
move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .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); send_once(&sender, mapped);
}, },
); );

View File

@@ -3278,11 +3278,11 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => { crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge
} }
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => { crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
} }
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund
} }
} }
} }
@@ -4626,6 +4626,8 @@ struct CompatibleBigFishWorkSummaryRecord {
level_main_image_ready_count: u32, level_main_image_ready_count: u32,
level_motion_ready_count: u32, level_motion_ready_count: u32,
background_ready: bool, background_ready: bool,
#[serde(default)]
play_count: u32,
} }
impl CompatibleBigFishWorkSummaryRecord { impl CompatibleBigFishWorkSummaryRecord {
@@ -4650,6 +4652,7 @@ impl CompatibleBigFishWorkSummaryRecord {
level_main_image_ready_count: self.level_main_image_ready_count, level_main_image_ready_count: self.level_main_image_ready_count,
level_motion_ready_count: self.level_motion_ready_count, level_motion_ready_count: self.level_motion_ready_count,
background_ready: self.background_ready, background_ready: self.background_ready,
play_count: self.play_count,
} }
} }
} }
@@ -4678,7 +4681,7 @@ mod tests {
"level_motion_ready_count":0, "level_motion_ready_count":0,
"background_ready":false "background_ready":false
}]"# }]"#
.to_string(), .to_string(),
), ),
error_message: None, error_message: None,
}; };
@@ -4710,7 +4713,7 @@ mod tests {
"level_motion_ready_count":16, "level_motion_ready_count":16,
"background_ready":true "background_ready":true
}]"# }]"#
.to_string(), .to_string(),
), ),
error_message: None, error_message: None,
}; };

View File

@@ -16,9 +16,9 @@ pub enum RuntimeProfileWalletLedgerSourceType {
PointsRecharge, PointsRecharge,
AssetGenerationConsume, AssetOperationConsume,
AssetGenerationRefund, AssetOperationRefund,
} }
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {

View File

@@ -248,7 +248,7 @@ pub fn consume_profile_wallet_points_and_return(
apply_profile_wallet_adjustment( apply_profile_wallet_adjustment(
tx, tx,
input.clone(), input.clone(),
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume, RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
true, true,
) )
}) { }) {
@@ -275,7 +275,7 @@ pub fn refund_profile_wallet_points_and_return(
apply_profile_wallet_adjustment( apply_profile_wallet_adjustment(
tx, tx,
input.clone(), input.clone(),
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund, RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
false, false,
) )
}) { }) {

View File

@@ -11,7 +11,29 @@ import {
} from './RpgEntryHomeView'; } from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; 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', () => ({ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
getRpgProfileRechargeCenter: vi.fn(async () => ({ getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0, walletBalance: 0,
membership: { membership: {
@@ -285,6 +307,36 @@ test('opens recharge modal and submits points product', async () => {
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1)); 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 () => { test('shows a reachable login entry in logged out mobile shell', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const openLoginModal = vi.fn(); const openLoginModal = vi.fn();

View File

@@ -34,6 +34,7 @@ import type {
PlatformBrowseHistoryEntry, PlatformBrowseHistoryEntry,
ProfileDashboardCardKey, ProfileDashboardCardKey,
ProfileDashboardSummary, ProfileDashboardSummary,
ProfileWalletLedgerResponse,
ProfileRechargeCenterResponse, ProfileRechargeCenterResponse,
ProfileRechargeProduct, ProfileRechargeProduct,
ProfileReferralInviteCenterResponse, ProfileReferralInviteCenterResponse,
@@ -46,6 +47,7 @@ import {
createRpgProfileRechargeOrder, createRpgProfileRechargeOrder,
getRpgProfileRechargeCenter, getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter, getRpgProfileReferralInviteCenter,
getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode, redeemRpgProfileReferralInviteCode,
} from '../../services/rpg-entry/rpgProfileClient'; } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
@@ -923,6 +925,128 @@ function formatMembershipDuration(days: number) {
return `${days}`; return `${days}`;
} }
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
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 (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[30rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
aria-label="关闭叙世币账单"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
LEDGER
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>{balance}</span>
</div>
</div>
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-3 text-sm text-rose-700">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-full bg-[#ff4056] px-4 py-2 text-xs font-black text-white"
>
</button>
</div>
) : isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="h-16 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
) : entries.length === 0 ? (
<div className="mt-5 rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm font-semibold text-zinc-500">
</div>
) : (
<div className="mt-5 space-y-2.5">
{entries.map((entry) => {
const isIncome = entry.amountDelta > 0;
const label =
WALLET_LEDGER_SOURCE_LABELS[entry.sourceType] ??
entry.sourceType;
return (
<div
key={entry.id}
className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-3 py-3 shadow-sm"
>
<div className="min-w-0">
<div className="truncate text-sm font-black text-zinc-900">
{label}
</div>
<div className="mt-1 text-xs font-semibold text-zinc-500">
{formatPlatformWorldTime(entry.createdAt)}
</div>
</div>
<div className="shrink-0 text-right">
<div
className={`text-base font-black ${
isIncome ? 'text-emerald-600' : 'text-rose-500'
}`}
>
{formatWalletLedgerAmount(entry.amountDelta)}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
{entry.balanceAfter}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function AccountRechargeModal({ function AccountRechargeModal({
center, center,
activeTab, activeTab,
@@ -1304,6 +1428,13 @@ export function RpgEntryHomeView({
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false); const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
const [submittingRechargeProductId, setSubmittingRechargeProductId] = const [submittingRechargeProductId, setSubmittingRechargeProductId] =
useState<string | null>(null); useState<string | null>(null);
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
const [walletLedger, setWalletLedger] =
useState<ProfileWalletLedgerResponse | null>(null);
const [walletLedgerError, setWalletLedgerError] = useState<string | null>(
null,
);
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
const [profilePopupPanel, setProfilePopupPanel] = const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null); useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] = const [referralCenter, setReferralCenter] =
@@ -1415,6 +1546,23 @@ export function RpgEntryHomeView({
}) })
.finally(() => setIsLoadingRecharge(false)); .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) => { const submitRechargeProduct = (product: ProfileRechargeProduct) => {
if (submittingRechargeProductId) { if (submittingRechargeProductId) {
return; return;
@@ -1865,7 +2013,7 @@ export function RpgEntryHomeView({
label="剩余叙世币" label="剩余叙世币"
value="暂不可用" value="暂不可用"
icon={Coins} icon={Coins}
onClick={onOpenProfileDashboardCard} onClick={openWalletLedgerPanel}
/> />
<ProfileStatCard <ProfileStatCard
cardKey="playTime" cardKey="playTime"
@@ -1889,7 +2037,7 @@ export function RpgEntryHomeView({
label="剩余叙世币" label="剩余叙世币"
value={formatDashboardCount(remainingNarrativeCoins)} value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins} icon={Coins}
onClick={onOpenProfileDashboardCard} onClick={openWalletLedgerPanel}
/> />
<ProfileStatCard <ProfileStatCard
cardKey="playTime" cardKey="playTime"
@@ -2318,6 +2466,16 @@ export function RpgEntryHomeView({
onSubmitRedeem={submitReferralInviteCode} onSubmitRedeem={submitReferralInviteCode}
/> />
) : null} ) : null}
{isWalletLedgerOpen ? (
<WalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
</div> </div>
); );
} }
@@ -2422,6 +2580,16 @@ export function RpgEntryHomeView({
onSubmitRedeem={submitReferralInviteCode} onSubmitRedeem={submitReferralInviteCode}
/> />
) : null} ) : null}
{isWalletLedgerOpen ? (
<WalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
</div> </div>
); );
} }