diff --git a/.env.example b/.env.example index 1f06973c..879be395 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,8 @@ AUTH_REFRESH_COOKIE_SAME_SITE="Lax" AUTH_REFRESH_COOKIE_SECURE="false" # Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。 GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json" +# 开发期便捷开关:true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。 +GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false" # 手机号验证码登录配置(阿里云 PNVS)。 # 正式环境请改成你自己的 AccessKey 和短信签名/模板。 diff --git a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md index 6f45b1f1..fdfb33ea 100644 --- a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md +++ b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md @@ -1,6 +1,8 @@ # 密码登录入口历史落地设计 > 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。 +> +> 2026-04-28 更新:为开发期本地/测试服联调新增服务端环境变量 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED`,默认 `false`。仅当该变量显式为 `true` 时,`POST /api/auth/entry` 可对未知手机号用本次密码直接创建账号并登录;默认关闭时仍严格保持未知手机号返回 `401` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。 日期:`2026-04-21` @@ -166,6 +168,13 @@ 2. 不创建账号。 3. 不写 `password_hash`。 +开发期例外: + +1. 当 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 时,未知手机号会创建手机号账号。 +2. 新账号立即写入本次密码的 `password_hash`,并将 `password_login_enabled` 置为 `true`。 +3. 成功响应沿用密码登录响应体,`created` 只保留在领域结果中,不额外暴露到当前 HTTP contract。 +4. 手机号格式和密码长度校验仍完全沿用正式密码入口规则。 + ### 8.2 未设置密码 当账号存在但 `password_login_enabled = false` 时: @@ -233,6 +242,8 @@ 4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`。 5. 登录成功时返回 access token。 6. 登录成功时写回 refresh cookie。 +7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。 +8. 开关开启时,未知手机号可通过 `/api/auth/entry` 创建账号并登录;同手机号后续用相同密码登录复用同一用户,错误密码仍返回 `401`。 ## 13. 完成定义 diff --git a/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md new file mode 100644 index 00000000..02c593b3 --- /dev/null +++ b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md @@ -0,0 +1,131 @@ +# 资料兑换码模块落地设计 + +## 1. 目标 + +本轮在现有“我的”资料与钱包 projection 上新增兑换码能力。用户兑换成功后直接增加叙世币余额,写入 `profile_wallet_ledger`,并同步刷新 `profile_dashboard_state.wallet_balance`。 + +管理侧本轮只提供后端 API,不新增管理后台页面。私有兑换码创建时支持内部 `userId` 与公开叙世号两类输入,后端创建阶段统一解析成内部 `userId` 存储。 + +## 2. 兑换码类型 + +`RuntimeProfileRedeemCodeMode` 固定为三种: + +| 类型 | 规则 | +| --- | --- | +| `Public` | 任意用户可兑换,`max_uses` 按用户独立计算。 | +| `Unique` | 任意用户可兑换,`max_uses` 全局共用。 | +| `Private` | 仅 `allowed_user_ids` 中的用户可兑换,`max_uses` 全局共用。 | + +兑换码入库前必须 `trim + uppercase`。空兑换码、奖励为 0、次数为 0 均拒绝。 + +## 3. 表结构 + +### 3.1 `profile_redeem_code` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `code` | `String` | 主键,标准化后的兑换码。 | +| `mode` | `RuntimeProfileRedeemCodeMode` | 兑换码模式。 | +| `reward_points` | `u64` | 单次到账叙世币。 | +| `max_uses` | `u32` | 公共码为单用户上限,唯一码/私有码为全局上限。 | +| `global_used_count` | `u32` | 全局已使用次数。公共码也记录总使用次数,但不参与公共码上限判断。 | +| `enabled` | `bool` | 是否启用。 | +| `allowed_user_ids` | `Vec` | 私有码允许用户;公共/唯一码存空数组。 | +| `created_by` | `String` | 管理员用户 ID。 | +| `created_at` | `Timestamp` | 创建时间。 | +| `updated_at` | `Timestamp` | 更新时间。 | + +### 3.2 `profile_redeem_code_usage` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `usage_id` | `String` | 主键,格式 `redeem:{code}:{user_id}:{micros}:{sequence}`。 | +| `code` | `String` | 兑换码。 | +| `user_id` | `String` | 兑换用户。 | +| `amount_granted` | `u64` | 到账叙世币。 | +| `created_at` | `Timestamp` | 兑换时间。 | + +索引:`code`、`user_id`、`(code, user_id)`。 + +## 4. SpacetimeDB 过程 + +### 4.1 用户兑换 + +`redeem_profile_reward_code(input: RuntimeProfileRewardCodeRedeemInput) -> RuntimeProfileRewardCodeRedeemProcedureResult` + +流程: + +1. 标准化 code。 +2. 校验兑换码存在、启用、奖励大于 0。 +3. 按模式校验使用范围与次数。 +4. 同一事务内写入 `profile_redeem_code_usage`、增加钱包余额、写入 `profile_wallet_ledger`,最后更新 `profile_redeem_code.global_used_count`。 +5. 返回 `walletBalance`、`amountGranted` 与本次 `ledgerEntry`。 + +### 4.2 管理创建/更新 + +`admin_upsert_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminUpsertInput) -> RuntimeProfileRedeemCodeAdminProcedureResult` + +私有码必须至少解析出一个内部用户 ID。公共码与唯一码忽略 allowed 列表并存空数组。 + +### 4.3 管理停用 + +`admin_disable_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminDisableInput) -> RuntimeProfileRedeemCodeAdminProcedureResult` + +只更新 `enabled=false` 与 `updated_at`,不存在时返回“兑换码不存在”。 + +## 5. Axum API + +用户接口: + +- `POST /api/profile/redeem-codes/redeem` +- `POST /api/runtime/profile/redeem-codes/redeem` + +请求:`{ "code": "WELCOME2026" }` + +成功返回: + +```json +{ + "walletBalance": 130, + "amountGranted": 100, + "ledgerEntry": { + "id": "redeem:WELCOME2026:user:1777392000000000:0", + "amountDelta": 100, + "balanceAfter": 130, + "sourceType": "redeem_code_reward", + "createdAt": "2026-04-28T00:00:00Z" + } +} +``` + +管理员接口: + +- `POST /admin/api/profile/redeem-codes` +- `POST /admin/api/profile/redeem-codes/disable` + +管理员接口复用现有 `require_admin_auth`。 + +## 6. 错误文案 + +| 场景 | message | +| --- | --- | +| 空 code | `兑换码不能为空` | +| 不存在 | `兑换码不存在` | +| 停用 | `兑换码已停用` | +| 奖励为 0 | `兑换码奖励无效` | +| 次数耗尽 | `兑换次数已用完` | +| 私有码账号不匹配 | `该兑换码不适用于当前账号` | +| 私有码无允许用户 | `私有兑换码必须指定可兑换用户` | + +## 7. 前端交互 + +“我的”页头像右侧入口由 `会员充值` 改为 `兑换码`。点击打开独立模态窗口,窗口内只保留输入框、兑换按钮和后端返回提示,不展示兑换规则说明。 + +成功后展示 `已到账 X 叙世币`,并刷新 profile dashboard。失败后直接展示后端 `message`。 + +## 8. 测试矩阵 + +- Rust/module-runtime:覆盖公共码、唯一码、私有码、失败场景、流水来源和余额累加。 +- Axum:覆盖用户鉴权、管理员鉴权、runtime error 到 400 的映射和兼容路径。 +- 前端:覆盖入口替换、独立 modal、成功刷新余额和失败展示后端 message。 +- 验证命令:`cargo test`、目标前端测试、`npm run api-server:maincloud`、`npm run check:encoding`。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index bbd7f5c3..edbb6008 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -23,7 +23,7 @@ spacetime sql "SELECT * FROM custom_world_gallery_entry" | 领域 | 表 | | --- | --- | | 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | -| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_played_world`, `profile_save_archive` | +| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_played_world`, `profile_save_archive` | | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` | @@ -133,6 +133,27 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = ''; SELECT * FROM profile_wallet_ledger WHERE user_id = '' ORDER BY created_at DESC; ``` +### `profile_redeem_code` + +- 作用:运营发放的叙世币兑换码,支持公共码、唯一码和私有码。 +- 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`。 +- 索引:主键 `code`。 + +```sql +SELECT * FROM profile_redeem_code WHERE code = ''; +``` + +### `profile_redeem_code_usage` + +- 作用:记录每一次兑换行为,为公共码用户维度计次、唯一/私有码全局计次提供依据。 +- 结构:`usage_id PK: String`, `code: String`, `user_id: String`, `amount_granted: u64`, `created_at: Timestamp`。 +- 索引:`code`, `user_id`, `(code, user_id)`。 + +```sql +SELECT * FROM profile_redeem_code_usage WHERE code = ''; +SELECT * FROM profile_redeem_code_usage WHERE user_id = ''; +``` + ### `profile_played_world` - 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e11beb06..6345b70b 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -57,7 +57,8 @@ export type ProfileWalletLedgerEntry = { | 'invite_invitee_reward' | 'points_recharge' | 'asset_generation_consume' - | 'asset_generation_refund'; + | 'asset_generation_refund' + | 'redeem_code_reward'; createdAt: string; }; @@ -159,6 +160,16 @@ export type RedeemProfileReferralInviteCodeResponse = { inviterBalanceAfter: number; }; +export type RedeemProfileRewardCodeRequest = { + code: string; +}; + +export type RedeemProfileRewardCodeResponse = { + walletBalance: number; + amountGranted: number; + ledgerEntry: ProfileWalletLedgerEntry; +}; + export type ProfilePlayedWorkSummary = { worldKey: string; ownerUserId: string | null; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 28008d2f..9a37e1da 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -94,9 +94,10 @@ use crate::{ runtime_chat::stream_runtime_npc_chat_turn, runtime_inventory::get_runtime_inventory_state, runtime_profile::{ + admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger, - redeem_profile_referral_invite_code, + redeem_profile_referral_invite_code, redeem_profile_reward_code, }, runtime_save::{ delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, @@ -143,6 +144,20 @@ pub fn build_router(state: AppState) -> Router { require_admin_auth, )), ) + .route( + "/admin/api/profile/redeem-codes", + post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/profile/redeem-codes/disable", + post(admin_disable_profile_redeem_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/healthz", get(|Extension(request_context): Extension<_>| async move { @@ -851,6 +866,20 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/profile/redeem-codes/redeem", + post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/redeem-codes/redeem", + post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/profile/play-stats", get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( @@ -1357,6 +1386,36 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn password_entry_dev_auto_register_creates_unknown_phone_when_enabled() { + let config = AppConfig { + dev_password_entry_auto_register_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let first_response = + password_login_request(app.clone(), "13800138023", TEST_PASSWORD).await; + let first_status = first_response.status(); + let first_body = first_response + .into_body() + .collect() + .await + .expect("first response body should collect") + .to_bytes(); + let first_payload: Value = + serde_json::from_slice(&first_body).expect("first response body should be valid json"); + let second_response = password_login_request(app, "13800138023", TEST_PASSWORD).await; + + assert_eq!(first_status, StatusCode::OK); + assert!(first_payload["token"].as_str().is_some()); + assert_eq!( + first_payload["user"]["loginMethod"], + Value::String("password".to_string()) + ); + assert_eq!(second_response.status(), StatusCode::OK); + } + #[tokio::test] async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index e2c497d5..deb69712 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -29,6 +29,7 @@ pub struct AppConfig { pub refresh_cookie_same_site: String, pub refresh_session_ttl_days: u32, pub auth_store_path: PathBuf, + pub dev_password_entry_auto_register_enabled: bool, pub sms_auth_enabled: bool, pub sms_auth_provider: String, pub sms_endpoint: String, @@ -118,6 +119,7 @@ impl Default for AppConfig { refresh_cookie_same_site: "Lax".to_string(), refresh_session_ttl_days: 30, auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH), + dev_password_entry_auto_register_enabled: false, sms_auth_enabled: false, sms_auth_provider: "mock".to_string(), sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), @@ -273,6 +275,11 @@ impl AppConfig { if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) { config.auth_store_path = PathBuf::from(auth_store_path); } + if let Some(enabled) = + read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"]) + { + config.dev_password_entry_auto_register_enabled = enabled; + } if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) { config.sms_auth_enabled = sms_auth_enabled; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 743adcf3..52bbd3a3 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -26,14 +26,19 @@ pub async fn password_entry( headers: HeaderMap, Json(payload): Json, ) -> Result { - let result = state - .password_entry_service() - .execute(PasswordEntryInput { - phone_number: payload.phone, - password: payload.password, - }) - .await - .map_err(map_password_entry_error)?; + let input = PasswordEntryInput { + phone_number: payload.phone, + password: payload.password, + }; + let result = if state.config.dev_password_entry_auto_register_enabled { + state + .password_entry_service() + .execute_with_dev_registration(input) + .await + } else { + state.password_entry_service().execute(input).await + } + .map_err(map_password_entry_error)?; let session_client = resolve_session_client_context(&headers); let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; state diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 2efe3856..35034922 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -7,30 +7,36 @@ use axum::{ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, - RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, + RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, + RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, + RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, + RuntimeReferralRedeemRecord, }; use serde_json::{Value, json}; use shared_contracts::runtime::{ + AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, - ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse, - ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, - RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, + ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, + ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse, + ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, + RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, + RedeemProfileRewardCodeResponse, }; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, + admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken, + http_error::AppError, request_context::RequestContext, state::AppState, }; pub async fn get_profile_dashboard( @@ -118,6 +124,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND } + RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD + } } } @@ -228,6 +237,99 @@ pub async fn redeem_profile_referral_invite_code( )) } +pub async fn redeem_profile_reward_code( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let redeemed_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .redeem_profile_reward_code(user_id, payload.code, redeemed_at_micros as i64) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_redeem_profile_reward_code_response(record), + )) +} + +pub async fn admin_upsert_profile_redeem_code( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let mode = parse_profile_redeem_code_mode(&payload.mode).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let record = state + .spacetime_client() + .admin_upsert_profile_redeem_code( + admin.session().subject.clone(), + payload.code, + mode, + payload.reward_points, + payload.max_uses, + payload.enabled, + payload.allowed_user_ids, + payload.allowed_public_user_codes, + updated_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_profile_redeem_code_admin_response(record), + )) +} + +pub async fn admin_disable_profile_redeem_code( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .admin_disable_profile_redeem_code( + admin.session().subject.clone(), + payload.code, + updated_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_profile_redeem_code_admin_response(record), + )) +} + pub async fn get_profile_play_stats( State(state): State, Extension(request_context): Extension, @@ -396,6 +498,49 @@ fn build_redeem_profile_referral_invite_code_response( } } +fn build_redeem_profile_reward_code_response( + record: RuntimeProfileRewardCodeRedeemRecord, +) -> RedeemProfileRewardCodeResponse { + RedeemProfileRewardCodeResponse { + wallet_balance: record.wallet_balance, + amount_granted: record.amount_granted, + ledger_entry: ProfileWalletLedgerEntryResponse { + id: record.ledger_entry.wallet_ledger_id, + amount_delta: record.ledger_entry.amount_delta, + balance_after: record.ledger_entry.balance_after, + source_type: format_profile_wallet_ledger_source_type(record.ledger_entry.source_type) + .to_string(), + created_at: record.ledger_entry.created_at, + }, + } +} + +fn parse_profile_redeem_code_mode(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "public" => Ok(RuntimeProfileRedeemCodeMode::Public), + "unique" => Ok(RuntimeProfileRedeemCodeMode::Unique), + "private" => Ok(RuntimeProfileRedeemCodeMode::Private), + _ => Err("兑换码类型无效".to_string()), + } +} + +fn build_profile_redeem_code_admin_response( + record: RuntimeProfileRedeemCodeRecord, +) -> ProfileRedeemCodeAdminResponse { + ProfileRedeemCodeAdminResponse { + code: record.code, + mode: record.mode.as_str().to_string(), + reward_points: record.reward_points, + max_uses: record.max_uses, + global_used_count: record.global_used_count, + enabled: record.enabled, + allowed_user_ids: record.allowed_user_ids, + created_by: record.created_by, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + #[cfg(test)] mod tests { use module_runtime::RuntimeProfileWalletLedgerSourceType; diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 1952d725..57005c54 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -486,6 +486,38 @@ impl PasswordEntryService { verify_stored_password_user(existing_user, &input.password).await } + pub async fn execute_with_dev_registration( + &self, + input: PasswordEntryInput, + ) -> Result { + validate_password(&input.password)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number) + .map_err(|_| PasswordEntryError::InvalidPhoneNumber)?; + if let Some(existing_user) = self + .store + .find_by_phone_number_for_password(&normalized_phone.e164)? + { + return verify_stored_password_user(existing_user, &input.password).await; + } + + let password_hash = hash_password(&input.password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + let user = self.store.create_dev_password_phone_user( + normalized_phone.clone(), + normalized_phone.masked_national_number, + password_hash, + )?; + + Ok(PasswordEntryResult { + user: AuthUser { + login_method: AuthLoginMethod::Password, + ..user + }, + created: true, + }) + } + pub fn get_user_by_id( &self, user_id: &str, @@ -1336,6 +1368,53 @@ impl InMemoryAuthStore { Ok(user) } + fn create_dev_password_phone_user( + &self, + phone_number: PhoneNumberSnapshot, + display_name: String, + password_hash: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + if state.phone_to_user_id.contains_key(&phone_number.e164) { + return Err(PasswordEntryError::InvalidCredentials); + } + + let sequence = state.next_user_id; + let user_id = format!("user_{sequence:08}"); + let public_user_code = build_public_user_code(sequence); + state.next_user_id += 1; + let username = build_system_username("phone", state.next_user_id); + let user = AuthUser { + id: user_id.clone(), + public_user_code, + username: username.clone(), + display_name, + phone_number_masked: Some(phone_number.masked_national_number.clone()), + login_method: AuthLoginMethod::Password, + binding_status: AuthBindingStatus::Active, + wechat_bound: false, + token_version: 1, + }; + state + .phone_to_user_id + .insert(phone_number.e164.clone(), user_id); + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash, + password_login_enabled: true, + phone_number: Some(phone_number.e164), + }, + ); + self.persist_password_state(&state)?; + + Ok(user) + } + fn create_pending_wechat_user( &self, profile: WechatIdentityProfile, @@ -2474,6 +2553,39 @@ mod tests { assert_eq!(error, PasswordEntryError::InvalidCredentials); } + #[tokio::test] + async fn password_entry_dev_registration_creates_unknown_phone_user() { + let service = build_password_service(build_store()); + + let created = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("dev registration should create user"); + let reused = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("same password should reuse created user"); + let wrong_password = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret999".to_string(), + }) + .await + .expect_err("existing user still requires the right password"); + + assert!(created.created); + assert_eq!(created.user.login_method, AuthLoginMethod::Password); + assert!(!reused.created); + assert_eq!(created.user.id, reused.user.id); + assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials); + } + #[tokio::test] async fn phone_user_can_set_password_then_login() { let store = build_store(); diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 60a4a02a..fdf6de87 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -261,6 +261,15 @@ pub enum RuntimeProfileWalletLedgerSourceType { PointsRecharge, AssetGenerationConsume, AssetGenerationRefund, + RedeemCodeReward, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileRedeemCodeMode { + Public, + Unique, + Private, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput { pub created_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRewardCodeRedeemInput { + pub user_id: String, + pub code: String, + pub redeemed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRewardCodeRedeemSnapshot { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRewardCodeRedeemProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeAdminUpsertInput { + pub admin_user_id: String, + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub allowed_public_user_codes: Vec, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeAdminDisableInput { + pub admin_user_id: String, + pub code: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeSnapshot { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralInviteCenterSnapshot { @@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError { MissingLedgerId, InvalidWalletAmount, MissingInviteCode, + MissingRedeemCode, + InvalidRedeemCodeReward, + InvalidRedeemCodeMaxUses, MissingProductId, MissingWorldKey, MissingBottomTab, @@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord { pub has_points_recharged: bool, } +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileRewardCodeRedeemRecord { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileRedeemCodeRecord { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at: String, + pub created_at_micros: i64, + pub updated_at: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq)] pub struct RuntimeReferralInviteCenterRecord { pub user_id: String, @@ -970,6 +1074,73 @@ pub fn build_runtime_referral_redeem_input( }) } +pub fn build_runtime_profile_reward_code_redeem_input( + user_id: String, + code: String, + redeemed_at_micros: i64, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; + Ok(RuntimeProfileRewardCodeRedeemInput { + user_id, + code, + redeemed_at_micros, + }) +} + +pub fn build_runtime_profile_redeem_code_admin_upsert_input( + admin_user_id: String, + code: String, + mode: RuntimeProfileRedeemCodeMode, + reward_points: u64, + max_uses: u32, + enabled: bool, + allowed_user_ids: Vec, + allowed_public_user_codes: Vec, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; + if reward_points == 0 { + return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward); + } + if max_uses == 0 { + return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses); + } + + Ok(RuntimeProfileRedeemCodeAdminUpsertInput { + admin_user_id, + code, + mode, + reward_points, + max_uses, + enabled, + allowed_user_ids: allowed_user_ids + .into_iter() + .filter_map(|value| normalize_optional_string(Some(value))) + .collect(), + allowed_public_user_codes: allowed_public_user_codes + .into_iter() + .filter_map(|value| normalize_optional_string(Some(value))) + .collect(), + updated_at_micros, + }) +} + +pub fn build_runtime_profile_redeem_code_admin_disable_input( + admin_user_id: String, + code: String, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; + Ok(RuntimeProfileRedeemCodeAdminDisableInput { + admin_user_id, + code, + updated_at_micros, + }) +} + pub fn build_runtime_profile_play_stats_get_input( user_id: String, ) -> Result { @@ -1323,6 +1494,35 @@ pub fn build_runtime_referral_redeem_record( } } +pub fn build_runtime_profile_reward_code_redeem_record( + snapshot: RuntimeProfileRewardCodeRedeemSnapshot, +) -> RuntimeProfileRewardCodeRedeemRecord { + RuntimeProfileRewardCodeRedeemRecord { + wallet_balance: snapshot.wallet_balance, + amount_granted: snapshot.amount_granted, + ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry), + } +} + +pub fn build_runtime_profile_redeem_code_record( + snapshot: RuntimeProfileRedeemCodeSnapshot, +) -> RuntimeProfileRedeemCodeRecord { + RuntimeProfileRedeemCodeRecord { + code: snapshot.code, + mode: snapshot.mode, + reward_points: snapshot.reward_points, + max_uses: snapshot.max_uses, + global_used_count: snapshot.global_used_count, + enabled: snapshot.enabled, + allowed_user_ids: snapshot.allowed_user_ids, + created_by: snapshot.created_by, + created_at: format_utc_micros(snapshot.created_at_micros), + created_at_micros: snapshot.created_at_micros, + updated_at: format_utc_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, + } +} + pub fn build_runtime_profile_played_world_record( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> RuntimeProfilePlayedWorldRecord { @@ -1508,6 +1708,17 @@ impl RuntimeProfileWalletLedgerSourceType { Self::PointsRecharge => "points_recharge", Self::AssetGenerationConsume => "asset_generation_consume", Self::AssetGenerationRefund => "asset_generation_refund", + Self::RedeemCodeReward => "redeem_code_reward", + } + } +} + +impl RuntimeProfileRedeemCodeMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Public => "public", + Self::Unique => "unique", + Self::Private => "private", } } } @@ -1736,6 +1947,10 @@ pub fn normalize_invite_code(value: String) -> Option { } } +pub fn normalize_redeem_code(value: String) -> Option { + normalize_invite_code(value) +} + impl std::fmt::Display for RuntimeProfileFieldError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { 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::MissingRedeemCode => f.write_str("兑换码不能为空"), + Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"), + Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 57d5671d..6a89d016 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -11,6 +11,7 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str = "asset_generation_consume"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str = "asset_generation_refund"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; @@ -258,6 +259,60 @@ pub struct RedeemProfileReferralInviteCodeResponse { pub inviter_balance_after: u64, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RedeemProfileRewardCodeRequest { + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RedeemProfileRewardCodeResponse { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: ProfileWalletLedgerEntryResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertProfileRedeemCodeRequest { + pub code: String, + pub mode: String, + pub reward_points: u64, + pub max_uses: u32, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub allowed_user_ids: Vec, + #[serde(default)] + pub allowed_public_user_codes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDisableProfileRedeemCodeRequest { + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileRedeemCodeAdminResponse { + pub code: String, + pub mode: String, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at: String, + pub updated_at: String, +} + +fn default_true() -> bool { + true +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfilePlayedWorkSummaryResponse { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index fea844c4..cfbe290e 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -120,6 +120,8 @@ use module_runtime::{ RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, + RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, @@ -129,8 +131,12 @@ use module_runtime::{ build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_create_input, - build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record, - build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input, + build_runtime_profile_redeem_code_admin_disable_input, + build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, + build_runtime_profile_reward_code_redeem_input, + build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input, + build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input, + build_runtime_profile_wallet_adjustment_input, build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 92e78bbb..b04457ee 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -161,6 +161,48 @@ impl From } } +impl From + for RuntimeProfileRewardCodeRedeemInput +{ + fn from(input: module_runtime::RuntimeProfileRewardCodeRedeemInput) -> Self { + Self { + user_id: input.user_id, + code: input.code, + redeemed_at_micros: input.redeemed_at_micros, + } + } +} + +impl From + for RuntimeProfileRedeemCodeAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + code: input.code, + mode: map_runtime_profile_redeem_code_mode(input.mode), + reward_points: input.reward_points, + max_uses: input.max_uses, + enabled: input.enabled, + allowed_user_ids: input.allowed_user_ids, + allowed_public_user_codes: input.allowed_public_user_codes, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for RuntimeProfileRedeemCodeAdminDisableInput +{ + fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminDisableInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + code: input.code, + updated_at_micros: input.updated_at_micros, + } + } +} + impl From for RuntimeReferralInviteCenterGetInput { @@ -802,6 +844,48 @@ pub(crate) fn map_runtime_referral_redeem_procedure_result( )) } +pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result( + result: RuntimeProfileRewardCodeRedeemProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 reward redeem 快照".to_string(), + ) + })?; + + Ok(build_runtime_profile_reward_code_redeem_record( + map_runtime_profile_reward_code_redeem_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( + result: RuntimeProfileRedeemCodeAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 redeem code 快照".to_string()) + })?; + + Ok(build_runtime_profile_redeem_code_record( + map_runtime_profile_redeem_code_snapshot(snapshot), + )) +} + pub(crate) fn map_runtime_profile_play_stats_procedure_result( result: RuntimeProfilePlayStatsProcedureResult, ) -> Result { @@ -1673,6 +1757,33 @@ pub(crate) fn map_runtime_referral_redeem_snapshot( } } +pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot( + snapshot: RuntimeProfileRewardCodeRedeemSnapshot, +) -> module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { + module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { + wallet_balance: snapshot.wallet_balance, + amount_granted: snapshot.amount_granted, + ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry), + } +} + +pub(crate) fn map_runtime_profile_redeem_code_snapshot( + snapshot: RuntimeProfileRedeemCodeSnapshot, +) -> module_runtime::RuntimeProfileRedeemCodeSnapshot { + module_runtime::RuntimeProfileRedeemCodeSnapshot { + code: snapshot.code, + mode: map_runtime_profile_redeem_code_mode_back(snapshot.mode), + reward_points: snapshot.reward_points, + max_uses: snapshot.max_uses, + global_used_count: snapshot.global_used_count, + enabled: snapshot.enabled, + allowed_user_ids: snapshot.allowed_user_ids, + created_by: snapshot.created_by, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + pub(crate) fn map_runtime_profile_played_world_snapshot( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { @@ -3284,6 +3395,41 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { + module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward + } + } +} + +pub(crate) fn map_runtime_profile_redeem_code_mode( + value: module_runtime::RuntimeProfileRedeemCodeMode, +) -> crate::module_bindings::RuntimeProfileRedeemCodeMode { + match value { + module_runtime::RuntimeProfileRedeemCodeMode::Public => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Public + } + module_runtime::RuntimeProfileRedeemCodeMode::Unique => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique + } + module_runtime::RuntimeProfileRedeemCodeMode::Private => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Private + } + } +} + +pub(crate) fn map_runtime_profile_redeem_code_mode_back( + value: crate::module_bindings::RuntimeProfileRedeemCodeMode, +) -> module_runtime::RuntimeProfileRedeemCodeMode { + match value { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Public => { + module_runtime::RuntimeProfileRedeemCodeMode::Public + } + crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique => { + module_runtime::RuntimeProfileRedeemCodeMode::Unique + } + crate::module_bindings::RuntimeProfileRedeemCodeMode::Private => { + module_runtime::RuntimeProfileRedeemCodeMode::Private + } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs new file mode 100644 index 00000000..c254d1f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs @@ -0,0 +1,53 @@ +// 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_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +use super::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminDisableProfileRedeemCodeArgs { + pub input: RuntimeProfileRedeemCodeAdminDisableInput, +} + +impl __sdk::InModule for AdminDisableProfileRedeemCodeArgs { + type Module = super::RemoteModule; +} + +pub trait admin_disable_profile_redeem_code { + fn admin_disable_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminDisableInput) { + self.admin_disable_profile_redeem_code_then(input, |_, _| {}); + } + + fn admin_disable_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminDisableInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_disable_profile_redeem_code for super::RemoteProcedures { + fn admin_disable_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminDisableInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( + "admin_disable_profile_redeem_code", + AdminDisableProfileRedeemCodeArgs { input }, + callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs new file mode 100644 index 00000000..cafe2382 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs @@ -0,0 +1,53 @@ +// 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_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; +use super::runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminUpsertProfileRedeemCodeArgs { + pub input: RuntimeProfileRedeemCodeAdminUpsertInput, +} + +impl __sdk::InModule for AdminUpsertProfileRedeemCodeArgs { + type Module = super::RemoteModule; +} + +pub trait admin_upsert_profile_redeem_code { + fn admin_upsert_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminUpsertInput) { + self.admin_upsert_profile_redeem_code_then(input, |_, _| {}); + } + + fn admin_upsert_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminUpsertInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_upsert_profile_redeem_code for super::RemoteProcedures { + fn admin_upsert_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminUpsertInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( + "admin_upsert_profile_redeem_code", + AdminUpsertProfileRedeemCodeArgs { input }, + callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 386e98f4..1a028014 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -251,6 +251,9 @@ pub mod list_custom_world_works_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; +pub mod redeem_profile_reward_code_procedure; +pub mod admin_upsert_profile_redeem_code_procedure; +pub mod admin_disable_profile_redeem_code_procedure; pub mod list_puzzle_gallery_procedure; pub mod list_puzzle_works_procedure; pub mod npc_battle_interaction_procedure_result_type; @@ -415,6 +418,14 @@ pub mod runtime_profile_wallet_ledger_entry_snapshot_type; pub mod runtime_profile_wallet_ledger_list_input_type; pub mod runtime_profile_wallet_ledger_procedure_result_type; pub mod runtime_profile_wallet_ledger_source_type_type; +pub mod runtime_profile_redeem_code_mode_type; +pub mod runtime_profile_reward_code_redeem_input_type; +pub mod runtime_profile_reward_code_redeem_snapshot_type; +pub mod runtime_profile_reward_code_redeem_procedure_result_type; +pub mod runtime_profile_redeem_code_admin_upsert_input_type; +pub mod runtime_profile_redeem_code_admin_disable_input_type; +pub mod runtime_profile_redeem_code_snapshot_type; +pub mod runtime_profile_redeem_code_admin_procedure_result_type; pub mod runtime_referral_invite_center_get_input_type; pub mod runtime_referral_invite_center_procedure_result_type; pub mod runtime_referral_invite_center_snapshot_type; @@ -722,6 +733,9 @@ pub use list_custom_world_works_procedure::list_custom_world_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; pub use list_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; +pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; +pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; +pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use list_puzzle_gallery_procedure::list_puzzle_gallery; pub use list_puzzle_works_procedure::list_puzzle_works; pub use npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult; @@ -886,6 +900,14 @@ pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletL 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_source_type_type::RuntimeProfileWalletLedgerSourceType; +pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; +pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; +pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; +pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; +pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; +pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; +pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput; pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult; pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs new file mode 100644 index 00000000..5f5e7400 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs @@ -0,0 +1,53 @@ +// 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_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; +use super::runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RedeemProfileRewardCodeArgs { + pub input: RuntimeProfileRewardCodeRedeemInput, +} + +impl __sdk::InModule for RedeemProfileRewardCodeArgs { + type Module = super::RemoteModule; +} + +pub trait redeem_profile_reward_code { + fn redeem_profile_reward_code(&self, input: RuntimeProfileRewardCodeRedeemInput) { + self.redeem_profile_reward_code_then(input, |_, _| {}); + } + + fn redeem_profile_reward_code_then( + &self, + input: RuntimeProfileRewardCodeRedeemInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl redeem_profile_reward_code for super::RemoteProcedures { + fn redeem_profile_reward_code_then( + &self, + input: RuntimeProfileRewardCodeRedeemInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRewardCodeRedeemProcedureResult>( + "redeem_profile_reward_code", + RedeemProfileRewardCodeArgs { input }, + callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs new file mode 100644 index 00000000..5a7ed897 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs @@ -0,0 +1,17 @@ +// 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 RuntimeProfileRedeemCodeAdminDisableInput { + pub admin_user_id: String, + pub code: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminDisableInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs new file mode 100644 index 00000000..62254ff9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs new file mode 100644 index 00000000..5f18a875 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs @@ -0,0 +1,25 @@ +// 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_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeAdminUpsertInput { + pub admin_user_id: String, + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub allowed_public_user_codes: Vec, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs new file mode 100644 index 00000000..4bea6d79 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs @@ -0,0 +1,20 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRedeemCodeMode { + Public, + + Unique, + + Private, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeMode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs new file mode 100644 index 00000000..aea09f25 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs @@ -0,0 +1,26 @@ +// 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_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeSnapshot { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs new file mode 100644 index 00000000..e99bc781 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs @@ -0,0 +1,17 @@ +// 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 RuntimeProfileRewardCodeRedeemInput { + pub user_id: String, + pub code: String, + pub redeemed_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs new file mode 100644 index 00000000..dd8936d7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRewardCodeRedeemProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs new file mode 100644 index 00000000..614e5d78 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRewardCodeRedeemSnapshot { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index fc2093e3..3697b09f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -19,6 +19,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { AssetGenerationConsume, AssetGenerationRefund, + + RedeemCodeReward, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 67560b74..f95407cf 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -255,6 +255,97 @@ impl SpacetimeClient { .await } + pub async fn redeem_profile_reward_code( + &self, + user_id: String, + code: String, + redeemed_at_micros: i64, + ) -> Result { + let procedure_input = + build_runtime_profile_reward_code_redeem_input(user_id, code, redeemed_at_micros) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection.procedures().redeem_profile_reward_code_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_reward_code_redeem_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn admin_upsert_profile_redeem_code( + &self, + admin_user_id: String, + code: String, + mode: DomainRuntimeProfileRedeemCodeMode, + reward_points: u64, + max_uses: u32, + enabled: bool, + allowed_user_ids: Vec, + allowed_public_user_codes: Vec, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_redeem_code_admin_upsert_input( + admin_user_id, + code, + mode, + reward_points, + max_uses, + enabled, + allowed_user_ids, + allowed_public_user_codes, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_redeem_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_redeem_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn admin_disable_profile_redeem_code( + &self, + admin_user_id: String, + code: String, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_redeem_code_admin_disable_input( + admin_user_id, + code, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .admin_disable_profile_redeem_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_redeem_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn get_profile_play_stats( &self, user_id: String, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index f2f6be5b..0ba811af 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -109,6 +109,8 @@ macro_rules! migration_tables { user_browse_history, profile_dashboard_state, profile_wallet_ledger, + profile_redeem_code, + profile_redeem_code_usage, profile_invite_code, profile_referral_relation, profile_played_world, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 09ca0cc7..fd33a9c3 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -28,6 +28,39 @@ pub struct ProfileWalletLedger { pub(crate) created_at: Timestamp, } +#[spacetimedb::table(accessor = profile_redeem_code)] +pub struct ProfileRedeemCode { + #[primary_key] + pub(crate) code: String, + pub(crate) mode: RuntimeProfileRedeemCodeMode, + pub(crate) reward_points: u64, + pub(crate) max_uses: u32, + pub(crate) global_used_count: u32, + pub(crate) enabled: bool, + pub(crate) allowed_user_ids: Vec, + pub(crate) created_by: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = profile_redeem_code_usage, + index(accessor = by_profile_redeem_code_usage_code, btree(columns = [code])), + index(accessor = by_profile_redeem_code_usage_user_id, btree(columns = [user_id])), + index( + accessor = by_profile_redeem_code_usage_code_user_id, + btree(columns = [code, user_id]) + ) +)] +pub struct ProfileRedeemCodeUsage { + #[primary_key] + pub(crate) usage_id: String, + pub(crate) code: String, + pub(crate) user_id: String, + pub(crate) amount_granted: u64, + pub(crate) created_at: Timestamp, +} + #[spacetimedb::table(accessor = profile_invite_code)] pub struct ProfileInviteCode { #[primary_key] @@ -396,6 +429,64 @@ pub fn redeem_profile_referral_invite_code( } } +// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。 +#[spacetimedb::procedure] +pub fn redeem_profile_reward_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileRewardCodeRedeemInput, +) -> RuntimeProfileRewardCodeRedeemProcedureResult { + match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_upsert_profile_redeem_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileRedeemCodeAdminUpsertInput, +) -> RuntimeProfileRedeemCodeAdminProcedureResult { + match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_disable_profile_redeem_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileRedeemCodeAdminDisableInput, +) -> RuntimeProfileRedeemCodeAdminProcedureResult { + match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + pub(crate) fn list_profile_save_archive_rows( ctx: &ReducerContext, input: RuntimeProfileSaveArchiveListInput, @@ -1194,6 +1285,185 @@ fn redeem_profile_referral_invite_code_record( }) } +fn redeem_profile_reward_code_record( + ctx: &ReducerContext, + input: RuntimeProfileRewardCodeRedeemInput, +) -> Result { + let validated_input = build_runtime_profile_reward_code_redeem_input( + input.user_id, + input.code, + input.redeemed_at_micros, + ) + .map_err(|error| error.to_string())?; + let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros); + let user_id = validated_input.user_id; + let code = validated_input.code; + let redeem_code = ctx + .db + .profile_redeem_code() + .code() + .find(&code) + .ok_or_else(|| "兑换码不存在".to_string())?; + + if !redeem_code.enabled { + return Err("兑换码已停用".to_string()); + } + if redeem_code.reward_points == 0 { + return Err("兑换码奖励无效".to_string()); + } + + let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id); + match redeem_code.mode { + RuntimeProfileRedeemCodeMode::Public if user_used_count >= redeem_code.max_uses => { + return Err("兑换次数已用完".to_string()); + } + RuntimeProfileRedeemCodeMode::Unique + if redeem_code.global_used_count >= redeem_code.max_uses => + { + return Err("兑换次数已用完".to_string()); + } + RuntimeProfileRedeemCodeMode::Private => { + if !redeem_code + .allowed_user_ids + .iter() + .any(|item| item == &user_id) + { + return Err("该兑换码不适用于当前账号".to_string()); + } + if redeem_code.global_used_count >= redeem_code.max_uses { + return Err("兑换次数已用完".to_string()); + } + } + _ => {} + } + + let usage_id = build_profile_redeem_code_usage_id( + ctx, + &code, + &user_id, + validated_input.redeemed_at_micros, + ); + let wallet_ledger_id = format!("{}:ledger", usage_id); + let wallet_balance = apply_profile_wallet_delta( + ctx, + &user_id, + redeem_code.reward_points, + RuntimeProfileWalletLedgerSourceType::RedeemCodeReward, + &wallet_ledger_id, + redeemed_at, + )?; + + ctx.db + .profile_redeem_code_usage() + .insert(ProfileRedeemCodeUsage { + usage_id, + code: code.clone(), + user_id, + amount_granted: redeem_code.reward_points, + created_at: redeemed_at, + }); + + let next_code = ProfileRedeemCode { + global_used_count: redeem_code.global_used_count.saturating_add(1), + updated_at: redeemed_at, + ..redeem_code + }; + ctx.db.profile_redeem_code().code().delete(&code); + ctx.db.profile_redeem_code().insert(next_code); + + let ledger_entry = ctx + .db + .profile_wallet_ledger() + .wallet_ledger_id() + .find(&wallet_ledger_id) + .ok_or_else(|| "兑换码钱包流水写入失败".to_string())?; + + Ok(RuntimeProfileRewardCodeRedeemSnapshot { + wallet_balance, + amount_granted: ledger_entry.amount_delta.max(0) as u64, + ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry), + }) +} + +fn admin_upsert_profile_redeem_code_record( + ctx: &ReducerContext, + input: RuntimeProfileRedeemCodeAdminUpsertInput, +) -> Result { + let validated_input = build_runtime_profile_redeem_code_admin_upsert_input( + input.admin_user_id, + input.code, + input.mode, + input.reward_points, + input.max_uses, + input.enabled, + input.allowed_user_ids, + input.allowed_public_user_codes, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); + let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?; + let existing = ctx + .db + .profile_redeem_code() + .code() + .find(&validated_input.code); + let created_at = existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or(updated_at); + let global_used_count = existing + .as_ref() + .map(|row| row.global_used_count) + .unwrap_or(0); + + if let Some(existing) = existing { + ctx.db.profile_redeem_code().code().delete(&existing.code); + } + + let row = ProfileRedeemCode { + code: validated_input.code, + mode: validated_input.mode, + reward_points: validated_input.reward_points, + max_uses: validated_input.max_uses, + global_used_count, + enabled: validated_input.enabled, + allowed_user_ids, + created_by: validated_input.admin_user_id, + created_at, + updated_at, + }; + let inserted = ctx.db.profile_redeem_code().insert(row); + Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) +} + +fn admin_disable_profile_redeem_code_record( + ctx: &ReducerContext, + input: RuntimeProfileRedeemCodeAdminDisableInput, +) -> Result { + let validated_input = build_runtime_profile_redeem_code_admin_disable_input( + input.admin_user_id, + input.code, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); + let existing = ctx + .db + .profile_redeem_code() + .code() + .find(&validated_input.code) + .ok_or_else(|| "兑换码不存在".to_string())?; + + ctx.db.profile_redeem_code().code().delete(&existing.code); + let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode { + enabled: false, + updated_at, + ..existing + }); + Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) +} + fn build_profile_referral_invite_center_snapshot( ctx: &ReducerContext, user_id: &str, @@ -1579,6 +1849,74 @@ fn latest_profile_recharge_order( orders.into_iter().next() } +fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 { + ctx.db + .profile_redeem_code_usage() + .by_profile_redeem_code_usage_code_user_id() + .filter((code, user_id)) + .count() as u32 +} + +fn build_profile_redeem_code_usage_id( + ctx: &ReducerContext, + code: &str, + user_id: &str, + redeemed_at_micros: i64, +) -> String { + let sequence = count_profile_redeem_code_user_usage(ctx, code, user_id); + format!( + "redeem:{}:{}:{}:{}", + code, user_id, redeemed_at_micros, sequence + ) +} + +fn resolve_profile_redeem_code_allowed_user_ids( + ctx: &ReducerContext, + input: &RuntimeProfileRedeemCodeAdminUpsertInput, +) -> Result, String> { + if input.mode != RuntimeProfileRedeemCodeMode::Private { + return Ok(Vec::new()); + } + + let mut allowed_user_ids = input.allowed_user_ids.clone(); + for public_user_code in &input.allowed_public_user_codes { + if let Some(account) = ctx + .db + .user_account() + .by_user_account_public_code() + .filter(public_user_code) + .next() + { + allowed_user_ids.push(account.user_id); + } + } + allowed_user_ids.sort(); + allowed_user_ids.dedup(); + + if allowed_user_ids.is_empty() { + return Err("私有兑换码必须指定可兑换用户".to_string()); + } + + Ok(allowed_user_ids) +} + +fn build_profile_redeem_code_snapshot_from_row( + row: &ProfileRedeemCode, +) -> RuntimeProfileRedeemCodeSnapshot { + RuntimeProfileRedeemCodeSnapshot { + code: row.code.clone(), + mode: row.mode, + reward_points: row.reward_points, + max_uses: row.max_uses, + global_used_count: row.global_used_count, + enabled: row.enabled, + allowed_user_ids: row.allowed_user_ids.clone(), + created_by: row.created_by.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn build_profile_wallet_ledger_snapshot_from_row( row: &ProfileWalletLedger, ) -> RuntimeProfileWalletLedgerEntrySnapshot { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 5b9d370c..b2e5462e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -8,7 +8,6 @@ import { Clock3, Coins, Copy, - Crown, House, LogIn, MessageCircle, @@ -34,19 +33,17 @@ import type { PlatformBrowseHistoryEntry, ProfileDashboardCardKey, ProfileDashboardSummary, - ProfileRechargeCenterResponse, - ProfileRechargeProduct, ProfileReferralInviteCenterResponse, ProfileSaveArchiveSummary, RedeemProfileReferralInviteCodeResponse, + RedeemProfileRewardCodeResponse, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { AuthUser } from '../../services/authService'; import { - createRpgProfileRechargeOrder, - getRpgProfileRechargeCenter, getRpgProfileReferralInviteCenter, redeemRpgProfileReferralInviteCode, + redeemRpgProfileRewardCode, } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; @@ -910,206 +907,68 @@ function ProfileShortcutButton({ ); } -function formatRechargePrice(priceCents: number) { - const yuan = priceCents / 100; - return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`; -} - -function formatMembershipDuration(days: number) { - if (days >= 365) { - return '365天'; - } - - return `${days}天`; -} - -function AccountRechargeModal({ - center, - activeTab, - isLoading, +function RewardCodeRedeemModal({ + value, isSubmitting, error, - onTabChange, + success, + onChange, + onSubmit, onClose, - onSelectProduct, }: { - center: ProfileRechargeCenterResponse | null; - activeTab: 'points' | 'membership'; - isLoading: boolean; - isSubmitting: string | null; + value: string; + isSubmitting: boolean; error: string | null; - onTabChange: (tab: 'points' | 'membership') => void; + success: string | null; + onChange: (value: string) => void; + onSubmit: () => void; onClose: () => void; - onSelectProduct: (product: ProfileRechargeProduct) => void; }) { - const visibleProducts = - activeTab === 'points' - ? (center?.pointProducts ?? []) - : (center?.membershipProducts ?? []); - return ( -
-
- -
-
-
- WALLET -
-
账户充值
-
- - - {center ? `${center.walletBalance}叙世币` : '叙世币账户'} - -
-
- -
- - -
- +
+
+
+
兑换码
+ +
+
+ onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + onSubmit(); + } + }} + className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal" + placeholder="输入兑换码" + autoFocus + /> + {error ? ( -
+
{error}
) : null} - - {isLoading ? ( -
- {Array.from({ length: activeTab === 'points' ? 6 : 3 }).map( - (_, index) => ( -
- ), - )} + {success ? ( +
+ {success}
- ) : activeTab === 'points' ? ( -
- {visibleProducts.map((product) => ( - - ))} -
- ) : ( - <> -
- {visibleProducts.map((product) => ( - - ))} -
-
-
- 用户等级特权 -
-
-
- {center?.benefits.map((benefit) => ( -
-
- {benefit.benefitName} -
-
- {benefit.normalValue} -
-
- {benefit.monthValue} -
-
- {benefit.seasonValue} -
-
- {benefit.yearValue} -
-
- ))} -
-
-
- - )} + ) : null}
@@ -1294,16 +1153,13 @@ export function RpgEntryHomeView({ const authUi = useAuthUi(); const [desktopSearchKeyword, setDesktopSearchKeyword] = useState(''); const [mobileSearchKeyword, setMobileSearchKeyword] = useState(''); - const [isRechargeOpen, setIsRechargeOpen] = useState(false); - const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>( - 'points', + const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false); + const [rewardCodeInput, setRewardCodeInput] = useState(''); + const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false); + const [rewardCodeError, setRewardCodeError] = useState(null); + const [rewardCodeSuccess, setRewardCodeSuccess] = useState( + null, ); - const [rechargeCenter, setRechargeCenter] = - useState(null); - const [rechargeError, setRechargeError] = useState(null); - const [isLoadingRecharge, setIsLoadingRecharge] = useState(false); - const [submittingRechargeProductId, setSubmittingRechargeProductId] = - useState(null); const [profilePopupPanel, setProfilePopupPanel] = useState(null); const [referralCenter, setReferralCenter] = @@ -1401,36 +1257,6 @@ export function RpgEntryHomeView({ } authUi?.openLoginModal(); }; - const openRechargePanel = () => { - setIsRechargeOpen(true); - setRechargeError(null); - setIsLoadingRecharge(true); - void getRpgProfileRechargeCenter() - .then(setRechargeCenter) - .catch((error: unknown) => { - setRechargeCenter(null); - setRechargeError( - error instanceof Error ? error.message : '读取账户充值失败', - ); - }) - .finally(() => setIsLoadingRecharge(false)); - }; - const submitRechargeProduct = (product: ProfileRechargeProduct) => { - if (submittingRechargeProductId) { - return; - } - setSubmittingRechargeProductId(product.productId); - setRechargeError(null); - void createRpgProfileRechargeOrder(product.productId) - .then((response) => { - setRechargeCenter(response.center); - void onRechargeSuccess?.(); - }) - .catch((error: unknown) => { - setRechargeError(error instanceof Error ? error.message : '充值失败'); - }) - .finally(() => setSubmittingRechargeProductId(null)); - }; const openProfilePopupPanel = (panel: ProfilePopupPanel) => { setProfilePopupPanel(panel); setReferralError(null); @@ -1486,6 +1312,30 @@ export function RpgEntryHomeView({ }) .finally(() => setIsSubmittingReferral(false)); }; + const openRewardCodeModal = () => { + setIsRewardCodeOpen(true); + setRewardCodeError(null); + setRewardCodeSuccess(null); + }; + const submitRewardCode = () => { + if (isSubmittingRewardCode || !rewardCodeInput.trim()) { + return; + } + + setIsSubmittingRewardCode(true); + setRewardCodeError(null); + setRewardCodeSuccess(null); + void redeemRpgProfileRewardCode(rewardCodeInput) + .then((response: RedeemProfileRewardCodeResponse) => { + setRewardCodeInput(''); + setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`); + void onRechargeSuccess?.(); + }) + .catch((error: unknown) => { + setRewardCodeError(error instanceof Error ? error.message : '兑换失败'); + }) + .finally(() => setIsSubmittingRewardCode(false)); + }; const submitDesktopSearch = () => { const keyword = desktopSearchKeyword.trim(); if (!keyword || !onSearchPublicCode || isSearchingPublicCode) { @@ -1833,17 +1683,13 @@ export function RpgEntryHomeView({ @@ -2291,18 +2137,6 @@ export function RpgEntryHomeView({ ))}
- {isRechargeOpen ? ( - setIsRechargeOpen(false)} - onSelectProduct={submitRechargeProduct} - /> - ) : null} {profilePopupPanel ? (
- {isRechargeOpen ? ( - setIsRechargeOpen(false)} - onSelectProduct={submitRechargeProduct} + {isRewardCodeOpen ? ( + setIsRewardCodeOpen(false)} /> ) : null} {profilePopupPanel ? ( diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index 6947ff68..fbdb9e0b 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -11,6 +11,7 @@ import type { ProfileSaveArchiveResumeResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeResponse, + RedeemProfileRewardCodeResponse, RuntimeSettings, } from '../../../packages/shared/src/contracts/runtime'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; @@ -125,6 +126,22 @@ export function redeemRpgProfileReferralInviteCode( ); } +export function redeemRpgProfileRewardCode( + code: string, + options: RuntimeRequestOptions = {}, +) { + return requestRpgRuntimeJson( + '/profile/redeem-codes/redeem', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }, + '兑换失败', + options, + ); +} + export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/profile/play-stats',