Merge pull request #1 from codex/profile-redeem-code
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
profile redeem code and dev password registration
This commit was merged in pull request #1.
This commit is contained in:
@@ -48,6 +48,8 @@ AUTH_REFRESH_COOKIE_SAME_SITE="Lax"
|
|||||||
AUTH_REFRESH_COOKIE_SECURE="false"
|
AUTH_REFRESH_COOKIE_SECURE="false"
|
||||||
# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。
|
# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。
|
||||||
GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json"
|
GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json"
|
||||||
|
# 开发期便捷开关:true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。
|
||||||
|
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false"
|
||||||
|
|
||||||
# 手机号验证码登录配置(阿里云 PNVS)。
|
# 手机号验证码登录配置(阿里云 PNVS)。
|
||||||
# 正式环境请改成你自己的 AccessKey 和短信签名/模板。
|
# 正式环境请改成你自己的 AccessKey 和短信签名/模板。
|
||||||
|
|||||||
@@ -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-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`
|
日期:`2026-04-21`
|
||||||
|
|
||||||
@@ -166,6 +168,13 @@
|
|||||||
2. 不创建账号。
|
2. 不创建账号。
|
||||||
3. 不写 `password_hash`。
|
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 未设置密码
|
### 8.2 未设置密码
|
||||||
|
|
||||||
当账号存在但 `password_login_enabled = false` 时:
|
当账号存在但 `password_login_enabled = false` 时:
|
||||||
@@ -233,6 +242,8 @@
|
|||||||
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`。
|
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`。
|
||||||
5. 登录成功时返回 access token。
|
5. 登录成功时返回 access token。
|
||||||
6. 登录成功时写回 refresh cookie。
|
6. 登录成功时写回 refresh cookie。
|
||||||
|
7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。
|
||||||
|
8. 开关开启时,未知手机号可通过 `/api/auth/entry` 创建账号并登录;同手机号后续用相同密码登录复用同一用户,错误密码仍返回 `401`。
|
||||||
|
|
||||||
## 13. 完成定义
|
## 13. 完成定义
|
||||||
|
|
||||||
|
|||||||
131
docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md
Normal file
131
docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md
Normal file
@@ -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<String>` | 私有码允许用户;公共/唯一码存空数组。 |
|
||||||
|
| `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`。
|
||||||
@@ -23,7 +23,7 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
|
|||||||
| 领域 | 表 |
|
| 领域 | 表 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` |
|
| 认证 | `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` |
|
| 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` |
|
| 世界创作 | `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` |
|
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` |
|
||||||
@@ -133,6 +133,27 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>';
|
|||||||
SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created_at DESC;
|
SELECT * FROM profile_wallet_ledger WHERE user_id = '<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<String>`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`。
|
||||||
|
- 索引:主键 `code`。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM profile_redeem_code WHERE code = '<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 = '<CODE>';
|
||||||
|
SELECT * FROM profile_redeem_code_usage WHERE user_id = '<user_id>';
|
||||||
|
```
|
||||||
|
|
||||||
### `profile_played_world`
|
### `profile_played_world`
|
||||||
|
|
||||||
- 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。
|
- 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export type ProfileWalletLedgerEntry = {
|
|||||||
| 'invite_invitee_reward'
|
| 'invite_invitee_reward'
|
||||||
| 'points_recharge'
|
| 'points_recharge'
|
||||||
| 'asset_generation_consume'
|
| 'asset_generation_consume'
|
||||||
| 'asset_generation_refund';
|
| 'asset_generation_refund'
|
||||||
|
| 'redeem_code_reward';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,6 +160,16 @@ export type RedeemProfileReferralInviteCodeResponse = {
|
|||||||
inviterBalanceAfter: number;
|
inviterBalanceAfter: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RedeemProfileRewardCodeRequest = {
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedeemProfileRewardCodeResponse = {
|
||||||
|
walletBalance: number;
|
||||||
|
amountGranted: number;
|
||||||
|
ledgerEntry: ProfileWalletLedgerEntry;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProfilePlayedWorkSummary = {
|
export type ProfilePlayedWorkSummary = {
|
||||||
worldKey: string;
|
worldKey: string;
|
||||||
ownerUserId: string | null;
|
ownerUserId: string | null;
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ use crate::{
|
|||||||
runtime_chat::stream_runtime_npc_chat_turn,
|
runtime_chat::stream_runtime_npc_chat_turn,
|
||||||
runtime_inventory::get_runtime_inventory_state,
|
runtime_inventory::get_runtime_inventory_state,
|
||||||
runtime_profile::{
|
runtime_profile::{
|
||||||
|
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
|
||||||
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
|
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
|
||||||
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
|
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::{
|
runtime_save::{
|
||||||
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
||||||
@@ -143,6 +144,20 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_admin_auth,
|
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(
|
.route(
|
||||||
"/healthz",
|
"/healthz",
|
||||||
get(|Extension(request_context): Extension<_>| async move {
|
get(|Extension(request_context): Extension<_>| async move {
|
||||||
@@ -851,6 +866,20 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
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(
|
.route(
|
||||||
"/api/runtime/profile/play-stats",
|
"/api/runtime/profile/play-stats",
|
||||||
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
||||||
@@ -1357,6 +1386,36 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
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]
|
#[tokio::test]
|
||||||
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
|
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
|
||||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub struct AppConfig {
|
|||||||
pub refresh_cookie_same_site: String,
|
pub refresh_cookie_same_site: String,
|
||||||
pub refresh_session_ttl_days: u32,
|
pub refresh_session_ttl_days: u32,
|
||||||
pub auth_store_path: PathBuf,
|
pub auth_store_path: PathBuf,
|
||||||
|
pub dev_password_entry_auto_register_enabled: bool,
|
||||||
pub sms_auth_enabled: bool,
|
pub sms_auth_enabled: bool,
|
||||||
pub sms_auth_provider: String,
|
pub sms_auth_provider: String,
|
||||||
pub sms_endpoint: String,
|
pub sms_endpoint: String,
|
||||||
@@ -118,6 +119,7 @@ impl Default for AppConfig {
|
|||||||
refresh_cookie_same_site: "Lax".to_string(),
|
refresh_cookie_same_site: "Lax".to_string(),
|
||||||
refresh_session_ttl_days: 30,
|
refresh_session_ttl_days: 30,
|
||||||
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
|
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
|
||||||
|
dev_password_entry_auto_register_enabled: false,
|
||||||
sms_auth_enabled: false,
|
sms_auth_enabled: false,
|
||||||
sms_auth_provider: "mock".to_string(),
|
sms_auth_provider: "mock".to_string(),
|
||||||
sms_endpoint: "dypnsapi.aliyuncs.com".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"]) {
|
if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
|
||||||
config.auth_store_path = PathBuf::from(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"]) {
|
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
|
||||||
config.sms_auth_enabled = sms_auth_enabled;
|
config.sms_auth_enabled = sms_auth_enabled;
|
||||||
|
|||||||
@@ -26,14 +26,19 @@ pub async fn password_entry(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(payload): Json<PasswordEntryRequest>,
|
Json(payload): Json<PasswordEntryRequest>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let result = state
|
let input = PasswordEntryInput {
|
||||||
.password_entry_service()
|
phone_number: payload.phone,
|
||||||
.execute(PasswordEntryInput {
|
password: payload.password,
|
||||||
phone_number: payload.phone,
|
};
|
||||||
password: payload.password,
|
let result = if state.config.dev_password_entry_auto_register_enabled {
|
||||||
})
|
state
|
||||||
.await
|
.password_entry_service()
|
||||||
.map_err(map_password_entry_error)?;
|
.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 session_client = resolve_session_client_context(&headers);
|
||||||
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
||||||
state
|
state
|
||||||
|
|||||||
@@ -7,30 +7,36 @@ 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, RuntimeProfileWalletLedgerSourceType,
|
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
||||||
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord,
|
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||||
|
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||||
|
RuntimeReferralRedeemRecord,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use shared_contracts::runtime::{
|
use shared_contracts::runtime::{
|
||||||
|
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
|
||||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
||||||
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
||||||
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
|
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
|
||||||
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
|
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
|
||||||
ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse,
|
ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
|
||||||
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
|
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
|
||||||
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
|
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
|
||||||
|
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
|
||||||
|
RedeemProfileRewardCodeResponse,
|
||||||
};
|
};
|
||||||
use spacetime_client::SpacetimeClientError;
|
use spacetime_client::SpacetimeClientError;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
|
||||||
request_context::RequestContext, state::AppState,
|
http_error::AppError, request_context::RequestContext, state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_profile_dashboard(
|
pub async fn get_profile_dashboard(
|
||||||
@@ -118,6 +124,9 @@ fn format_profile_wallet_ledger_source_type(
|
|||||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
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<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
Json(payload): Json<RedeemProfileRewardCodeRequest>,
|
||||||
|
) -> Result<Json<Value>, 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<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||||
|
Json(payload): Json<AdminUpsertProfileRedeemCodeRequest>,
|
||||||
|
) -> Result<Json<Value>, 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<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||||
|
Json(payload): Json<AdminDisableProfileRedeemCodeRequest>,
|
||||||
|
) -> Result<Json<Value>, 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(
|
pub async fn get_profile_play_stats(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
@@ -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<RuntimeProfileRedeemCodeMode, String> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
||||||
|
|||||||
@@ -486,6 +486,38 @@ impl PasswordEntryService {
|
|||||||
verify_stored_password_user(existing_user, &input.password).await
|
verify_stored_password_user(existing_user, &input.password).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn execute_with_dev_registration(
|
||||||
|
&self,
|
||||||
|
input: PasswordEntryInput,
|
||||||
|
) -> Result<PasswordEntryResult, PasswordEntryError> {
|
||||||
|
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(
|
pub fn get_user_by_id(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -1336,6 +1368,53 @@ impl InMemoryAuthStore {
|
|||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_dev_password_phone_user(
|
||||||
|
&self,
|
||||||
|
phone_number: PhoneNumberSnapshot,
|
||||||
|
display_name: String,
|
||||||
|
password_hash: String,
|
||||||
|
) -> Result<AuthUser, PasswordEntryError> {
|
||||||
|
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(
|
fn create_pending_wechat_user(
|
||||||
&self,
|
&self,
|
||||||
profile: WechatIdentityProfile,
|
profile: WechatIdentityProfile,
|
||||||
@@ -2474,6 +2553,39 @@ mod tests {
|
|||||||
assert_eq!(error, PasswordEntryError::InvalidCredentials);
|
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]
|
#[tokio::test]
|
||||||
async fn phone_user_can_set_password_then_login() {
|
async fn phone_user_can_set_password_then_login() {
|
||||||
let store = build_store();
|
let store = build_store();
|
||||||
|
|||||||
@@ -261,6 +261,15 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
|||||||
PointsRecharge,
|
PointsRecharge,
|
||||||
AssetGenerationConsume,
|
AssetGenerationConsume,
|
||||||
AssetGenerationRefund,
|
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))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
@@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput {
|
|||||||
pub created_at_micros: i64,
|
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<RuntimeProfileRewardCodeRedeemSnapshot>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub allowed_public_user_codes: Vec<String>,
|
||||||
|
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<String>,
|
||||||
|
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<RuntimeProfileRedeemCodeSnapshot>,
|
||||||
|
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 RuntimeReferralInviteCenterSnapshot {
|
pub struct RuntimeReferralInviteCenterSnapshot {
|
||||||
@@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError {
|
|||||||
MissingLedgerId,
|
MissingLedgerId,
|
||||||
InvalidWalletAmount,
|
InvalidWalletAmount,
|
||||||
MissingInviteCode,
|
MissingInviteCode,
|
||||||
|
MissingRedeemCode,
|
||||||
|
InvalidRedeemCodeReward,
|
||||||
|
InvalidRedeemCodeMaxUses,
|
||||||
MissingProductId,
|
MissingProductId,
|
||||||
MissingWorldKey,
|
MissingWorldKey,
|
||||||
MissingBottomTab,
|
MissingBottomTab,
|
||||||
@@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord {
|
|||||||
pub has_points_recharged: bool,
|
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<String>,
|
||||||
|
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)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct RuntimeReferralInviteCenterRecord {
|
pub struct RuntimeReferralInviteCenterRecord {
|
||||||
pub user_id: String,
|
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<RuntimeProfileRewardCodeRedeemInput, RuntimeProfileFieldError> {
|
||||||
|
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<String>,
|
||||||
|
allowed_public_user_codes: Vec<String>,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
) -> Result<RuntimeProfileRedeemCodeAdminUpsertInput, RuntimeProfileFieldError> {
|
||||||
|
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<RuntimeProfileRedeemCodeAdminDisableInput, RuntimeProfileFieldError> {
|
||||||
|
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(
|
pub fn build_runtime_profile_play_stats_get_input(
|
||||||
user_id: String,
|
user_id: String,
|
||||||
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
|
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
|
||||||
@@ -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(
|
pub fn build_runtime_profile_played_world_record(
|
||||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||||
) -> RuntimeProfilePlayedWorldRecord {
|
) -> RuntimeProfilePlayedWorldRecord {
|
||||||
@@ -1508,6 +1708,17 @@ impl RuntimeProfileWalletLedgerSourceType {
|
|||||||
Self::PointsRecharge => "points_recharge",
|
Self::PointsRecharge => "points_recharge",
|
||||||
Self::AssetGenerationConsume => "asset_generation_consume",
|
Self::AssetGenerationConsume => "asset_generation_consume",
|
||||||
Self::AssetGenerationRefund => "asset_generation_refund",
|
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<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn normalize_redeem_code(value: String) -> Option<String> {
|
||||||
|
normalize_invite_code(value)
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for RuntimeProfileFieldError {
|
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 {
|
||||||
@@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
|||||||
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
|
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
|
||||||
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
|
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::MissingRedeemCode => f.write_str("兑换码不能为空"),
|
||||||
|
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
|
||||||
|
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
|
||||||
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 不能为空"),
|
||||||
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
|
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str =
|
|||||||
"asset_generation_consume";
|
"asset_generation_consume";
|
||||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str =
|
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str =
|
||||||
"asset_generation_refund";
|
"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_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";
|
||||||
@@ -258,6 +259,60 @@ pub struct RedeemProfileReferralInviteCodeResponse {
|
|||||||
pub inviter_balance_after: u64,
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub allowed_public_user_codes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub created_by: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProfilePlayedWorkSummaryResponse {
|
pub struct ProfilePlayedWorkSummaryResponse {
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ use module_runtime::{
|
|||||||
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
|
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
|
||||||
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
|
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
|
||||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||||
|
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
|
||||||
|
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||||
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
|
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
|
||||||
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
|
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
|
||||||
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
|
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_play_stats_record, build_runtime_profile_recharge_center_get_input,
|
||||||
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_redeem_code_admin_disable_input,
|
||||||
build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_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_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,
|
||||||
|
|||||||
@@ -161,6 +161,48 @@ impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<module_runtime::RuntimeProfileRewardCodeRedeemInput>
|
||||||
|
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<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput>
|
||||||
|
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<module_runtime::RuntimeProfileRedeemCodeAdminDisableInput>
|
||||||
|
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<module_runtime::RuntimeReferralInviteCenterGetInput>
|
impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
|
||||||
for RuntimeReferralInviteCenterGetInput
|
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<RuntimeProfileRewardCodeRedeemRecord, 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 未返回 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<RuntimeProfileRedeemCodeRecord, 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 未返回 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(
|
pub(crate) fn map_runtime_profile_play_stats_procedure_result(
|
||||||
result: RuntimeProfilePlayStatsProcedureResult,
|
result: RuntimeProfilePlayStatsProcedureResult,
|
||||||
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
|
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
|
||||||
@@ -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(
|
pub(crate) fn map_runtime_profile_played_world_snapshot(
|
||||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||||
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
|
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
|
||||||
@@ -3284,6 +3395,41 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
|
|||||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
||||||
module_runtime::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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||||
|
) + 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<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||||
|
"admin_disable_profile_redeem_code",
|
||||||
|
AdminDisableProfileRedeemCodeArgs { input },
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||||
|
) + 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<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||||
|
"admin_upsert_profile_redeem_code",
|
||||||
|
AdminUpsertProfileRedeemCodeArgs { input },
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -251,6 +251,9 @@ pub mod list_custom_world_works_procedure;
|
|||||||
pub mod list_platform_browse_history_procedure;
|
pub mod list_platform_browse_history_procedure;
|
||||||
pub mod list_profile_save_archives_procedure;
|
pub mod list_profile_save_archives_procedure;
|
||||||
pub mod list_profile_wallet_ledger_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_gallery_procedure;
|
||||||
pub mod list_puzzle_works_procedure;
|
pub mod list_puzzle_works_procedure;
|
||||||
pub mod npc_battle_interaction_procedure_result_type;
|
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_list_input_type;
|
||||||
pub mod runtime_profile_wallet_ledger_procedure_result_type;
|
pub mod runtime_profile_wallet_ledger_procedure_result_type;
|
||||||
pub mod runtime_profile_wallet_ledger_source_type_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_get_input_type;
|
||||||
pub mod runtime_referral_invite_center_procedure_result_type;
|
pub mod runtime_referral_invite_center_procedure_result_type;
|
||||||
pub mod runtime_referral_invite_center_snapshot_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_platform_browse_history_procedure::list_platform_browse_history;
|
||||||
pub use list_profile_save_archives_procedure::list_profile_save_archives;
|
pub use list_profile_save_archives_procedure::list_profile_save_archives;
|
||||||
pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger;
|
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_gallery_procedure::list_puzzle_gallery;
|
||||||
pub use list_puzzle_works_procedure::list_puzzle_works;
|
pub use list_puzzle_works_procedure::list_puzzle_works;
|
||||||
pub use npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult;
|
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_list_input_type::RuntimeProfileWalletLedgerListInput;
|
||||||
pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult;
|
pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult;
|
||||||
pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType;
|
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_get_input_type::RuntimeReferralInviteCenterGetInput;
|
||||||
pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult;
|
pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult;
|
||||||
pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot;
|
pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot;
|
||||||
|
|||||||
@@ -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<RuntimeProfileRewardCodeRedeemProcedureResult, __sdk::InternalError>,
|
||||||
|
) + 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<RuntimeProfileRewardCodeRedeemProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, RuntimeProfileRewardCodeRedeemProcedureResult>(
|
||||||
|
"redeem_profile_reward_code",
|
||||||
|
RedeemProfileRewardCodeArgs { input },
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<RuntimeProfileRedeemCodeSnapshot>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
pub allowed_public_user_codes: Vec<String>,
|
||||||
|
pub updated_at_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminUpsertInput {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
pub created_by: String,
|
||||||
|
pub created_at_micros: i64,
|
||||||
|
pub updated_at_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<RuntimeProfileRewardCodeRedeemSnapshot>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||||
|
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_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;
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
|||||||
AssetGenerationConsume,
|
AssetGenerationConsume,
|
||||||
|
|
||||||
AssetGenerationRefund,
|
AssetGenerationRefund,
|
||||||
|
|
||||||
|
RedeemCodeReward,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
||||||
|
|||||||
@@ -255,6 +255,97 @@ impl SpacetimeClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn redeem_profile_reward_code(
|
||||||
|
&self,
|
||||||
|
user_id: String,
|
||||||
|
code: String,
|
||||||
|
redeemed_at_micros: i64,
|
||||||
|
) -> Result<RuntimeProfileRewardCodeRedeemRecord, SpacetimeClientError> {
|
||||||
|
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<String>,
|
||||||
|
allowed_public_user_codes: Vec<String>,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
|
||||||
|
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<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
|
||||||
|
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(
|
pub async fn get_profile_play_stats(
|
||||||
&self,
|
&self,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ macro_rules! migration_tables {
|
|||||||
user_browse_history,
|
user_browse_history,
|
||||||
profile_dashboard_state,
|
profile_dashboard_state,
|
||||||
profile_wallet_ledger,
|
profile_wallet_ledger,
|
||||||
|
profile_redeem_code,
|
||||||
|
profile_redeem_code_usage,
|
||||||
profile_invite_code,
|
profile_invite_code,
|
||||||
profile_referral_relation,
|
profile_referral_relation,
|
||||||
profile_played_world,
|
profile_played_world,
|
||||||
|
|||||||
@@ -28,6 +28,39 @@ pub struct ProfileWalletLedger {
|
|||||||
pub(crate) created_at: Timestamp,
|
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<String>,
|
||||||
|
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)]
|
#[spacetimedb::table(accessor = profile_invite_code)]
|
||||||
pub struct ProfileInviteCode {
|
pub struct ProfileInviteCode {
|
||||||
#[primary_key]
|
#[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(
|
pub(crate) fn list_profile_save_archive_rows(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: RuntimeProfileSaveArchiveListInput,
|
input: RuntimeProfileSaveArchiveListInput,
|
||||||
@@ -1194,6 +1285,185 @@ fn redeem_profile_referral_invite_code_record(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn redeem_profile_reward_code_record(
|
||||||
|
ctx: &ReducerContext,
|
||||||
|
input: RuntimeProfileRewardCodeRedeemInput,
|
||||||
|
) -> Result<RuntimeProfileRewardCodeRedeemSnapshot, String> {
|
||||||
|
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<RuntimeProfileRedeemCodeSnapshot, String> {
|
||||||
|
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<RuntimeProfileRedeemCodeSnapshot, String> {
|
||||||
|
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(
|
fn build_profile_referral_invite_center_snapshot(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -1579,6 +1849,74 @@ fn latest_profile_recharge_order(
|
|||||||
orders.into_iter().next()
|
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<Vec<String>, 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(
|
fn build_profile_wallet_ledger_snapshot_from_row(
|
||||||
row: &ProfileWalletLedger,
|
row: &ProfileWalletLedger,
|
||||||
) -> RuntimeProfileWalletLedgerEntrySnapshot {
|
) -> RuntimeProfileWalletLedgerEntrySnapshot {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Clock3,
|
Clock3,
|
||||||
Coins,
|
Coins,
|
||||||
Copy,
|
Copy,
|
||||||
Crown,
|
|
||||||
House,
|
House,
|
||||||
LogIn,
|
LogIn,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
@@ -34,19 +33,17 @@ import type {
|
|||||||
PlatformBrowseHistoryEntry,
|
PlatformBrowseHistoryEntry,
|
||||||
ProfileDashboardCardKey,
|
ProfileDashboardCardKey,
|
||||||
ProfileDashboardSummary,
|
ProfileDashboardSummary,
|
||||||
ProfileRechargeCenterResponse,
|
|
||||||
ProfileRechargeProduct,
|
|
||||||
ProfileReferralInviteCenterResponse,
|
ProfileReferralInviteCenterResponse,
|
||||||
ProfileSaveArchiveSummary,
|
ProfileSaveArchiveSummary,
|
||||||
RedeemProfileReferralInviteCodeResponse,
|
RedeemProfileReferralInviteCodeResponse,
|
||||||
|
RedeemProfileRewardCodeResponse,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import type { AuthUser } from '../../services/authService';
|
import type { AuthUser } from '../../services/authService';
|
||||||
import {
|
import {
|
||||||
createRpgProfileRechargeOrder,
|
|
||||||
getRpgProfileRechargeCenter,
|
|
||||||
getRpgProfileReferralInviteCenter,
|
getRpgProfileReferralInviteCenter,
|
||||||
redeemRpgProfileReferralInviteCode,
|
redeemRpgProfileReferralInviteCode,
|
||||||
|
redeemRpgProfileRewardCode,
|
||||||
} from '../../services/rpg-entry/rpgProfileClient';
|
} from '../../services/rpg-entry/rpgProfileClient';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
@@ -910,206 +907,68 @@ function ProfileShortcutButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRechargePrice(priceCents: number) {
|
function RewardCodeRedeemModal({
|
||||||
const yuan = priceCents / 100;
|
value,
|
||||||
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMembershipDuration(days: number) {
|
|
||||||
if (days >= 365) {
|
|
||||||
return '365天';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${days}天`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccountRechargeModal({
|
|
||||||
center,
|
|
||||||
activeTab,
|
|
||||||
isLoading,
|
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
error,
|
error,
|
||||||
onTabChange,
|
success,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
onClose,
|
onClose,
|
||||||
onSelectProduct,
|
|
||||||
}: {
|
}: {
|
||||||
center: ProfileRechargeCenterResponse | null;
|
value: string;
|
||||||
activeTab: 'points' | 'membership';
|
isSubmitting: boolean;
|
||||||
isLoading: boolean;
|
|
||||||
isSubmitting: string | null;
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onTabChange: (tab: 'points' | 'membership') => void;
|
success: string | null;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelectProduct: (product: ProfileRechargeProduct) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const visibleProducts =
|
|
||||||
activeTab === 'points'
|
|
||||||
? (center?.pointProducts ?? [])
|
|
||||||
: (center?.membershipProducts ?? []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
|
||||||
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[32rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_34%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
|
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
|
||||||
<button
|
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||||
type="button"
|
<div className="text-base font-black">兑换码</div>
|
||||||
onClick={onClose}
|
<button
|
||||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
|
type="button"
|
||||||
aria-label="关闭账户充值"
|
aria-label="关闭兑换码"
|
||||||
>
|
onClick={onClose}
|
||||||
×
|
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||||
</button>
|
>
|
||||||
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
×
|
||||||
<div className="pr-10">
|
</button>
|
||||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
</div>
|
||||||
WALLET
|
<div className="space-y-3 px-5 py-5">
|
||||||
</div>
|
<input
|
||||||
<div className="mt-1 text-2xl font-black">账户充值</div>
|
value={value}
|
||||||
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
onChange={(event) => onChange(event.target.value)}
|
||||||
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
|
onKeyDown={(event) => {
|
||||||
<span>
|
if (event.key === 'Enter') {
|
||||||
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
|
onSubmit();
|
||||||
</span>
|
}
|
||||||
</div>
|
}}
|
||||||
</div>
|
className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
|
||||||
|
placeholder="输入兑换码"
|
||||||
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
|
autoFocus
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onTabChange('points')}
|
type="button"
|
||||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
onClick={onSubmit}
|
||||||
activeTab === 'points'
|
disabled={isSubmitting || !value.trim()}
|
||||||
? 'bg-white text-[#ff4056] shadow-sm'
|
className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
: 'text-zinc-500'
|
>
|
||||||
}`}
|
{isSubmitting ? '兑换中' : '兑换'}
|
||||||
>
|
</button>
|
||||||
叙世币充值
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onTabChange('membership')}
|
|
||||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
|
||||||
activeTab === 'membership'
|
|
||||||
? 'bg-white text-[#ff4056] shadow-sm'
|
|
||||||
: 'text-zinc-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
会员卡充值
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{success ? (
|
||||||
{isLoading ? (
|
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
{success}
|
||||||
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
|
|
||||||
(_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="h-24 animate-pulse rounded-xl bg-zinc-100"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'points' ? (
|
) : null}
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
|
||||||
{visibleProducts.map((product) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={product.productId}
|
|
||||||
disabled={Boolean(isSubmitting)}
|
|
||||||
onClick={() => onSelectProduct(product)}
|
|
||||||
className="relative min-h-[8.45rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
|
|
||||||
>
|
|
||||||
<div className="h-8 bg-[#ff4056] px-2 py-1.5 text-xs font-black text-white">
|
|
||||||
{product.badgeLabel}
|
|
||||||
</div>
|
|
||||||
<div className="px-2 py-3">
|
|
||||||
<div className="text-xl font-black">
|
|
||||||
{product.pointsAmount}叙世币
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">
|
|
||||||
金额:{formatRechargePrice(product.priceCents)}
|
|
||||||
</div>
|
|
||||||
<div className="my-2 h-px bg-zinc-100" />
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
{isSubmitting === product.productId
|
|
||||||
? '处理中'
|
|
||||||
: product.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
|
||||||
{visibleProducts.map((product) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={product.productId}
|
|
||||||
disabled={Boolean(isSubmitting)}
|
|
||||||
onClick={() => onSelectProduct(product)}
|
|
||||||
className="group relative min-h-[7.75rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-[#ff4056] hover:shadow-md disabled:opacity-70"
|
|
||||||
>
|
|
||||||
<div className="absolute right-0 top-0 h-16 w-16 rounded-bl-[2rem] bg-[#ff4056]/10 transition group-hover:bg-[#ff4056]/16" />
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-lg font-black">
|
|
||||||
{product.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs font-bold text-zinc-500">
|
|
||||||
{formatMembershipDuration(product.durationDays)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Crown className="h-5 w-5 shrink-0 text-[#ff4056]" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-2xl font-black text-[#ff4056]">
|
|
||||||
{formatRechargePrice(product.priceCents)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs font-semibold text-zinc-500">
|
|
||||||
{isSubmitting === product.productId
|
|
||||||
? '处理中'
|
|
||||||
: product.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
|
|
||||||
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
|
|
||||||
用户等级特权
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="grid min-w-[30rem] grid-cols-5 text-center text-sm">
|
|
||||||
{center?.benefits.map((benefit) => (
|
|
||||||
<div key={benefit.benefitName} className="contents">
|
|
||||||
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
|
|
||||||
{benefit.benefitName}
|
|
||||||
</div>
|
|
||||||
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
|
|
||||||
{benefit.normalValue}
|
|
||||||
</div>
|
|
||||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
|
|
||||||
{benefit.monthValue}
|
|
||||||
</div>
|
|
||||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
|
|
||||||
{benefit.seasonValue}
|
|
||||||
</div>
|
|
||||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
|
|
||||||
{benefit.yearValue}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1294,16 +1153,13 @@ export function RpgEntryHomeView({
|
|||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
|
||||||
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
const [rewardCodeInput, setRewardCodeInput] = useState('');
|
||||||
'points',
|
const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
|
||||||
|
const [rewardCodeError, setRewardCodeError] = useState<string | null>(null);
|
||||||
|
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
const [rechargeCenter, setRechargeCenter] =
|
|
||||||
useState<ProfileRechargeCenterResponse | null>(null);
|
|
||||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
|
||||||
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
|
|
||||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
|
||||||
useState<string | null>(null);
|
|
||||||
const [profilePopupPanel, setProfilePopupPanel] =
|
const [profilePopupPanel, setProfilePopupPanel] =
|
||||||
useState<ProfilePopupPanel | null>(null);
|
useState<ProfilePopupPanel | null>(null);
|
||||||
const [referralCenter, setReferralCenter] =
|
const [referralCenter, setReferralCenter] =
|
||||||
@@ -1401,36 +1257,6 @@ export function RpgEntryHomeView({
|
|||||||
}
|
}
|
||||||
authUi?.openLoginModal();
|
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) => {
|
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
|
||||||
setProfilePopupPanel(panel);
|
setProfilePopupPanel(panel);
|
||||||
setReferralError(null);
|
setReferralError(null);
|
||||||
@@ -1486,6 +1312,30 @@ export function RpgEntryHomeView({
|
|||||||
})
|
})
|
||||||
.finally(() => setIsSubmittingReferral(false));
|
.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 submitDesktopSearch = () => {
|
||||||
const keyword = desktopSearchKeyword.trim();
|
const keyword = desktopSearchKeyword.trim();
|
||||||
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
||||||
@@ -1833,17 +1683,13 @@ export function RpgEntryHomeView({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openRechargePanel}
|
onClick={openRewardCodeModal}
|
||||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||||
>
|
>
|
||||||
<Crown className="h-4 w-4" />
|
<Ticket className="h-4 w-4" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-bold">会员充值</div>
|
<div className="text-xs font-bold">兑换码</div>
|
||||||
<div className="text-[10px] opacity-80">
|
<div className="text-[10px] opacity-80">叙世币</div>
|
||||||
{rechargeCenter?.membership.status === 'active'
|
|
||||||
? '叙世会员'
|
|
||||||
: '普通用户'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||||
</button>
|
</button>
|
||||||
@@ -2291,18 +2137,6 @@ export function RpgEntryHomeView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isRechargeOpen ? (
|
|
||||||
<AccountRechargeModal
|
|
||||||
center={rechargeCenter}
|
|
||||||
activeTab={rechargeTab}
|
|
||||||
isLoading={isLoadingRecharge}
|
|
||||||
isSubmitting={submittingRechargeProductId}
|
|
||||||
error={rechargeError}
|
|
||||||
onTabChange={setRechargeTab}
|
|
||||||
onClose={() => setIsRechargeOpen(false)}
|
|
||||||
onSelectProduct={submitRechargeProduct}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{profilePopupPanel ? (
|
{profilePopupPanel ? (
|
||||||
<ProfileReferralModal
|
<ProfileReferralModal
|
||||||
panel={profilePopupPanel}
|
panel={profilePopupPanel}
|
||||||
@@ -2395,16 +2229,15 @@ export function RpgEntryHomeView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isRechargeOpen ? (
|
{isRewardCodeOpen ? (
|
||||||
<AccountRechargeModal
|
<RewardCodeRedeemModal
|
||||||
center={rechargeCenter}
|
value={rewardCodeInput}
|
||||||
activeTab={rechargeTab}
|
isSubmitting={isSubmittingRewardCode}
|
||||||
isLoading={isLoadingRecharge}
|
error={rewardCodeError}
|
||||||
isSubmitting={submittingRechargeProductId}
|
success={rewardCodeSuccess}
|
||||||
error={rechargeError}
|
onChange={setRewardCodeInput}
|
||||||
onTabChange={setRechargeTab}
|
onSubmit={submitRewardCode}
|
||||||
onClose={() => setIsRechargeOpen(false)}
|
onClose={() => setIsRewardCodeOpen(false)}
|
||||||
onSelectProduct={submitRechargeProduct}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{profilePopupPanel ? (
|
{profilePopupPanel ? (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ProfileSaveArchiveResumeResponse,
|
ProfileSaveArchiveResumeResponse,
|
||||||
ProfileWalletLedgerResponse,
|
ProfileWalletLedgerResponse,
|
||||||
RedeemProfileReferralInviteCodeResponse,
|
RedeemProfileReferralInviteCodeResponse,
|
||||||
|
RedeemProfileRewardCodeResponse,
|
||||||
RuntimeSettings,
|
RuntimeSettings,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||||
@@ -125,6 +126,22 @@ export function redeemRpgProfileReferralInviteCode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function redeemRpgProfileRewardCode(
|
||||||
|
code: string,
|
||||||
|
options: RuntimeRequestOptions = {},
|
||||||
|
) {
|
||||||
|
return requestRpgRuntimeJson<RedeemProfileRewardCodeResponse>(
|
||||||
|
'/profile/redeem-codes/redeem',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
},
|
||||||
|
'兑换失败',
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||||
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
|
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
|
||||||
'/profile/play-stats',
|
'/profile/play-stats',
|
||||||
|
|||||||
Reference in New Issue
Block a user