Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
# 资产生成叙世币消耗接入方案
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成由 Axum API 调用外部模型并写入 OSS,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此扣费需要拆成两层:
|
||||||
|
|
||||||
|
- SpacetimeDB 负责钱包余额和流水的原子变更。
|
||||||
|
- Axum 负责在发起外部生成前扣费,并在生成或持久化失败时补偿退款。
|
||||||
|
|
||||||
|
## 首期范围
|
||||||
|
|
||||||
|
首期接入带 Bearer 身份、能明确归属真实用户的资产生成与发布入口:
|
||||||
|
|
||||||
|
- `POST /api/custom-world/scene-image`
|
||||||
|
- `POST /api/custom-world/cover-image`
|
||||||
|
- `POST /api/runtime/custom-world/cover-image`
|
||||||
|
- `POST /api/runtime/custom-world-library/{profile_id}/publish`
|
||||||
|
- 自定义世界 Agent 动作 `publish_world`
|
||||||
|
- Big Fish Agent 正式图片生成动作 `big_fish_generate_level_main_image`、`big_fish_generate_level_motion`、`big_fish_generate_stage_background`
|
||||||
|
- Big Fish Agent 动作 `big_fish_publish_game`
|
||||||
|
- Puzzle Agent 图片生成动作 `compile_puzzle_draft`、`generate_puzzle_images`
|
||||||
|
- Puzzle Agent 动作 `publish_puzzle_work`
|
||||||
|
|
||||||
|
暂不接入以下入口:
|
||||||
|
|
||||||
|
- 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。
|
||||||
|
- 手动上传封面:不调用外部生成模型,不消耗叙世币。
|
||||||
|
- 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。
|
||||||
|
- 文本实体、NPC 生成:本次需求聚焦资产生成,首期只覆盖图片资产。
|
||||||
|
|
||||||
|
## 计费规则
|
||||||
|
|
||||||
|
- 每次图片资产生成请求消耗 `1` 枚叙世币。
|
||||||
|
- 每次作品发布请求消耗 `1` 枚叙世币;余额不足时禁止发布。
|
||||||
|
- 在调用外部图片生成前预扣,余额不足时直接返回业务错误,不调用外部模型。
|
||||||
|
- 发布请求在写入发布状态前预扣,余额不足时直接返回业务错误,不调用发布 mutation。
|
||||||
|
- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,Axum 自动发起同额退款。
|
||||||
|
- 如果退款失败,原始错误仍返回给调用方,同时服务端日志记录退款失败,便于后续人工核对。
|
||||||
|
|
||||||
|
## 钱包流水
|
||||||
|
|
||||||
|
新增两个流水来源类型,首期同时覆盖“资产生成”和“资产发布”这两类资产操作:
|
||||||
|
|
||||||
|
- `asset_generation_consume`:资产生成预扣,`amount_delta = -1`。
|
||||||
|
- `asset_generation_refund`:资产生成失败退款,`amount_delta = +1`。
|
||||||
|
|
||||||
|
`wallet_ledger_id` 由 Axum 传入,格式:
|
||||||
|
|
||||||
|
- 扣费:`asset_generation_consume:{user_id}:{asset_kind}:{asset_id}`
|
||||||
|
- 退款:`asset_generation_refund:{user_id}:{asset_kind}:{asset_id}`
|
||||||
|
|
||||||
|
SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID 已存在,则直接返回当前钱包快照,不重复变更余额。
|
||||||
|
|
||||||
|
## 工程落点
|
||||||
|
|
||||||
|
- `module-runtime`:新增钱包调整输入、钱包调整结果、流水来源枚举。
|
||||||
|
- `spacetime-module`:新增 `consume_profile_wallet_points_and_return` 与 `refund_profile_wallet_points_and_return` procedure,并扩展钱包变更 helper 支持负数。
|
||||||
|
- `spacetime-client`:新增对应调用方法和绑定类型。
|
||||||
|
- `api-server`:在自定义世界图片生成与发布入口前扣费,错误分支退款。
|
||||||
|
- `shared-contracts`:新增 API 流水来源常量,保证“我的-钱包流水”输出使用稳定契约字符串。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
本次不做分档价格、不做会员免扣、不做前端计费展示改造,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
- 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型:
|
- 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型:
|
||||||
- `invite_inviter_reward`
|
- `invite_inviter_reward`
|
||||||
- `invite_invitee_reward`
|
- `invite_invitee_reward`
|
||||||
|
- API 返回钱包流水时,`sourceType` 必须复用 `server-rs/crates/shared-contracts/src/runtime.rs` 中的常量,避免 SpacetimeDB 枚举映射和前端合同字符串漂移。
|
||||||
|
|
||||||
## SpacetimeDB 表设计
|
## SpacetimeDB 表设计
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,17 @@
|
|||||||
6. 作品详情返回必须恢复打开详情前的平台来源 Tab;从分类进入回分类,从首页进入回首页,从创作中心进入回创作中心。
|
6. 作品详情返回必须恢复打开详情前的平台来源 Tab;从分类进入回分类,从首页进入回首页,从创作中心进入回创作中心。
|
||||||
7. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。
|
7. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。
|
||||||
|
|
||||||
|
## 作品分享路由补充
|
||||||
|
|
||||||
|
1. 公开作品入口路由统一使用当前作品页面路径加 `work=作品号`:RPG 为 `/worlds/detail?work=CW-00000001`,拼图为 `/gallery/puzzle/detail?work=PZ-00000001`,大鱼玩法为 `/runtime/big-fish?work=BF-00000001`。
|
||||||
|
2. 从公开广场、最近浏览、创作中心打开已发布作品详情或玩法时,若当前作品有公开作品号,地址栏必须同步追加 `work=作品号`;没有作品号的草稿详情仍保持无查询参数路径。
|
||||||
|
3. 首次进入主应用时若 URL 带 `work` 查询参数,平台入口自动复用现有公开编号搜索逻辑打开对应作品详情,不新增独立详情系统。
|
||||||
|
4. 详情页必须保留“复制作品号”和“分享作品”两个独立动作:
|
||||||
|
- 复制作品号只复制 `CW / PZ / BF` 编号。
|
||||||
|
- 分享作品复制一段邀请好友来玩的中文文本,文本内必须包含作品名、作品号和带 `work` 查询参数的完整网址。
|
||||||
|
5. 分享复制使用现有剪切板兼容工具,Clipboard API 权限失败时走降级复制,并在按钮内短暂反馈 `已复制` 或 `复制失败`。
|
||||||
|
6. UI 中只保留按钮级短文案,不写规则说明,不在详情页新增大段分享说明。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
1. 399px 竖屏首页能直接看到并使用搜索入口。
|
1. 399px 竖屏首页能直接看到并使用搜索入口。
|
||||||
@@ -23,3 +34,5 @@
|
|||||||
5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。
|
5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。
|
||||||
6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。
|
6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。
|
||||||
7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。
|
7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。
|
||||||
|
8. 打开 `/?work=CW-00000001`、`/worlds/detail?work=CW-00000001`、`/gallery/puzzle/detail?work=PZ-00000001` 或 `/runtime/big-fish?work=BF-00000001` 后能自动进入对应公开作品详情或玩法。
|
||||||
|
9. 点击详情页“分享作品”后,剪切板内容包含邀请文本、作品号和当前站点下带 `work=作品号` 的完整网址。
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export type ProfileWalletLedgerEntry = {
|
|||||||
| 'snapshot_sync'
|
| 'snapshot_sync'
|
||||||
| 'invite_inviter_reward'
|
| 'invite_inviter_reward'
|
||||||
| 'invite_invitee_reward'
|
| 'invite_invitee_reward'
|
||||||
| 'points_recharge';
|
| 'points_recharge'
|
||||||
|
| 'asset_generation_consume'
|
||||||
|
| 'asset_generation_refund';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
81
server-rs/crates/api-server/src/asset_billing.rs
Normal file
81
server-rs/crates/api-server/src/asset_billing.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde_json::json;
|
||||||
|
use spacetime_client::SpacetimeClientError;
|
||||||
|
|
||||||
|
use crate::{http_error::AppError, state::AppState};
|
||||||
|
|
||||||
|
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
|
||||||
|
|
||||||
|
/// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
||||||
|
pub(crate) async fn consume_asset_operation_points(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
asset_kind: &str,
|
||||||
|
asset_id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let ledger_id = format!(
|
||||||
|
"asset_generation_consume:{}:{}:{}",
|
||||||
|
owner_user_id, asset_kind, asset_id
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.consume_profile_wallet_points(
|
||||||
|
owner_user_id.to_string(),
|
||||||
|
ASSET_OPERATION_POINTS_COST,
|
||||||
|
ledger_id,
|
||||||
|
current_utc_micros(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(map_asset_operation_wallet_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。
|
||||||
|
pub(crate) async fn refund_asset_operation_points(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
asset_kind: &str,
|
||||||
|
asset_id: &str,
|
||||||
|
) {
|
||||||
|
let ledger_id = format!(
|
||||||
|
"asset_generation_refund:{}:{}:{}",
|
||||||
|
owner_user_id, asset_kind, asset_id
|
||||||
|
);
|
||||||
|
if let Err(error) = state
|
||||||
|
.spacetime_client()
|
||||||
|
.refund_profile_wallet_points(
|
||||||
|
owner_user_id.to_string(),
|
||||||
|
ASSET_OPERATION_POINTS_COST,
|
||||||
|
ledger_id,
|
||||||
|
current_utc_micros(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
owner_user_id,
|
||||||
|
asset_kind,
|
||||||
|
asset_id,
|
||||||
|
error = %error,
|
||||||
|
"资产操作失败后的叙世币退款失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError {
|
||||||
|
let status = match &error {
|
||||||
|
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||||
|
SpacetimeClientError::Procedure(message) if message.contains("叙世币余额不足") => {
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
}
|
||||||
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
|
};
|
||||||
|
|
||||||
|
AppError::from_status(status).with_details(json!({
|
||||||
|
"provider": "profile-wallet",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_utc_micros() -> i64 {
|
||||||
|
time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000
|
||||||
|
}
|
||||||
@@ -46,6 +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},
|
||||||
auth::AuthenticatedAccessToken,
|
auth::AuthenticatedAccessToken,
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
request_context::RequestContext,
|
request_context::RequestContext,
|
||||||
@@ -471,9 +472,30 @@ pub async fn execute_big_fish_action(
|
|||||||
|
|
||||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
let now = current_utc_micros();
|
let now = current_utc_micros();
|
||||||
let session = match payload.action.trim() {
|
let action = payload.action.trim().to_string();
|
||||||
|
let billed_asset_kind = match action.as_str() {
|
||||||
|
"big_fish_generate_level_main_image" => Some("big_fish_level_main_image"),
|
||||||
|
"big_fish_generate_level_motion" => Some("big_fish_level_motion"),
|
||||||
|
"big_fish_generate_stage_background" => Some("big_fish_stage_background"),
|
||||||
|
"big_fish_publish_game" => Some("big_fish_publish_game"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let billing_asset_id = format!("{session_id}:{now}");
|
||||||
|
if let Some(asset_kind) = billed_asset_kind {
|
||||||
|
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| big_fish_error_response(&request_context, error))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_result = match action.as_str() {
|
||||||
"big_fish_compile_draft" => {
|
"big_fish_compile_draft" => {
|
||||||
compile_big_fish_draft_with_all_assets(&state, session_id, owner_user_id, now).await
|
compile_big_fish_draft_with_all_assets(
|
||||||
|
&state,
|
||||||
|
session_id.clone(),
|
||||||
|
owner_user_id.clone(),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
"big_fish_generate_level_main_image" => {
|
"big_fish_generate_level_main_image" => {
|
||||||
let asset_url = generate_big_fish_formal_asset(
|
let asset_url = generate_big_fish_formal_asset(
|
||||||
@@ -486,12 +508,30 @@ pub async fn execute_big_fish_action(
|
|||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| big_fish_error_response(&request_context, error))?;
|
.map_err(|error| {
|
||||||
|
if let Some(asset_kind) = billed_asset_kind {
|
||||||
|
tokio::spawn({
|
||||||
|
let state = state.clone();
|
||||||
|
let owner_user_id = owner_user_id.clone();
|
||||||
|
let billing_asset_id = billing_asset_id.clone();
|
||||||
|
async move {
|
||||||
|
refund_asset_operation_points(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
asset_kind,
|
||||||
|
&billing_asset_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
big_fish_error_response(&request_context, error)
|
||||||
|
})?;
|
||||||
state
|
state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||||
session_id,
|
owner_user_id: owner_user_id.clone(),
|
||||||
owner_user_id,
|
session_id: session_id.clone(),
|
||||||
asset_kind: "level_main_image".to_string(),
|
asset_kind: "level_main_image".to_string(),
|
||||||
level: payload.level,
|
level: payload.level,
|
||||||
motion_key: None,
|
motion_key: None,
|
||||||
@@ -511,12 +551,30 @@ pub async fn execute_big_fish_action(
|
|||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| big_fish_error_response(&request_context, error))?;
|
.map_err(|error| {
|
||||||
|
if let Some(asset_kind) = billed_asset_kind {
|
||||||
|
tokio::spawn({
|
||||||
|
let state = state.clone();
|
||||||
|
let owner_user_id = owner_user_id.clone();
|
||||||
|
let billing_asset_id = billing_asset_id.clone();
|
||||||
|
async move {
|
||||||
|
refund_asset_operation_points(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
asset_kind,
|
||||||
|
&billing_asset_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
big_fish_error_response(&request_context, error)
|
||||||
|
})?;
|
||||||
state
|
state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||||
session_id,
|
owner_user_id: owner_user_id.clone(),
|
||||||
owner_user_id,
|
session_id: session_id.clone(),
|
||||||
asset_kind: "level_motion".to_string(),
|
asset_kind: "level_motion".to_string(),
|
||||||
level: payload.level,
|
level: payload.level,
|
||||||
motion_key: payload.motion_key,
|
motion_key: payload.motion_key,
|
||||||
@@ -536,12 +594,30 @@ pub async fn execute_big_fish_action(
|
|||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| big_fish_error_response(&request_context, error))?;
|
.map_err(|error| {
|
||||||
|
if let Some(asset_kind) = billed_asset_kind {
|
||||||
|
tokio::spawn({
|
||||||
|
let state = state.clone();
|
||||||
|
let owner_user_id = owner_user_id.clone();
|
||||||
|
let billing_asset_id = billing_asset_id.clone();
|
||||||
|
async move {
|
||||||
|
refund_asset_operation_points(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
asset_kind,
|
||||||
|
&billing_asset_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
big_fish_error_response(&request_context, error)
|
||||||
|
})?;
|
||||||
state
|
state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||||
session_id,
|
owner_user_id: owner_user_id.clone(),
|
||||||
owner_user_id,
|
session_id: session_id.clone(),
|
||||||
asset_kind: "stage_background".to_string(),
|
asset_kind: "stage_background".to_string(),
|
||||||
level: None,
|
level: None,
|
||||||
motion_key: None,
|
motion_key: None,
|
||||||
@@ -553,7 +629,7 @@ pub async fn execute_big_fish_action(
|
|||||||
"big_fish_publish_game" => {
|
"big_fish_publish_game" => {
|
||||||
state
|
state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.publish_big_fish_game(session_id, owner_user_id, now)
|
.publish_big_fish_game(session_id, owner_user_id.clone(), now)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
@@ -562,8 +638,25 @@ pub async fn execute_big_fish_action(
|
|||||||
format!("action `{other}` is not supported").as_str(),
|
format!("action `{other}` is not supported").as_str(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
|
let session = match session_result {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(error) => {
|
||||||
|
if let Some(asset_kind) = billed_asset_kind {
|
||||||
|
refund_asset_operation_points(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
asset_kind,
|
||||||
|
&billing_asset_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
return Err(big_fish_error_response(
|
||||||
|
&request_context,
|
||||||
|
map_big_fish_client_error(error),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
|
|||||||
@@ -51,6 +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},
|
||||||
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,
|
||||||
@@ -350,20 +351,37 @@ pub async fn publish_custom_world_library_profile(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mutation = state
|
consume_asset_operation_points(&state, &owner_user_id, "custom_world_publish", &profile_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||||
|
|
||||||
|
let mutation_result = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.publish_custom_world_profile(
|
.publish_custom_world_profile(
|
||||||
profile_id,
|
profile_id.clone(),
|
||||||
owner_user_id,
|
owner_user_id.clone(),
|
||||||
None,
|
None,
|
||||||
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
|
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
|
||||||
resolve_author_display_name(&state, &authenticated),
|
resolve_author_display_name(&state, &authenticated),
|
||||||
current_utc_micros(),
|
current_utc_micros(),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.map_err(|error| {
|
let mutation = match mutation_result {
|
||||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
Ok(mutation) => mutation,
|
||||||
})?;
|
Err(error) => {
|
||||||
|
refund_asset_operation_points(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
"custom_world_publish",
|
||||||
|
&profile_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(custom_world_error_response(
|
||||||
|
&request_context,
|
||||||
|
map_custom_world_client_error(error),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
@@ -1227,7 +1245,19 @@ pub async fn execute_custom_world_agent_action(
|
|||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = state
|
let should_bill_publish = action == "publish_world";
|
||||||
|
if should_bill_publish {
|
||||||
|
consume_asset_operation_points(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
"custom_world_agent_publish",
|
||||||
|
&session_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = match state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
|
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
@@ -1238,9 +1268,24 @@ pub async fn execute_custom_world_agent_action(
|
|||||||
submitted_at_micros,
|
submitted_at_micros,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
{
|
||||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
Ok(result) => result,
|
||||||
})?;
|
Err(error) => {
|
||||||
|
if should_bill_publish {
|
||||||
|
refund_asset_operation_points(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
"custom_world_agent_publish",
|
||||||
|
&session_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
return Err(custom_world_error_response(
|
||||||
|
&request_context,
|
||||||
|
map_custom_world_client_error(error),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if matches!(
|
if matches!(
|
||||||
action.as_str(),
|
action.as_str(),
|
||||||
|
|||||||
@@ -440,108 +440,127 @@ pub async fn generate_custom_world_scene_image(
|
|||||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
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 settings = require_dashscope_settings(&state)
|
let asset_id = format!("custom-scene-{}", current_utc_millis());
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
crate::asset_billing::consume_asset_operation_points(
|
||||||
let http_client = build_dashscope_http_client(&settings)
|
&state,
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
&owner_user_id,
|
||||||
let reference_image =
|
"scene_image",
|
||||||
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
asset_id.as_str(),
|
||||||
Some(
|
)
|
||||||
resolve_reference_image_as_data_url(
|
.await
|
||||||
&state,
|
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||||
&http_client,
|
let asset_result = async {
|
||||||
reference_image_src,
|
let settings = require_dashscope_settings(&state)?;
|
||||||
"referenceImageSrc",
|
let http_client = build_dashscope_http_client(&settings)?;
|
||||||
|
let reference_image =
|
||||||
|
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
||||||
|
Some(
|
||||||
|
resolve_reference_image_as_data_url(
|
||||||
|
&state,
|
||||||
|
&http_client,
|
||||||
|
reference_image_src,
|
||||||
|
"referenceImageSrc",
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
)
|
)
|
||||||
.await
|
} else {
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?,
|
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 {
|
} else {
|
||||||
None
|
create_text_to_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
state.config.dashscope_scene_image_model.as_str(),
|
||||||
|
normalized.prompt.as_str(),
|
||||||
|
Some(normalized.negative_prompt.as_str()),
|
||||||
|
normalized.size.as_str(),
|
||||||
|
"创建场景图片生成任务失败",
|
||||||
|
"查询场景图片任务失败",
|
||||||
|
"场景图片生成任务失败",
|
||||||
|
"场景图片生成超时或未返回图片地址",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}?;
|
||||||
|
let scene_model = if reference_image.is_some() {
|
||||||
|
state.config.dashscope_reference_image_model.clone()
|
||||||
|
} else {
|
||||||
|
state.config.dashscope_scene_image_model.clone()
|
||||||
};
|
};
|
||||||
let generated = if let Some(reference_image) = reference_image.as_deref() {
|
let downloaded = download_remote_image(
|
||||||
create_reference_image_generation(
|
|
||||||
&http_client,
|
&http_client,
|
||||||
&settings,
|
generated.image_url.as_str(),
|
||||||
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
|
.await?;
|
||||||
} else {
|
let upload = PreparedAssetUpload {
|
||||||
create_text_to_image_generation(
|
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||||
&http_client,
|
path_segments: vec![
|
||||||
&settings,
|
sanitize_storage_segment(
|
||||||
state.config.dashscope_scene_image_model.as_str(),
|
normalized
|
||||||
normalized.prompt.as_str(),
|
.profile_id
|
||||||
Some(normalized.negative_prompt.as_str()),
|
.as_deref()
|
||||||
normalized.size.as_str(),
|
.unwrap_or(normalized.world_name.as_str()),
|
||||||
"创建场景图片生成任务失败",
|
"world",
|
||||||
"查询场景图片任务失败",
|
),
|
||||||
"场景图片生成任务失败",
|
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
|
||||||
"场景图片生成超时或未返回图片地址",
|
asset_id.clone(),
|
||||||
|
],
|
||||||
|
file_name: format!("scene.{}", downloaded.extension),
|
||||||
|
content_type: downloaded.mime_type,
|
||||||
|
body: downloaded.bytes,
|
||||||
|
asset_kind: "scene_image",
|
||||||
|
entity_kind: "custom_world_landmark",
|
||||||
|
entity_id: normalized.entity_id.clone(),
|
||||||
|
profile_id: normalized.profile_id.clone(),
|
||||||
|
slot: "scene_image",
|
||||||
|
source_job_id: Some(generated.task_id.clone()),
|
||||||
|
};
|
||||||
|
persist_custom_world_asset(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
upload,
|
||||||
|
GeneratedAssetResponse {
|
||||||
|
image_src: String::new(),
|
||||||
|
asset_id: asset_id.clone(),
|
||||||
|
source_type: "generated".to_string(),
|
||||||
|
model: Some(scene_model),
|
||||||
|
size: Some(normalized.size),
|
||||||
|
task_id: Some(generated.task_id),
|
||||||
|
prompt: Some(normalized.prompt),
|
||||||
|
actual_prompt: generated.actual_prompt,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
.await;
|
||||||
let scene_model = if reference_image.is_some() {
|
|
||||||
state.config.dashscope_reference_image_model.clone()
|
let asset = match asset_result {
|
||||||
} else {
|
Ok(asset) => asset,
|
||||||
state.config.dashscope_scene_image_model.clone()
|
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));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let downloaded = download_remote_image(
|
|
||||||
&http_client,
|
|
||||||
generated.image_url.as_str(),
|
|
||||||
"下载生成图片失败",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
|
||||||
let asset_id = format!("custom-scene-{}", current_utc_millis());
|
|
||||||
let upload = PreparedAssetUpload {
|
|
||||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
|
||||||
path_segments: vec![
|
|
||||||
sanitize_storage_segment(
|
|
||||||
normalized
|
|
||||||
.profile_id
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(normalized.world_name.as_str()),
|
|
||||||
"world",
|
|
||||||
),
|
|
||||||
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
|
|
||||||
asset_id.clone(),
|
|
||||||
],
|
|
||||||
file_name: format!("scene.{}", downloaded.extension),
|
|
||||||
content_type: downloaded.mime_type,
|
|
||||||
body: downloaded.bytes,
|
|
||||||
asset_kind: "scene_image",
|
|
||||||
entity_kind: "custom_world_landmark",
|
|
||||||
entity_id: normalized.entity_id.clone(),
|
|
||||||
profile_id: normalized.profile_id.clone(),
|
|
||||||
slot: "scene_image",
|
|
||||||
source_job_id: Some(generated.task_id.clone()),
|
|
||||||
};
|
|
||||||
let asset = persist_custom_world_asset(
|
|
||||||
&state,
|
|
||||||
&owner_user_id,
|
|
||||||
upload,
|
|
||||||
GeneratedAssetResponse {
|
|
||||||
image_src: String::new(),
|
|
||||||
asset_id: asset_id.clone(),
|
|
||||||
source_type: "generated".to_string(),
|
|
||||||
model: Some(scene_model),
|
|
||||||
size: Some(normalized.size),
|
|
||||||
task_id: Some(generated.task_id),
|
|
||||||
prompt: Some(normalized.prompt),
|
|
||||||
actual_prompt: generated.actual_prompt,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
|
||||||
|
|
||||||
Ok(json_success_body(Some(&request_context), asset))
|
Ok(json_success_body(Some(&request_context), asset))
|
||||||
}
|
}
|
||||||
@@ -697,109 +716,128 @@ pub async fn generate_custom_world_cover_image(
|
|||||||
trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string());
|
trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string());
|
||||||
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
|
let 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 settings = require_dashscope_settings(&state)
|
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
|
||||||
let http_client = build_dashscope_http_client(&settings)
|
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
|
||||||
let reference_sources = collect_cover_reference_image_sources(
|
|
||||||
&payload.profile,
|
|
||||||
&payload.character_role_ids,
|
|
||||||
payload.reference_image_src.as_deref().unwrap_or_default(),
|
|
||||||
);
|
|
||||||
let prompt = build_custom_world_cover_image_prompt(
|
|
||||||
&payload.profile,
|
|
||||||
&payload.character_role_ids,
|
|
||||||
payload.user_prompt.as_deref().unwrap_or_default(),
|
|
||||||
!reference_sources.is_empty(),
|
|
||||||
);
|
|
||||||
let mut reference_images = Vec::with_capacity(reference_sources.len());
|
|
||||||
for source in &reference_sources {
|
|
||||||
reference_images.push(
|
|
||||||
resolve_reference_image_as_data_url(
|
|
||||||
&state,
|
|
||||||
&http_client,
|
|
||||||
source.as_str(),
|
|
||||||
"referenceImageSrc",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let generated = if reference_images.is_empty() {
|
|
||||||
create_text_to_image_generation(
|
|
||||||
&http_client,
|
|
||||||
&settings,
|
|
||||||
state.config.dashscope_cover_image_model.as_str(),
|
|
||||||
prompt.as_str(),
|
|
||||||
None,
|
|
||||||
size.as_str(),
|
|
||||||
"创建作品封面生成任务失败",
|
|
||||||
"查询作品封面任务失败",
|
|
||||||
"作品封面生成任务失败",
|
|
||||||
"作品封面生成超时或未返回图片地址",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
create_reference_image_generation(
|
|
||||||
&http_client,
|
|
||||||
&settings,
|
|
||||||
state.config.dashscope_reference_image_model.as_str(),
|
|
||||||
prompt.as_str(),
|
|
||||||
size.as_str(),
|
|
||||||
&reference_images,
|
|
||||||
None,
|
|
||||||
"创建参考图封面任务失败",
|
|
||||||
"封面生成未返回图片地址",
|
|
||||||
"cover-edit",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
|
||||||
let downloaded = download_remote_image(
|
|
||||||
&http_client,
|
|
||||||
generated.image_url.as_str(),
|
|
||||||
"下载作品封面失败",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
|
||||||
let asset_id = format!("custom-cover-{}", current_utc_millis());
|
let asset_id = format!("custom-cover-{}", current_utc_millis());
|
||||||
let upload = PreparedAssetUpload {
|
crate::asset_billing::consume_asset_operation_points(
|
||||||
prefix: LegacyAssetPrefix::CustomWorldCovers,
|
|
||||||
path_segments: vec![
|
|
||||||
sanitize_storage_segment(entity_id.as_str(), "world"),
|
|
||||||
asset_id.clone(),
|
|
||||||
],
|
|
||||||
file_name: format!("cover.{}", downloaded.extension),
|
|
||||||
content_type: downloaded.mime_type,
|
|
||||||
body: downloaded.bytes,
|
|
||||||
asset_kind: "custom_world_cover",
|
|
||||||
entity_kind: "custom_world_profile",
|
|
||||||
entity_id,
|
|
||||||
profile_id,
|
|
||||||
slot: "cover",
|
|
||||||
source_job_id: Some(generated.task_id.clone()),
|
|
||||||
};
|
|
||||||
let asset = persist_custom_world_asset(
|
|
||||||
&state,
|
&state,
|
||||||
&owner_user_id,
|
&owner_user_id,
|
||||||
upload,
|
"custom_world_cover",
|
||||||
GeneratedAssetResponse {
|
asset_id.as_str(),
|
||||||
image_src: String::new(),
|
|
||||||
asset_id: asset_id.clone(),
|
|
||||||
source_type: "generated".to_string(),
|
|
||||||
model: Some(if reference_images.is_empty() {
|
|
||||||
state.config.dashscope_cover_image_model.clone()
|
|
||||||
} else {
|
|
||||||
state.config.dashscope_reference_image_model.clone()
|
|
||||||
}),
|
|
||||||
size: Some(size),
|
|
||||||
task_id: Some(generated.task_id),
|
|
||||||
prompt: Some(prompt),
|
|
||||||
actual_prompt: generated.actual_prompt,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
.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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod ai_generation_drafts;
|
|||||||
mod ai_tasks;
|
mod ai_tasks;
|
||||||
mod api_response;
|
mod api_response;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod asset_billing;
|
||||||
mod assets;
|
mod assets;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod auth_me;
|
mod auth_me;
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ use shared_contracts::{
|
|||||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||||
puzzle_runtime::{
|
puzzle_runtime::{
|
||||||
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||||||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse,
|
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||||
PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse,
|
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
|
||||||
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
|
||||||
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
|
SwapPuzzlePiecesRequest,
|
||||||
},
|
},
|
||||||
puzzle_works::{
|
puzzle_works::{
|
||||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||||
@@ -55,12 +55,11 @@ use spacetime_client::{
|
|||||||
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
||||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord,
|
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
|
||||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
|
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||||
SpacetimeClientError,
|
|
||||||
};
|
};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
@@ -68,6 +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},
|
||||||
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},
|
||||||
@@ -441,8 +441,22 @@ 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 billed_asset_kind = match action.as_str() {
|
||||||
|
"compile_puzzle_draft" => Some("puzzle_initial_image"),
|
||||||
|
"generate_puzzle_images" => Some("puzzle_generated_image"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let billing_asset_id = format!("{session_id}:{now}");
|
||||||
|
if let Some(asset_kind) = billed_asset_kind {
|
||||||
|
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
let (operation_type, phase_label, phase_detail, session) = match payload.action.trim() {
|
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||||
"compile_puzzle_draft" => {
|
"compile_puzzle_draft" => {
|
||||||
let session = compile_puzzle_draft_with_initial_cover(
|
let session = compile_puzzle_draft_with_initial_cover(
|
||||||
&state,
|
&state,
|
||||||
@@ -510,7 +524,7 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
.save_puzzle_generated_images(
|
.save_puzzle_generated_images(
|
||||||
PuzzleGeneratedImagesSaveRecordInput {
|
PuzzleGeneratedImagesSaveRecordInput {
|
||||||
session_id: session.session_id,
|
session_id: session.session_id,
|
||||||
owner_user_id,
|
owner_user_id: owner_user_id.clone(),
|
||||||
candidates_json,
|
candidates_json,
|
||||||
saved_at_micros: now,
|
saved_at_micros: now,
|
||||||
},
|
},
|
||||||
@@ -565,13 +579,18 @@ 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);
|
||||||
let profile = state
|
consume_asset_operation_points(&state, &owner_user_id, "puzzle_publish_work", &work_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||||
|
})?;
|
||||||
|
let profile_result = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.publish_puzzle_work(PuzzlePublishRecordInput {
|
.publish_puzzle_work(PuzzlePublishRecordInput {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
owner_user_id: owner_user_id.clone(),
|
owner_user_id: owner_user_id.clone(),
|
||||||
// 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。
|
// 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。
|
||||||
work_id,
|
work_id: work_id.clone(),
|
||||||
profile_id,
|
profile_id,
|
||||||
author_display_name: resolve_author_display_name(&state, &authenticated),
|
author_display_name: resolve_author_display_name(&state, &authenticated),
|
||||||
level_name: payload.level_name.clone(),
|
level_name: payload.level_name.clone(),
|
||||||
@@ -579,14 +598,24 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
theme_tags: payload.theme_tags.clone(),
|
theme_tags: payload.theme_tags.clone(),
|
||||||
published_at_micros: now,
|
published_at_micros: now,
|
||||||
})
|
})
|
||||||
.await
|
.await;
|
||||||
.map_err(|error| {
|
let profile = match profile_result {
|
||||||
puzzle_error_response(
|
Ok(profile) => profile,
|
||||||
|
Err(error) => {
|
||||||
|
refund_asset_operation_points(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
"puzzle_publish_work",
|
||||||
|
&work_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(puzzle_error_response(
|
||||||
&request_context,
|
&request_context,
|
||||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
map_puzzle_client_error(error),
|
map_puzzle_client_error(error),
|
||||||
)
|
));
|
||||||
})?;
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let session = state
|
let session = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
@@ -626,6 +655,22 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let session = session.map_err(|error| {
|
let session = session.map_err(|error| {
|
||||||
|
if let Some(asset_kind) = billed_asset_kind {
|
||||||
|
tokio::spawn({
|
||||||
|
let state = state.clone();
|
||||||
|
let owner_user_id = owner_user_id.clone();
|
||||||
|
let billing_asset_id = billing_asset_id.clone();
|
||||||
|
async move {
|
||||||
|
refund_asset_operation_points(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
asset_kind,
|
||||||
|
&billing_asset_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
puzzle_error_response(
|
puzzle_error_response(
|
||||||
&request_context,
|
&request_context,
|
||||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
|||||||
@@ -7,18 +7,23 @@ use axum::{
|
|||||||
use module_runtime::{
|
use module_runtime::{
|
||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
|
||||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||||
RuntimeProfileRechargeProductRecord, RuntimeReferralInviteCenterRecord,
|
RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType,
|
||||||
RuntimeReferralRedeemRecord,
|
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use shared_contracts::runtime::{
|
use shared_contracts::runtime::{
|
||||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
||||||
ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME,
|
||||||
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND,
|
||||||
ProfileRechargeOrderResponse, ProfileRechargeProductResponse,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
||||||
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
|
||||||
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
||||||
RedeemProfileReferralInviteCodeResponse,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
||||||
|
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
|
||||||
|
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
|
||||||
|
ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse,
|
||||||
|
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
|
||||||
|
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
|
||||||
};
|
};
|
||||||
use spacetime_client::SpacetimeClientError;
|
use spacetime_client::SpacetimeClientError;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
@@ -82,7 +87,8 @@ pub async fn get_profile_wallet_ledger(
|
|||||||
id: entry.wallet_ledger_id,
|
id: entry.wallet_ledger_id,
|
||||||
amount_delta: entry.amount_delta,
|
amount_delta: entry.amount_delta,
|
||||||
balance_after: entry.balance_after,
|
balance_after: entry.balance_after,
|
||||||
source_type: entry.source_type.as_str().to_string(),
|
source_type: format_profile_wallet_ledger_source_type(entry.source_type)
|
||||||
|
.to_string(),
|
||||||
created_at: entry.created_at,
|
created_at: entry.created_at,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -90,6 +96,31 @@ pub async fn get_profile_wallet_ledger(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_profile_wallet_ledger_source_type(
|
||||||
|
source_type: RuntimeProfileWalletLedgerSourceType,
|
||||||
|
) -> &'static str {
|
||||||
|
match source_type {
|
||||||
|
RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
|
||||||
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC
|
||||||
|
}
|
||||||
|
RuntimeProfileWalletLedgerSourceType::InviteInviterReward => {
|
||||||
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
|
||||||
|
}
|
||||||
|
RuntimeProfileWalletLedgerSourceType::InviteInviteeReward => {
|
||||||
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD
|
||||||
|
}
|
||||||
|
RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
|
||||||
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE
|
||||||
|
}
|
||||||
|
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => {
|
||||||
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
|
||||||
|
}
|
||||||
|
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
||||||
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_profile_recharge_center(
|
pub async fn get_profile_recharge_center(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
@@ -367,6 +398,10 @@ fn build_redeem_profile_referral_invite_code_response(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
||||||
|
|
||||||
|
use super::format_profile_wallet_ledger_source_type;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{Request, StatusCode},
|
http::{Request, StatusCode},
|
||||||
@@ -381,6 +416,22 @@ mod tests {
|
|||||||
|
|
||||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_wallet_ledger_source_type_formats_asset_generation_values() {
|
||||||
|
assert_eq!(
|
||||||
|
format_profile_wallet_ledger_source_type(
|
||||||
|
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume
|
||||||
|
),
|
||||||
|
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_profile_wallet_ledger_source_type(
|
||||||
|
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
|
||||||
|
),
|
||||||
|
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn profile_dashboard_requires_authentication() {
|
async fn profile_dashboard_requires_authentication() {
|
||||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
|||||||
InviteInviterReward,
|
InviteInviterReward,
|
||||||
InviteInviteeReward,
|
InviteInviteeReward,
|
||||||
PointsRecharge,
|
PointsRecharge,
|
||||||
|
AssetGenerationConsume,
|
||||||
|
AssetGenerationRefund,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
@@ -399,12 +401,29 @@ pub struct RuntimeProfileWalletLedgerProcedureResult {
|
|||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeProfileWalletAdjustmentProcedureResult {
|
||||||
|
pub ok: bool,
|
||||||
|
pub record: Option<RuntimeProfileDashboardSnapshot>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeProfileWalletLedgerListInput {
|
pub struct RuntimeProfileWalletLedgerListInput {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeProfileWalletAdjustmentInput {
|
||||||
|
pub user_id: String,
|
||||||
|
pub amount: u64,
|
||||||
|
pub ledger_id: String,
|
||||||
|
pub created_at_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeReferralInviteCenterSnapshot {
|
pub struct RuntimeReferralInviteCenterSnapshot {
|
||||||
@@ -515,6 +534,8 @@ pub enum RuntimeBrowseHistoryFieldError {
|
|||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum RuntimeProfileFieldError {
|
pub enum RuntimeProfileFieldError {
|
||||||
MissingUserId,
|
MissingUserId,
|
||||||
|
MissingLedgerId,
|
||||||
|
InvalidWalletAmount,
|
||||||
MissingInviteCode,
|
MissingInviteCode,
|
||||||
MissingProductId,
|
MissingProductId,
|
||||||
MissingWorldKey,
|
MissingWorldKey,
|
||||||
@@ -877,6 +898,26 @@ pub fn build_runtime_profile_wallet_ledger_list_input(
|
|||||||
Ok(RuntimeProfileWalletLedgerListInput { user_id })
|
Ok(RuntimeProfileWalletLedgerListInput { user_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_runtime_profile_wallet_adjustment_input(
|
||||||
|
user_id: String,
|
||||||
|
amount: u64,
|
||||||
|
ledger_id: String,
|
||||||
|
created_at_micros: i64,
|
||||||
|
) -> Result<RuntimeProfileWalletAdjustmentInput, RuntimeProfileFieldError> {
|
||||||
|
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||||
|
let ledger_id =
|
||||||
|
normalize_required_string(ledger_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?;
|
||||||
|
if amount == 0 || amount > i64::MAX as u64 {
|
||||||
|
return Err(RuntimeProfileFieldError::InvalidWalletAmount);
|
||||||
|
}
|
||||||
|
Ok(RuntimeProfileWalletAdjustmentInput {
|
||||||
|
user_id,
|
||||||
|
amount,
|
||||||
|
ledger_id,
|
||||||
|
created_at_micros,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_runtime_profile_recharge_center_get_input(
|
pub fn build_runtime_profile_recharge_center_get_input(
|
||||||
user_id: String,
|
user_id: String,
|
||||||
) -> Result<RuntimeProfileRechargeCenterGetInput, RuntimeProfileFieldError> {
|
) -> Result<RuntimeProfileRechargeCenterGetInput, RuntimeProfileFieldError> {
|
||||||
@@ -1465,6 +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::AssetGenerationRefund => "asset_generation_refund",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1697,6 +1740,8 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
|||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
|
Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
|
||||||
|
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
|
||||||
|
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
|
||||||
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
|
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
|
||||||
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
|
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
|
||||||
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
|
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
|
||||||
@@ -1962,6 +2007,14 @@ mod tests {
|
|||||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(),
|
RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(),
|
||||||
"points_recharge"
|
"points_recharge"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume.as_str(),
|
||||||
|
"asset_generation_consume"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund.as_str(),
|
||||||
|
"asset_generation_refund"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync
|
|||||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge";
|
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_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 =
|
||||||
|
"asset_generation_consume";
|
||||||
|
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str =
|
||||||
|
"asset_generation_refund";
|
||||||
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
|
pub const BROWSE_HISTORY_THEME_MODE_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";
|
||||||
@@ -752,19 +756,83 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn profile_wallet_ledger_response_uses_camel_case_fields() {
|
fn profile_wallet_ledger_response_uses_camel_case_fields() {
|
||||||
let payload = serde_json::to_value(ProfileWalletLedgerResponse {
|
let payload = serde_json::to_value(ProfileWalletLedgerResponse {
|
||||||
entries: vec![ProfileWalletLedgerEntryResponse {
|
entries: vec![
|
||||||
id: "ledger-1".to_string(),
|
ProfileWalletLedgerEntryResponse {
|
||||||
amount_delta: 12,
|
id: "ledger-1".to_string(),
|
||||||
balance_after: 80,
|
amount_delta: 12,
|
||||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(),
|
balance_after: 80,
|
||||||
created_at: "2026-04-22T10:00:00Z".to_string(),
|
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(),
|
||||||
}],
|
created_at: "2026-04-22T10:00:00Z".to_string(),
|
||||||
|
},
|
||||||
|
ProfileWalletLedgerEntryResponse {
|
||||||
|
id: "ledger-2".to_string(),
|
||||||
|
amount_delta: 30,
|
||||||
|
balance_after: 110,
|
||||||
|
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
|
||||||
|
.to_string(),
|
||||||
|
created_at: "2026-04-22T10:01:00Z".to_string(),
|
||||||
|
},
|
||||||
|
ProfileWalletLedgerEntryResponse {
|
||||||
|
id: "ledger-3".to_string(),
|
||||||
|
amount_delta: 30,
|
||||||
|
balance_after: 140,
|
||||||
|
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD
|
||||||
|
.to_string(),
|
||||||
|
created_at: "2026-04-22T10:02:00Z".to_string(),
|
||||||
|
},
|
||||||
|
ProfileWalletLedgerEntryResponse {
|
||||||
|
id: "ledger-4".to_string(),
|
||||||
|
amount_delta: 60,
|
||||||
|
balance_after: 200,
|
||||||
|
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(),
|
||||||
|
created_at: "2026-04-22T10:03:00Z".to_string(),
|
||||||
|
},
|
||||||
|
ProfileWalletLedgerEntryResponse {
|
||||||
|
id: "ledger-5".to_string(),
|
||||||
|
amount_delta: -1,
|
||||||
|
balance_after: 199,
|
||||||
|
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
|
||||||
|
.to_string(),
|
||||||
|
created_at: "2026-04-22T10:04:00Z".to_string(),
|
||||||
|
},
|
||||||
|
ProfileWalletLedgerEntryResponse {
|
||||||
|
id: "ledger-6".to_string(),
|
||||||
|
amount_delta: 1,
|
||||||
|
balance_after: 200,
|
||||||
|
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
||||||
|
.to_string(),
|
||||||
|
created_at: "2026-04-22T10:05:00Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
.expect("payload should serialize");
|
.expect("payload should serialize");
|
||||||
|
|
||||||
assert_eq!(payload["entries"][0]["amountDelta"], json!(12));
|
assert_eq!(payload["entries"][0]["amountDelta"], json!(12));
|
||||||
assert_eq!(payload["entries"][0]["balanceAfter"], json!(80));
|
assert_eq!(payload["entries"][0]["balanceAfter"], json!(80));
|
||||||
assert_eq!(payload["entries"][0]["sourceType"], json!("snapshot_sync"));
|
assert_eq!(
|
||||||
|
payload["entries"][0]["sourceType"],
|
||||||
|
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["entries"][1]["sourceType"],
|
||||||
|
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["entries"][2]["sourceType"],
|
||||||
|
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["entries"][3]["sourceType"],
|
||||||
|
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["entries"][4]["sourceType"],
|
||||||
|
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["entries"][5]["sourceType"],
|
||||||
|
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["entries"][0]["createdAt"],
|
payload["entries"][0]["createdAt"],
|
||||||
json!("2026-04-22T10:00:00Z")
|
json!("2026-04-22T10:00:00Z")
|
||||||
|
|||||||
@@ -250,5 +250,4 @@ impl SpacetimeClient {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ use module_runtime::{
|
|||||||
build_runtime_profile_recharge_center_record,
|
build_runtime_profile_recharge_center_record,
|
||||||
build_runtime_profile_recharge_order_create_input,
|
build_runtime_profile_recharge_order_create_input,
|
||||||
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record,
|
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record,
|
||||||
build_runtime_profile_save_archive_resume_input,
|
build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input,
|
||||||
build_runtime_profile_wallet_ledger_entry_record,
|
build_runtime_profile_wallet_ledger_entry_record,
|
||||||
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,
|
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,
|
||||||
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,
|
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ impl From<module_runtime::RuntimeProfileWalletLedgerListInput>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<module_runtime::RuntimeProfileWalletAdjustmentInput>
|
||||||
|
for RuntimeProfileWalletAdjustmentInput
|
||||||
|
{
|
||||||
|
fn from(input: module_runtime::RuntimeProfileWalletAdjustmentInput) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id: input.user_id,
|
||||||
|
amount: input.amount,
|
||||||
|
ledger_id: input.ledger_id,
|
||||||
|
created_at_micros: input.created_at_micros,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<module_runtime::RuntimeProfileRechargeCenterGetInput>
|
impl From<module_runtime::RuntimeProfileRechargeCenterGetInput>
|
||||||
for RuntimeProfileRechargeCenterGetInput
|
for RuntimeProfileRechargeCenterGetInput
|
||||||
{
|
{
|
||||||
@@ -663,6 +676,28 @@ pub(crate) fn map_runtime_profile_wallet_ledger_procedure_result(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn map_runtime_profile_wallet_adjustment_procedure_result(
|
||||||
|
result: RuntimeProfileWalletAdjustmentProcedureResult,
|
||||||
|
) -> Result<RuntimeProfileDashboardRecord, SpacetimeClientError> {
|
||||||
|
if !result.ok {
|
||||||
|
return Err(SpacetimeClientError::Procedure(
|
||||||
|
result
|
||||||
|
.error_message
|
||||||
|
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = result.record.ok_or_else(|| {
|
||||||
|
SpacetimeClientError::Procedure(
|
||||||
|
"SpacetimeDB procedure 未返回 profile dashboard 快照".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(build_runtime_profile_dashboard_record(
|
||||||
|
map_runtime_profile_dashboard_snapshot(snapshot),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn map_runtime_profile_recharge_center_procedure_result(
|
pub(crate) fn map_runtime_profile_recharge_center_procedure_result(
|
||||||
result: RuntimeProfileRechargeCenterProcedureResult,
|
result: RuntimeProfileRechargeCenterProcedureResult,
|
||||||
) -> Result<RuntimeProfileRechargeCenterRecord, SpacetimeClientError> {
|
) -> Result<RuntimeProfileRechargeCenterRecord, SpacetimeClientError> {
|
||||||
@@ -3236,6 +3271,12 @@ 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 => {
|
||||||
|
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume
|
||||||
|
}
|
||||||
|
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
||||||
|
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
#![allow(unused, clippy::all)]
|
||||||
|
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||||
|
|
||||||
|
use super::runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput;
|
||||||
|
use super::runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult;
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
struct ConsumeProfileWalletPointsAndReturnArgs {
|
||||||
|
pub input: RuntimeProfileWalletAdjustmentInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for ConsumeProfileWalletPointsAndReturnArgs {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
/// Extension trait for access to the procedure `consume_profile_wallet_points_and_return`.
|
||||||
|
///
|
||||||
|
/// Implemented for [`super::RemoteProcedures`].
|
||||||
|
pub trait consume_profile_wallet_points_and_return {
|
||||||
|
fn consume_profile_wallet_points_and_return(&self, input: RuntimeProfileWalletAdjustmentInput) {
|
||||||
|
self.consume_profile_wallet_points_and_return_then(input, |_, _| {});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consume_profile_wallet_points_and_return_then(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileWalletAdjustmentInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl consume_profile_wallet_points_and_return for super::RemoteProcedures {
|
||||||
|
fn consume_profile_wallet_points_and_return_then(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileWalletAdjustmentInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>(
|
||||||
|
"consume_profile_wallet_points_and_return",
|
||||||
|
ConsumeProfileWalletPointsAndReturnArgs { input },
|
||||||
|
__callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,7 @@ pub mod complete_ai_task_and_return_procedure;
|
|||||||
pub mod confirm_asset_object_and_return_procedure;
|
pub mod confirm_asset_object_and_return_procedure;
|
||||||
pub mod confirm_asset_object_reducer;
|
pub mod confirm_asset_object_reducer;
|
||||||
pub mod consume_inventory_item_input_type;
|
pub mod consume_inventory_item_input_type;
|
||||||
|
pub mod consume_profile_wallet_points_and_return_procedure;
|
||||||
pub mod continue_story_and_return_procedure;
|
pub mod continue_story_and_return_procedure;
|
||||||
pub mod continue_story_reducer;
|
pub mod continue_story_reducer;
|
||||||
pub mod create_ai_task_and_return_procedure;
|
pub mod create_ai_task_and_return_procedure;
|
||||||
@@ -343,6 +344,7 @@ pub mod quest_step_snapshot_type;
|
|||||||
pub mod quest_treasure_inspected_signal_type;
|
pub mod quest_treasure_inspected_signal_type;
|
||||||
pub mod quest_turn_in_input_type;
|
pub mod quest_turn_in_input_type;
|
||||||
pub mod redeem_profile_referral_invite_code_procedure;
|
pub mod redeem_profile_referral_invite_code_procedure;
|
||||||
|
pub mod refund_profile_wallet_points_and_return_procedure;
|
||||||
pub mod refresh_session_type;
|
pub mod refresh_session_type;
|
||||||
pub mod resolve_combat_action_and_return_procedure;
|
pub mod resolve_combat_action_and_return_procedure;
|
||||||
pub mod resolve_combat_action_input_type;
|
pub mod resolve_combat_action_input_type;
|
||||||
@@ -405,6 +407,8 @@ pub mod runtime_profile_save_archive_list_input_type;
|
|||||||
pub mod runtime_profile_save_archive_procedure_result_type;
|
pub mod runtime_profile_save_archive_procedure_result_type;
|
||||||
pub mod runtime_profile_save_archive_resume_input_type;
|
pub mod runtime_profile_save_archive_resume_input_type;
|
||||||
pub mod runtime_profile_save_archive_snapshot_type;
|
pub mod runtime_profile_save_archive_snapshot_type;
|
||||||
|
pub mod runtime_profile_wallet_adjustment_input_type;
|
||||||
|
pub mod runtime_profile_wallet_adjustment_procedure_result_type;
|
||||||
pub mod runtime_profile_wallet_ledger_entry_snapshot_type;
|
pub mod runtime_profile_wallet_ledger_entry_snapshot_type;
|
||||||
pub mod runtime_profile_wallet_ledger_list_input_type;
|
pub mod runtime_profile_wallet_ledger_list_input_type;
|
||||||
pub mod runtime_profile_wallet_ledger_procedure_result_type;
|
pub mod runtime_profile_wallet_ledger_procedure_result_type;
|
||||||
@@ -583,6 +587,7 @@ pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return;
|
|||||||
pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return;
|
pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return;
|
||||||
pub use confirm_asset_object_reducer::confirm_asset_object;
|
pub use confirm_asset_object_reducer::confirm_asset_object;
|
||||||
pub use consume_inventory_item_input_type::ConsumeInventoryItemInput;
|
pub use consume_inventory_item_input_type::ConsumeInventoryItemInput;
|
||||||
|
pub use consume_profile_wallet_points_and_return_procedure::consume_profile_wallet_points_and_return;
|
||||||
pub use continue_story_and_return_procedure::continue_story_and_return;
|
pub use continue_story_and_return_procedure::continue_story_and_return;
|
||||||
pub use continue_story_reducer::continue_story;
|
pub use continue_story_reducer::continue_story;
|
||||||
pub use create_ai_task_and_return_procedure::create_ai_task_and_return;
|
pub use create_ai_task_and_return_procedure::create_ai_task_and_return;
|
||||||
@@ -808,6 +813,7 @@ pub use quest_step_snapshot_type::QuestStepSnapshot;
|
|||||||
pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal;
|
pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal;
|
||||||
pub use quest_turn_in_input_type::QuestTurnInInput;
|
pub use quest_turn_in_input_type::QuestTurnInInput;
|
||||||
pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code;
|
pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code;
|
||||||
|
pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return;
|
||||||
pub use refresh_session_type::RefreshSession;
|
pub use refresh_session_type::RefreshSession;
|
||||||
pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return;
|
pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return;
|
||||||
pub use resolve_combat_action_input_type::ResolveCombatActionInput;
|
pub use resolve_combat_action_input_type::ResolveCombatActionInput;
|
||||||
@@ -870,6 +876,8 @@ pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveL
|
|||||||
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
|
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
|
||||||
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;
|
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;
|
||||||
pub use runtime_profile_save_archive_snapshot_type::RuntimeProfileSaveArchiveSnapshot;
|
pub use runtime_profile_save_archive_snapshot_type::RuntimeProfileSaveArchiveSnapshot;
|
||||||
|
pub use runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput;
|
||||||
|
pub use runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult;
|
||||||
pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
|
pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
|
||||||
pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput;
|
pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput;
|
||||||
pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult;
|
pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
#![allow(unused, clippy::all)]
|
||||||
|
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||||
|
|
||||||
|
use super::runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput;
|
||||||
|
use super::runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult;
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
struct RefundProfileWalletPointsAndReturnArgs {
|
||||||
|
pub input: RuntimeProfileWalletAdjustmentInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RefundProfileWalletPointsAndReturnArgs {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
/// Extension trait for access to the procedure `refund_profile_wallet_points_and_return`.
|
||||||
|
///
|
||||||
|
/// Implemented for [`super::RemoteProcedures`].
|
||||||
|
pub trait refund_profile_wallet_points_and_return {
|
||||||
|
fn refund_profile_wallet_points_and_return(&self, input: RuntimeProfileWalletAdjustmentInput) {
|
||||||
|
self.refund_profile_wallet_points_and_return_then(input, |_, _| {});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refund_profile_wallet_points_and_return_then(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileWalletAdjustmentInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl refund_profile_wallet_points_and_return for super::RemoteProcedures {
|
||||||
|
fn refund_profile_wallet_points_and_return_then(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileWalletAdjustmentInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>(
|
||||||
|
"refund_profile_wallet_points_and_return",
|
||||||
|
RefundProfileWalletPointsAndReturnArgs { input },
|
||||||
|
__callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
#![allow(unused, clippy::all)]
|
||||||
|
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
pub struct RuntimeProfileWalletAdjustmentInput {
|
||||||
|
pub user_id: String,
|
||||||
|
pub amount: u64,
|
||||||
|
pub ledger_id: String,
|
||||||
|
pub created_at_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RuntimeProfileWalletAdjustmentInput {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
#![allow(unused, clippy::all)]
|
||||||
|
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||||
|
|
||||||
|
use super::runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot;
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
pub struct RuntimeProfileWalletAdjustmentProcedureResult {
|
||||||
|
pub ok: bool,
|
||||||
|
pub record: Option<RuntimeProfileDashboardSnapshot>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RuntimeProfileWalletAdjustmentProcedureResult {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
|||||||
InviteInviteeReward,
|
InviteInviteeReward,
|
||||||
|
|
||||||
PointsRecharge,
|
PointsRecharge,
|
||||||
|
|
||||||
|
AssetGenerationConsume,
|
||||||
|
|
||||||
|
AssetGenerationRefund,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
||||||
|
|||||||
@@ -89,6 +89,67 @@ impl SpacetimeClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn consume_profile_wallet_points(
|
||||||
|
&self,
|
||||||
|
user_id: String,
|
||||||
|
amount: u64,
|
||||||
|
ledger_id: String,
|
||||||
|
created_at_micros: i64,
|
||||||
|
) -> Result<RuntimeProfileDashboardRecord, SpacetimeClientError> {
|
||||||
|
let procedure_input = build_runtime_profile_wallet_adjustment_input(
|
||||||
|
user_id,
|
||||||
|
amount,
|
||||||
|
ledger_id,
|
||||||
|
created_at_micros,
|
||||||
|
)
|
||||||
|
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
self.call_after_connect(move |connection, sender| {
|
||||||
|
connection
|
||||||
|
.procedures()
|
||||||
|
.consume_profile_wallet_points_and_return_then(
|
||||||
|
procedure_input,
|
||||||
|
move |_, result| {
|
||||||
|
let mapped = result
|
||||||
|
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||||
|
.and_then(map_runtime_profile_wallet_adjustment_procedure_result);
|
||||||
|
send_once(&sender, mapped);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refund_profile_wallet_points(
|
||||||
|
&self,
|
||||||
|
user_id: String,
|
||||||
|
amount: u64,
|
||||||
|
ledger_id: String,
|
||||||
|
created_at_micros: i64,
|
||||||
|
) -> Result<RuntimeProfileDashboardRecord, SpacetimeClientError> {
|
||||||
|
let procedure_input = build_runtime_profile_wallet_adjustment_input(
|
||||||
|
user_id,
|
||||||
|
amount,
|
||||||
|
ledger_id,
|
||||||
|
created_at_micros,
|
||||||
|
)
|
||||||
|
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
self.call_after_connect(move |connection, sender| {
|
||||||
|
connection
|
||||||
|
.procedures()
|
||||||
|
.refund_profile_wallet_points_and_return_then(procedure_input, move |_, result| {
|
||||||
|
let mapped = result
|
||||||
|
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||||
|
.and_then(map_runtime_profile_wallet_adjustment_procedure_result);
|
||||||
|
send_once(&sender, mapped);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_profile_recharge_center(
|
pub async fn get_profile_recharge_center(
|
||||||
&self,
|
&self,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|||||||
@@ -238,6 +238,60 @@ pub fn list_profile_wallet_ledger(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 资产生成由 Axum 调用外部模型,钱包扣费必须先在 SpacetimeDB 内原子落账。
|
||||||
|
#[spacetimedb::procedure]
|
||||||
|
pub fn consume_profile_wallet_points_and_return(
|
||||||
|
ctx: &mut ProcedureContext,
|
||||||
|
input: RuntimeProfileWalletAdjustmentInput,
|
||||||
|
) -> RuntimeProfileWalletAdjustmentProcedureResult {
|
||||||
|
match ctx.try_with_tx(|tx| {
|
||||||
|
apply_profile_wallet_adjustment(
|
||||||
|
tx,
|
||||||
|
input.clone(),
|
||||||
|
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult {
|
||||||
|
ok: true,
|
||||||
|
record: Some(record),
|
||||||
|
error_message: None,
|
||||||
|
},
|
||||||
|
Err(message) => RuntimeProfileWalletAdjustmentProcedureResult {
|
||||||
|
ok: false,
|
||||||
|
record: None,
|
||||||
|
error_message: Some(message),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成链路失败时由 Axum 调用退款,ledger_id 幂等保证重复补偿不会重复加钱。
|
||||||
|
#[spacetimedb::procedure]
|
||||||
|
pub fn refund_profile_wallet_points_and_return(
|
||||||
|
ctx: &mut ProcedureContext,
|
||||||
|
input: RuntimeProfileWalletAdjustmentInput,
|
||||||
|
) -> RuntimeProfileWalletAdjustmentProcedureResult {
|
||||||
|
match ctx.try_with_tx(|tx| {
|
||||||
|
apply_profile_wallet_adjustment(
|
||||||
|
tx,
|
||||||
|
input.clone(),
|
||||||
|
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult {
|
||||||
|
ok: true,
|
||||||
|
record: Some(record),
|
||||||
|
error_message: None,
|
||||||
|
},
|
||||||
|
Err(message) => RuntimeProfileWalletAdjustmentProcedureResult {
|
||||||
|
ok: false,
|
||||||
|
record: None,
|
||||||
|
error_message: Some(message),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。
|
// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。
|
||||||
#[spacetimedb::procedure]
|
#[spacetimedb::procedure]
|
||||||
pub fn get_profile_play_stats(
|
pub fn get_profile_play_stats(
|
||||||
@@ -1370,15 +1424,91 @@ fn apply_profile_wallet_delta(
|
|||||||
ledger_id: &str,
|
ledger_id: &str,
|
||||||
created_at: Timestamp,
|
created_at: Timestamp,
|
||||||
) -> Result<u64, String> {
|
) -> Result<u64, String> {
|
||||||
|
let amount_delta =
|
||||||
|
i64::try_from(amount_delta).map_err(|_| "profile.wallet_amount 超出上限".to_string())?;
|
||||||
|
apply_profile_wallet_signed_delta(
|
||||||
|
ctx,
|
||||||
|
user_id,
|
||||||
|
amount_delta,
|
||||||
|
source_type,
|
||||||
|
ledger_id,
|
||||||
|
created_at,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_profile_wallet_adjustment(
|
||||||
|
ctx: &ReducerContext,
|
||||||
|
input: RuntimeProfileWalletAdjustmentInput,
|
||||||
|
source_type: RuntimeProfileWalletLedgerSourceType,
|
||||||
|
consume: bool,
|
||||||
|
) -> Result<RuntimeProfileDashboardSnapshot, String> {
|
||||||
|
let validated_input = build_runtime_profile_wallet_adjustment_input(
|
||||||
|
input.user_id,
|
||||||
|
input.amount,
|
||||||
|
input.ledger_id,
|
||||||
|
input.created_at_micros,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
|
||||||
|
let amount_delta = if consume {
|
||||||
|
-(validated_input.amount as i64)
|
||||||
|
} else {
|
||||||
|
validated_input.amount as i64
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_profile_wallet_signed_delta(
|
||||||
|
ctx,
|
||||||
|
&validated_input.user_id,
|
||||||
|
amount_delta,
|
||||||
|
source_type,
|
||||||
|
&validated_input.ledger_id,
|
||||||
|
created_at,
|
||||||
|
true,
|
||||||
|
)?;
|
||||||
|
get_profile_dashboard_snapshot(
|
||||||
|
ctx,
|
||||||
|
RuntimeProfileDashboardGetInput {
|
||||||
|
user_id: validated_input.user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_profile_wallet_signed_delta(
|
||||||
|
ctx: &ReducerContext,
|
||||||
|
user_id: &str,
|
||||||
|
amount_delta: i64,
|
||||||
|
source_type: RuntimeProfileWalletLedgerSourceType,
|
||||||
|
ledger_id: &str,
|
||||||
|
created_at: Timestamp,
|
||||||
|
idempotent: bool,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
if idempotent
|
||||||
|
&& ctx
|
||||||
|
.db
|
||||||
|
.profile_wallet_ledger()
|
||||||
|
.wallet_ledger_id()
|
||||||
|
.find(&ledger_id.to_string())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Ok(profile_wallet_balance(ctx, user_id));
|
||||||
|
}
|
||||||
|
|
||||||
let current = ctx
|
let current = ctx
|
||||||
.db
|
.db
|
||||||
.profile_dashboard_state()
|
.profile_dashboard_state()
|
||||||
.user_id()
|
.user_id()
|
||||||
.find(&user_id.to_string());
|
.find(&user_id.to_string());
|
||||||
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
|
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
|
||||||
let next_balance = previous_balance
|
let next_balance = if amount_delta >= 0 {
|
||||||
.checked_add(amount_delta)
|
previous_balance
|
||||||
.ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?;
|
.checked_add(amount_delta as u64)
|
||||||
|
.ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?
|
||||||
|
} else {
|
||||||
|
previous_balance
|
||||||
|
.checked_sub(amount_delta.unsigned_abs())
|
||||||
|
.ok_or_else(|| "叙世币余额不足".to_string())?
|
||||||
|
};
|
||||||
let created_state_at = current
|
let created_state_at = current
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|row| row.created_at)
|
.map(|row| row.created_at)
|
||||||
@@ -1413,7 +1543,7 @@ fn apply_profile_wallet_delta(
|
|||||||
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
|
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
|
||||||
wallet_ledger_id: ledger_id.to_string(),
|
wallet_ledger_id: ledger_id.to_string(),
|
||||||
user_id: user_id.to_string(),
|
user_id: user_id.to_string(),
|
||||||
amount_delta: amount_delta as i64,
|
amount_delta,
|
||||||
balance_after: next_balance,
|
balance_after: next_balance,
|
||||||
source_type,
|
source_type,
|
||||||
created_at,
|
created_at,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
APP_RUNTIME_ROUTES,
|
APP_RUNTIME_ROUTES,
|
||||||
normalizeAppPath,
|
normalizeAppPath,
|
||||||
pushAppHistoryPath,
|
pushAppHistoryPath,
|
||||||
|
readPublicWorkCodeFromLocationSearch,
|
||||||
resolvePathForSelectionStage,
|
resolvePathForSelectionStage,
|
||||||
resolveSelectionStageFromPath,
|
resolveSelectionStageFromPath,
|
||||||
} from './routing/appPageRoutes';
|
} from './routing/appPageRoutes';
|
||||||
@@ -45,6 +46,9 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const [runtimeReturnStage, setRuntimeReturnStage] =
|
const [runtimeReturnStage, setRuntimeReturnStage] =
|
||||||
useState<SelectionStage>('platform');
|
useState<SelectionStage>('platform');
|
||||||
|
const [initialPublicWorkCode] = useState(() =>
|
||||||
|
readPublicWorkCodeFromLocationSearch(window.location.search),
|
||||||
|
);
|
||||||
|
|
||||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||||
setRawSelectionStage(stage);
|
setRawSelectionStage(stage);
|
||||||
@@ -134,6 +138,7 @@ export default function App() {
|
|||||||
<PlatformEntryFlowShell
|
<PlatformEntryFlowShell
|
||||||
selectionStage={selectionStage}
|
selectionStage={selectionStage}
|
||||||
setSelectionStage={setSelectionStage}
|
setSelectionStage={setSelectionStage}
|
||||||
|
initialPublicWorkCode={initialPublicWorkCode}
|
||||||
hasSavedGame={false}
|
hasSavedGame={false}
|
||||||
savedSnapshot={null}
|
savedSnapshot={null}
|
||||||
handleContinueGame={handleContinueGame}
|
handleContinueGame={handleContinueGame}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, CircleHelp, Loader2, RotateCcw } from 'lucide-react';
|
import { ArrowLeft, CircleHelp, Loader2, RotateCcw, Share2 } from 'lucide-react';
|
||||||
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
BigFishRuntimeSnapshotResponse,
|
BigFishRuntimeSnapshotResponse,
|
||||||
SubmitBigFishInputRequest,
|
SubmitBigFishInputRequest,
|
||||||
} from '../../../packages/shared/src/contracts/bigFish';
|
} from '../../../packages/shared/src/contracts/bigFish';
|
||||||
|
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||||
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import { UnifiedModal } from '../common/UnifiedModal';
|
import { UnifiedModal } from '../common/UnifiedModal';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ type TouchSample = TouchOrigin;
|
|||||||
type BigFishRuntimeShellProps = {
|
type BigFishRuntimeShellProps = {
|
||||||
run: BigFishRuntimeSnapshotResponse | null;
|
run: BigFishRuntimeSnapshotResponse | null;
|
||||||
assetSlots?: BigFishAssetSlotResponse[];
|
assetSlots?: BigFishAssetSlotResponse[];
|
||||||
|
shareTitle?: string | null;
|
||||||
|
sharePublicWorkCode?: string | null;
|
||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -219,6 +223,8 @@ function BigFishEntityDot({
|
|||||||
export function BigFishRuntimeShell({
|
export function BigFishRuntimeShell({
|
||||||
run,
|
run,
|
||||||
assetSlots = [],
|
assetSlots = [],
|
||||||
|
shareTitle = null,
|
||||||
|
sharePublicWorkCode = null,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
error = null,
|
error = null,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -230,6 +236,9 @@ export function BigFishRuntimeShell({
|
|||||||
const currentTouchRef = useRef<TouchSample | null>(null);
|
const currentTouchRef = useRef<TouchSample | null>(null);
|
||||||
const lastTouchSampleRef = useRef<TouchSample | null>(null);
|
const lastTouchSampleRef = useRef<TouchSample | null>(null);
|
||||||
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
|
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
|
||||||
|
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
const [stick, setStick] = useState({ x: 0, y: 0 });
|
const [stick, setStick] = useState({ x: 0, y: 0 });
|
||||||
const stickRef = useRef(stick);
|
const stickRef = useRef(stick);
|
||||||
|
|
||||||
@@ -282,6 +291,28 @@ export function BigFishRuntimeShell({
|
|||||||
setStick(direction);
|
setStick(direction);
|
||||||
onSubmitInput(direction);
|
onSubmitInput(direction);
|
||||||
};
|
};
|
||||||
|
const sharePublicWork = () => {
|
||||||
|
const publicWorkCode = sharePublicWorkCode?.trim();
|
||||||
|
if (!publicWorkCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharePath = buildPublicWorkStagePath(
|
||||||
|
'big-fish-runtime',
|
||||||
|
publicWorkCode,
|
||||||
|
);
|
||||||
|
const shareUrl =
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? sharePath
|
||||||
|
: new URL(sharePath, window.location.origin).href;
|
||||||
|
const title = shareTitle?.trim() || '大鱼吃小鱼';
|
||||||
|
const shareText = `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${shareUrl}`;
|
||||||
|
|
||||||
|
void copyTextToClipboard(shareText).then((copied) => {
|
||||||
|
setShareState(copied ? 'copied' : 'failed');
|
||||||
|
window.setTimeout(() => setShareState('idle'), 1400);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
if (event.target instanceof HTMLElement && event.target.closest('button')) {
|
if (event.target instanceof HTMLElement && event.target.closest('button')) {
|
||||||
@@ -373,6 +404,29 @@ export function BigFishRuntimeShell({
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{sharePublicWorkCode?.trim() ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={
|
||||||
|
shareState === 'copied'
|
||||||
|
? '分享内容已复制'
|
||||||
|
: shareState === 'failed'
|
||||||
|
? '分享内容复制失败'
|
||||||
|
: '分享作品'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
shareState === 'copied'
|
||||||
|
? '已复制'
|
||||||
|
: shareState === 'failed'
|
||||||
|
? '复制失败'
|
||||||
|
: '分享作品'
|
||||||
|
}
|
||||||
|
onClick={sharePublicWork}
|
||||||
|
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="查看规则"
|
aria-label="查看规则"
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import type {
|
|||||||
CustomWorldLibraryEntry,
|
CustomWorldLibraryEntry,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||||
|
import {
|
||||||
|
buildPublicWorkStagePath,
|
||||||
|
pushAppHistoryPath,
|
||||||
|
} from '../../routing/appPageRoutes';
|
||||||
import {
|
import {
|
||||||
getPublicAuthUserByCode,
|
getPublicAuthUserByCode,
|
||||||
getPublicAuthUserById,
|
getPublicAuthUserById,
|
||||||
@@ -48,11 +52,11 @@ import {
|
|||||||
getBigFishCreationSession,
|
getBigFishCreationSession,
|
||||||
streamBigFishCreationMessage,
|
streamBigFishCreationMessage,
|
||||||
} from '../../services/big-fish-creation';
|
} from '../../services/big-fish-creation';
|
||||||
|
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||||
import {
|
import {
|
||||||
advanceLocalBigFishRuntimeRun,
|
advanceLocalBigFishRuntimeRun,
|
||||||
startLocalBigFishRuntimeRun,
|
startLocalBigFishRuntimeRun,
|
||||||
} from '../../services/big-fish-runtime';
|
} from '../../services/big-fish-runtime';
|
||||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
|
||||||
import {
|
import {
|
||||||
deleteBigFishWork,
|
deleteBigFishWork,
|
||||||
listBigFishWorks,
|
listBigFishWorks,
|
||||||
@@ -70,6 +74,8 @@ import {
|
|||||||
} from '../../services/miniGameDraftGenerationProgress';
|
} from '../../services/miniGameDraftGenerationProgress';
|
||||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||||
import {
|
import {
|
||||||
|
buildBigFishPublicWorkCode,
|
||||||
|
buildPuzzlePublicWorkCode,
|
||||||
isSameBigFishPublicWorkCode,
|
isSameBigFishPublicWorkCode,
|
||||||
isSamePuzzlePublicWorkCode,
|
isSamePuzzlePublicWorkCode,
|
||||||
} from '../../services/publicWorkCode';
|
} from '../../services/publicWorkCode';
|
||||||
@@ -407,6 +413,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
handleContinueGame,
|
handleContinueGame,
|
||||||
handleStartNewGame,
|
handleStartNewGame,
|
||||||
handleCustomWorldSelect,
|
handleCustomWorldSelect,
|
||||||
|
initialPublicWorkCode,
|
||||||
}: PlatformEntryFlowShellProps) {
|
}: PlatformEntryFlowShellProps) {
|
||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||||
@@ -418,6 +425,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
>([]);
|
>([]);
|
||||||
const [bigFishRun, setBigFishRun] =
|
const [bigFishRun, setBigFishRun] =
|
||||||
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
||||||
|
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
|
||||||
|
title: string;
|
||||||
|
publicWorkCode: string;
|
||||||
|
} | null>(null);
|
||||||
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
||||||
const [bigFishGenerationState, setBigFishGenerationState] =
|
const [bigFishGenerationState, setBigFishGenerationState] =
|
||||||
useState<MiniGameDraftGenerationState | null>(null);
|
useState<MiniGameDraftGenerationState | null>(null);
|
||||||
@@ -455,6 +466,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
readCustomWorldAgentUiState().activeSessionId &&
|
readCustomWorldAgentUiState().activeSessionId &&
|
||||||
shouldRestoreCustomWorldAgentUiState(),
|
shouldRestoreCustomWorldAgentUiState(),
|
||||||
);
|
);
|
||||||
|
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const platformBootstrap = usePlatformEntryBootstrap({
|
const platformBootstrap = usePlatformEntryBootstrap({
|
||||||
user: authUi?.user,
|
user: authUi?.user,
|
||||||
@@ -926,6 +938,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
);
|
);
|
||||||
setSelectedPuzzleDetail(galleryDetail.item);
|
setSelectedPuzzleDetail(galleryDetail.item);
|
||||||
setSelectionStage('puzzle-gallery-detail');
|
setSelectionStage('puzzle-gallery-detail');
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkStagePath(
|
||||||
|
'puzzle-gallery-detail',
|
||||||
|
buildPuzzlePublicWorkCode(galleryDetail.item.profileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeExecuteAction: ({ payload }) => {
|
beforeExecuteAction: ({ payload }) => {
|
||||||
@@ -1002,6 +1020,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setSelectedDetailEntry(null);
|
setSelectedDetailEntry(null);
|
||||||
setBigFishWorks([]);
|
setBigFishWorks([]);
|
||||||
setBigFishRun(null);
|
setBigFishRun(null);
|
||||||
|
setBigFishRuntimeShare(null);
|
||||||
setBigFishGenerationState(null);
|
setBigFishGenerationState(null);
|
||||||
setBigFishError(null);
|
setBigFishError(null);
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
@@ -1118,6 +1137,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBigFishError(null);
|
setBigFishError(null);
|
||||||
|
setBigFishRuntimeShare(null);
|
||||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||||
setSelectionStage('big-fish-runtime');
|
setSelectionStage('big-fish-runtime');
|
||||||
}, [bigFishSession, setSelectionStage]);
|
}, [bigFishSession, setSelectionStage]);
|
||||||
@@ -1128,6 +1148,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBigFishError(null);
|
setBigFishError(null);
|
||||||
|
setBigFishRuntimeShare(null);
|
||||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||||
setSelectionStage('big-fish-runtime');
|
setSelectionStage('big-fish-runtime');
|
||||||
}, [bigFishRun, bigFishSession, setSelectionStage]);
|
}, [bigFishRun, bigFishSession, setSelectionStage]);
|
||||||
@@ -1147,6 +1168,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleRun(startLocalPuzzleRun(item));
|
setPuzzleRun(startLocalPuzzleRun(item));
|
||||||
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
||||||
setSelectionStage('puzzle-runtime');
|
setSelectionStage('puzzle-runtime');
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkStagePath(
|
||||||
|
'puzzle-runtime',
|
||||||
|
buildPuzzlePublicWorkCode(item.profileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
|
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1600,6 +1627,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setSelectedPuzzleDetail(item);
|
setSelectedPuzzleDetail(item);
|
||||||
setPuzzleDetailReturnTarget(returnTarget);
|
setPuzzleDetailReturnTarget(returnTarget);
|
||||||
setSelectionStage('puzzle-gallery-detail');
|
setSelectionStage('puzzle-gallery-detail');
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkStagePath(
|
||||||
|
'puzzle-gallery-detail',
|
||||||
|
buildPuzzlePublicWorkCode(item.profileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1640,17 +1673,25 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const startBigFishRunFromWork = useCallback(
|
const startBigFishRunFromWork = useCallback(
|
||||||
(item: BigFishWorkSummary) => {
|
(item: BigFishWorkSummary) => {
|
||||||
const sessionId = item.sourceSessionId?.trim();
|
const sessionId = item.sourceSessionId?.trim();
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
|
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBigFishError(null);
|
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
|
||||||
bigFishFlow.setSession(null);
|
setBigFishError(null);
|
||||||
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
|
bigFishFlow.setSession(null);
|
||||||
setSelectionStage('big-fish-runtime');
|
setBigFishRuntimeShare({
|
||||||
},
|
title: item.title,
|
||||||
|
publicWorkCode,
|
||||||
|
});
|
||||||
|
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
|
||||||
|
setSelectionStage('big-fish-runtime');
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
|
||||||
|
);
|
||||||
|
},
|
||||||
[bigFishFlow, setSelectionStage],
|
[bigFishFlow, setSelectionStage],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1800,6 +1841,19 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const publicWorkCode = initialPublicWorkCode?.trim();
|
||||||
|
if (
|
||||||
|
!publicWorkCode ||
|
||||||
|
handledInitialPublicWorkCodeRef.current === publicWorkCode
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handledInitialPublicWorkCodeRef.current = publicWorkCode;
|
||||||
|
void handlePublicCodeSearch(publicWorkCode);
|
||||||
|
}, [handlePublicCodeSearch, initialPublicWorkCode]);
|
||||||
|
|
||||||
const openBigFishDraft = useCallback(
|
const openBigFishDraft = useCallback(
|
||||||
async (item: BigFishWorkSummary) => {
|
async (item: BigFishWorkSummary) => {
|
||||||
setBigFishRun(null);
|
setBigFishRun(null);
|
||||||
@@ -2288,6 +2342,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
<BigFishRuntimeShell
|
<BigFishRuntimeShell
|
||||||
run={bigFishRun}
|
run={bigFishRun}
|
||||||
assetSlots={bigFishSession?.assetSlots ?? []}
|
assetSlots={bigFishSession?.assetSlots ?? []}
|
||||||
|
shareTitle={bigFishRuntimeShare?.title ?? null}
|
||||||
|
sharePublicWorkCode={
|
||||||
|
bigFishRuntimeShare?.publicWorkCode ?? null
|
||||||
|
}
|
||||||
isBusy={isBigFishBusy}
|
isBusy={isBigFishBusy}
|
||||||
error={bigFishError}
|
error={bigFishError}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export type SyncedAgentDraftResult = {
|
|||||||
export type PlatformEntryFlowShellProps = {
|
export type PlatformEntryFlowShellProps = {
|
||||||
selectionStage: SelectionStage;
|
selectionStage: SelectionStage;
|
||||||
setSelectionStage: (stage: SelectionStage) => void;
|
setSelectionStage: (stage: SelectionStage) => void;
|
||||||
|
initialPublicWorkCode?: string | null;
|
||||||
hasSavedGame: boolean;
|
hasSavedGame: boolean;
|
||||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react';
|
import { ArrowLeft, Copy, Pencil, Play, Share2, UserRound } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
|
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
@@ -31,12 +32,31 @@ export function PuzzleGalleryDetailView({
|
|||||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
'idle',
|
'idle',
|
||||||
);
|
);
|
||||||
|
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
const copyPublicWorkCode = () => {
|
const copyPublicWorkCode = () => {
|
||||||
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
||||||
setCopyState(copied ? 'copied' : 'failed');
|
setCopyState(copied ? 'copied' : 'failed');
|
||||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const sharePublicWork = () => {
|
||||||
|
const sharePath = buildPublicWorkStagePath(
|
||||||
|
'puzzle-gallery-detail',
|
||||||
|
publicWorkCode,
|
||||||
|
);
|
||||||
|
const shareUrl =
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? sharePath
|
||||||
|
: new URL(sharePath, window.location.origin).href;
|
||||||
|
const shareText = `邀请你来玩《${item.levelName}》\n作品号:${publicWorkCode}\n${shareUrl}`;
|
||||||
|
|
||||||
|
void copyTextToClipboard(shareText).then((copied) => {
|
||||||
|
setShareState(copied ? 'copied' : 'failed');
|
||||||
|
window.setTimeout(() => setShareState('idle'), 1400);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||||
@@ -62,6 +82,19 @@ export function PuzzleGalleryDetailView({
|
|||||||
修改作品
|
修改作品
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={sharePublicWork}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
{shareState === 'copied'
|
||||||
|
? '已复制'
|
||||||
|
: shareState === 'failed'
|
||||||
|
? '复制失败'
|
||||||
|
: '分享作品'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ArrowLeft, Copy } from 'lucide-react';
|
import { ArrowLeft, Copy, Share2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||||
|
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
@@ -74,6 +75,9 @@ export function RpgEntryWorldDetailView({
|
|||||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
'idle',
|
'idle',
|
||||||
);
|
);
|
||||||
|
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
const canStartGame = entry.visibility === 'published';
|
const canStartGame = entry.visibility === 'published';
|
||||||
const previewCharacters = buildCustomWorldPlayableCharacters(
|
const previewCharacters = buildCustomWorldPlayableCharacters(
|
||||||
entry.profile,
|
entry.profile,
|
||||||
@@ -96,6 +100,19 @@ export function RpgEntryWorldDetailView({
|
|||||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const sharePublicWork = () => {
|
||||||
|
if (!publicWorkCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareUrl = buildPublicWorkDetailUrl(publicWorkCode);
|
||||||
|
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${shareUrl}`;
|
||||||
|
|
||||||
|
void copyTextToClipboard(shareText).then((copied) => {
|
||||||
|
setShareState(copied ? 'copied' : 'failed');
|
||||||
|
window.setTimeout(() => setShareState('idle'), 1400);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
@@ -146,21 +163,38 @@ export function RpgEntryWorldDetailView({
|
|||||||
: '仅自己可见'}
|
: '仅自己可见'}
|
||||||
</span>
|
</span>
|
||||||
{publicWorkCode ? (
|
{publicWorkCode ? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={copyPublicWorkCode}
|
type="button"
|
||||||
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
onClick={copyPublicWorkCode}
|
||||||
aria-label={`复制作品号 ${publicWorkCode}`}
|
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
||||||
title="复制作品号"
|
aria-label={`复制作品号 ${publicWorkCode}`}
|
||||||
>
|
title="复制作品号"
|
||||||
<span>作品号 {publicWorkCode}</span>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<span>作品号 {publicWorkCode}</span>
|
||||||
{copyState !== 'idle' ? (
|
<Copy className="h-3 w-3" />
|
||||||
<span className="text-xs">
|
{copyState !== 'idle' ? (
|
||||||
{copyState === 'copied' ? '已复制' : '复制失败'}
|
<span className="text-xs">
|
||||||
</span>
|
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||||
) : null}
|
</span>
|
||||||
</button>
|
) : null}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sharePublicWork}
|
||||||
|
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
||||||
|
aria-label={`分享作品 ${entry.worldName}`}
|
||||||
|
title="分享作品"
|
||||||
|
>
|
||||||
|
<Share2 className="h-3 w-3" />
|
||||||
|
<span>分享作品</span>
|
||||||
|
{shareState !== 'idle' ? (
|
||||||
|
<span className="text-xs">
|
||||||
|
{shareState === 'copied' ? '已复制' : '复制失败'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-3xl font-black text-white">
|
<div className="mt-4 text-3xl font-black text-white">
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import type {
|
|||||||
CustomWorldLibraryEntry,
|
CustomWorldLibraryEntry,
|
||||||
PlatformBrowseHistoryWriteEntry,
|
PlatformBrowseHistoryWriteEntry,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import {
|
||||||
|
buildPublicWorkDetailPath,
|
||||||
|
pushAppHistoryPath,
|
||||||
|
} from '../../routing/appPageRoutes';
|
||||||
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
import {
|
import {
|
||||||
deleteRpgEntryWorldProfile,
|
deleteRpgEntryWorldProfile,
|
||||||
getRpgEntryWorldGalleryDetail,
|
getRpgEntryWorldGalleryDetail,
|
||||||
@@ -16,7 +21,6 @@ import {
|
|||||||
publishRpgEntryWorldProfile,
|
publishRpgEntryWorldProfile,
|
||||||
unpublishRpgEntryWorldProfile,
|
unpublishRpgEntryWorldProfile,
|
||||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||||
import { ApiClientError } from '../../services/apiClient';
|
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import {
|
import {
|
||||||
normalizeRpgEntryAgentBackedProfile,
|
normalizeRpgEntryAgentBackedProfile,
|
||||||
@@ -167,6 +171,9 @@ export function useRpgEntryLibraryDetail(
|
|||||||
setSelectedDetailEntry(entry);
|
setSelectedDetailEntry(entry);
|
||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
setSelectionStage('detail');
|
setSelectionStage('detail');
|
||||||
|
if (entry.publicWorkCode?.trim()) {
|
||||||
|
pushAppHistoryPath(buildPublicWorkDetailPath(entry.publicWorkCode));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
|
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
|
||||||
);
|
);
|
||||||
@@ -183,6 +190,11 @@ export function useRpgEntryLibraryDetail(
|
|||||||
entry.profileId,
|
entry.profileId,
|
||||||
);
|
);
|
||||||
setSelectedDetailEntry(detailEntry);
|
setSelectedDetailEntry(detailEntry);
|
||||||
|
if (detailEntry.publicWorkCode?.trim()) {
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
|
||||||
|
);
|
||||||
|
}
|
||||||
void appendBrowseHistoryEntry({
|
void appendBrowseHistoryEntry({
|
||||||
ownerUserId: detailEntry.ownerUserId,
|
ownerUserId: detailEntry.ownerUserId,
|
||||||
profileId: detailEntry.profileId,
|
profileId: detailEntry.profileId,
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
APP_RUNTIME_ROUTES,
|
APP_RUNTIME_ROUTES,
|
||||||
|
buildPublicWorkDetailPath,
|
||||||
|
buildPublicWorkDetailUrl,
|
||||||
|
buildPublicWorkStagePath,
|
||||||
isKnownMainAppPagePath,
|
isKnownMainAppPagePath,
|
||||||
normalizeAppPath,
|
normalizeAppPath,
|
||||||
|
readPublicWorkCodeFromLocationSearch,
|
||||||
resolvePathForSelectionStage,
|
resolvePathForSelectionStage,
|
||||||
resolveSelectionStageFromPath,
|
resolveSelectionStageFromPath,
|
||||||
} from './appPageRoutes';
|
} from './appPageRoutes';
|
||||||
@@ -45,4 +49,22 @@ describe('appPageRoutes', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).toBe(true);
|
expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds and reads public work detail query routes', () => {
|
||||||
|
expect(buildPublicWorkDetailPath('CW-00000001')).toBe(
|
||||||
|
'/worlds/detail?work=CW-00000001',
|
||||||
|
);
|
||||||
|
expect(buildPublicWorkDetailUrl('CW-00000001', 'https://example.test')).toBe(
|
||||||
|
'https://example.test/worlds/detail?work=CW-00000001',
|
||||||
|
);
|
||||||
|
expect(readPublicWorkCodeFromLocationSearch('?work=CW-00000001')).toBe(
|
||||||
|
'CW-00000001',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
buildPublicWorkStagePath('puzzle-gallery-detail', 'PZ-00000002'),
|
||||||
|
).toBe('/gallery/puzzle/detail?work=PZ-00000002');
|
||||||
|
expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe(
|
||||||
|
'/runtime/big-fish?work=BF-00000003',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { SelectionStage } from '../components/platform-entry';
|
|||||||
|
|
||||||
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
|
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
|
||||||
|
|
||||||
|
export const PUBLIC_WORK_QUERY_PARAM = 'work';
|
||||||
|
|
||||||
const STAGE_ROUTE_ENTRIES = [
|
const STAGE_ROUTE_ENTRIES = [
|
||||||
['platform', '/'],
|
['platform', '/'],
|
||||||
['detail', '/worlds/detail'],
|
['detail', '/worlds/detail'],
|
||||||
@@ -49,6 +51,37 @@ export function resolvePathForSelectionStage(stage: SelectionStage) {
|
|||||||
return APP_STAGE_ROUTES[stage] ?? APP_STAGE_ROUTES.platform;
|
return APP_STAGE_ROUTES[stage] ?? APP_STAGE_ROUTES.platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readPublicWorkCodeFromLocationSearch(search: string) {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
return params.get(PUBLIC_WORK_QUERY_PARAM)?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPublicWorkDetailPath(publicWorkCode: string) {
|
||||||
|
return buildPublicWorkStagePath('detail', publicWorkCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPublicWorkStagePath(
|
||||||
|
stage: SelectionStage,
|
||||||
|
publicWorkCode: string,
|
||||||
|
) {
|
||||||
|
const code = publicWorkCode.trim();
|
||||||
|
const stagePath = resolvePathForSelectionStage(stage);
|
||||||
|
if (!code) {
|
||||||
|
return stagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set(PUBLIC_WORK_QUERY_PARAM, code);
|
||||||
|
return `${stagePath}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPublicWorkDetailUrl(
|
||||||
|
publicWorkCode: string,
|
||||||
|
origin = window.location.origin,
|
||||||
|
) {
|
||||||
|
return new URL(buildPublicWorkDetailPath(publicWorkCode), origin).href;
|
||||||
|
}
|
||||||
|
|
||||||
export function isKnownMainAppPagePath(pathname: string) {
|
export function isKnownMainAppPagePath(pathname: string) {
|
||||||
const normalizedPath = normalizeAppPath(pathname);
|
const normalizedPath = normalizeAppPath(pathname);
|
||||||
const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES);
|
const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES);
|
||||||
@@ -59,11 +92,14 @@ export function isKnownMainAppPagePath(pathname: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pushAppHistoryPath(path: string) {
|
export function pushAppHistoryPath(path: string) {
|
||||||
const normalizedPath = normalizeAppPath(path);
|
const nextUrl = new URL(path, window.location.origin);
|
||||||
if (normalizeAppPath(window.location.pathname) === normalizedPath) {
|
const normalizedPath = normalizeAppPath(nextUrl.pathname);
|
||||||
|
const nextRelativeUrl = `${normalizedPath}${nextUrl.search}`;
|
||||||
|
const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`;
|
||||||
|
if (currentRelativeUrl === nextRelativeUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
|
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
|
||||||
window.history.pushState(null, '', normalizedPath);
|
window.history.pushState(null, '', nextRelativeUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user