feat: add asset operation wallet ledger
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -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 负责钱包余额和流水的原子变更。
|
- 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 不再作为公开契约兼容。
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
|||||||
|
|
||||||
PointsRecharge,
|
PointsRecharge,
|
||||||
|
|
||||||
AssetGenerationConsume,
|
AssetOperationConsume,
|
||||||
|
|
||||||
AssetGenerationRefund,
|
AssetOperationRefund,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user