Merge pull request #1 from codex/profile-redeem-code
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:
2026-04-28 14:49:15 +08:00
32 changed files with 1836 additions and 281 deletions

View File

@@ -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 和短信签名/模板。

View File

@@ -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. 完成定义

View 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`

View File

@@ -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`
- 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。 - 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。

View File

@@ -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;

View File

@@ -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");

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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();

View File

@@ -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 不能为空"),

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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
}
} }
} }

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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;

View File

@@ -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,
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -19,6 +19,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
AssetGenerationConsume, AssetGenerationConsume,
AssetGenerationRefund, AssetGenerationRefund,
RedeemCodeReward,
} }
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 ? (

View File

@@ -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',