[codex] profile redeem code and dev password registration #1

Merged
kdletters merged 3 commits from codex/profile-redeem-code into master 2026-04-28 14:49:16 +08:00
27 changed files with 1671 additions and 279 deletions
Showing only changes of commit 6611852a97 - Show all commits

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` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_played_world`, `profile_save_archive` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_played_world`, `profile_save_archive` |
| RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` |
| 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` |
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` |
@@ -133,6 +133,27 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>';
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`
- 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。

View File

@@ -57,7 +57,8 @@ export type ProfileWalletLedgerEntry = {
| 'invite_invitee_reward'
| 'points_recharge'
| 'asset_generation_consume'
| 'asset_generation_refund';
| 'asset_generation_refund'
| 'redeem_code_reward';
createdAt: string;
};
@@ -159,6 +160,16 @@ export type RedeemProfileReferralInviteCodeResponse = {
inviterBalanceAfter: number;
};
export type RedeemProfileRewardCodeRequest = {
code: string;
};
export type RedeemProfileRewardCodeResponse = {
walletBalance: number;
amountGranted: number;
ledgerEntry: ProfileWalletLedgerEntry;
};
export type ProfilePlayedWorkSummary = {
worldKey: string;
ownerUserId: string | null;

View File

@@ -83,8 +83,7 @@ use crate::{
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
submit_puzzle_leaderboard,
swap_puzzle_pieces,
submit_puzzle_leaderboard, swap_puzzle_pieces,
},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
@@ -95,9 +94,10 @@ use crate::{
runtime_chat::stream_runtime_npc_chat_turn,
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code,
redeem_profile_referral_invite_code, redeem_profile_reward_code,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -144,6 +144,20 @@ pub fn build_router(state: AppState) -> Router {
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes",
post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes/disable",
post(admin_disable_profile_redeem_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/healthz",
get(|Extension(request_context): Extension<_>| async move {
@@ -848,6 +862,20 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(

View File

@@ -7,30 +7,36 @@ use axum::{
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
RedeemProfileRewardCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
http_error::AppError, request_context::RequestContext, state::AppState,
};
pub async fn get_profile_dashboard(
@@ -118,6 +124,9 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
}
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD
}
}
}
@@ -228,6 +237,99 @@ pub async fn redeem_profile_referral_invite_code(
))
}
pub async fn redeem_profile_reward_code(
State(state): State<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(
State(state): State<AppState>,
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)]
mod tests {
use module_runtime::RuntimeProfileWalletLedgerSourceType;

View File

@@ -261,6 +261,15 @@ pub enum RuntimeProfileWalletLedgerSourceType {
PointsRecharge,
AssetGenerationConsume,
AssetGenerationRefund,
RedeemCodeReward,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileRedeemCodeMode {
Public,
Unique,
Private,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput {
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRewardCodeRedeemInput {
pub user_id: String,
pub code: String,
pub redeemed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRewardCodeRedeemSnapshot {
pub wallet_balance: u64,
pub amount_granted: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRewardCodeRedeemProcedureResult {
pub ok: bool,
pub record: Option<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))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterSnapshot {
@@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError {
MissingLedgerId,
InvalidWalletAmount,
MissingInviteCode,
MissingRedeemCode,
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
MissingProductId,
MissingWorldKey,
MissingBottomTab,
@@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord {
pub has_points_recharged: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRewardCodeRedeemRecord {
pub wallet_balance: u64,
pub amount_granted: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRedeemCodeRecord {
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub global_used_count: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<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)]
pub struct RuntimeReferralInviteCenterRecord {
pub user_id: String,
@@ -970,6 +1074,73 @@ pub fn build_runtime_referral_redeem_input(
})
}
pub fn build_runtime_profile_reward_code_redeem_input(
user_id: String,
code: String,
redeemed_at_micros: i64,
) -> Result<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(
user_id: String,
) -> 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(
snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> RuntimeProfilePlayedWorldRecord {
@@ -1508,6 +1708,17 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::PointsRecharge => "points_recharge",
Self::AssetGenerationConsume => "asset_generation_consume",
Self::AssetGenerationRefund => "asset_generation_refund",
Self::RedeemCodeReward => "redeem_code_reward",
}
}
}
impl RuntimeProfileRedeemCodeMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Public => "public",
Self::Unique => "unique",
Self::Private => "private",
}
}
}
@@ -1736,6 +1947,10 @@ pub fn normalize_invite_code(value: String) -> Option<String> {
}
}
pub fn normalize_redeem_code(value: String) -> Option<String> {
normalize_invite_code(value)
}
impl std::fmt::Display for RuntimeProfileFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),

View File

@@ -11,6 +11,7 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str =
"asset_generation_consume";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str =
"asset_generation_refund";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward";
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -258,6 +259,60 @@ pub struct RedeemProfileReferralInviteCodeResponse {
pub inviter_balance_after: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RedeemProfileRewardCodeRequest {
pub code: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RedeemProfileRewardCodeResponse {
pub wallet_balance: u64,
pub amount_granted: u64,
pub ledger_entry: ProfileWalletLedgerEntryResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileRedeemCodeRequest {
pub code: String,
pub mode: String,
pub reward_points: u64,
pub max_uses: u32,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub allowed_user_ids: Vec<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)]
#[serde(rename_all = "camelCase")]
pub struct ProfilePlayedWorkSummaryResponse {

View File

@@ -30,10 +30,10 @@ pub use mapper::{
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
};
@@ -120,6 +120,8 @@ use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
@@ -129,8 +131,12 @@ use module_runtime::{
build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input,
build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input,
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record,
build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input,
build_runtime_profile_redeem_code_admin_disable_input,
build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,
build_runtime_profile_reward_code_redeem_input,
build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input,
build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input,
build_runtime_profile_wallet_adjustment_input,
build_runtime_profile_wallet_ledger_entry_record,
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,

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>
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(
result: RuntimeProfilePlayStatsProcedureResult,
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
@@ -1666,6 +1750,33 @@ pub(crate) fn map_runtime_referral_redeem_snapshot(
}
}
pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot(
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
) -> module_runtime::RuntimeProfileRewardCodeRedeemSnapshot {
module_runtime::RuntimeProfileRewardCodeRedeemSnapshot {
wallet_balance: snapshot.wallet_balance,
amount_granted: snapshot.amount_granted,
ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry),
}
}
pub(crate) fn map_runtime_profile_redeem_code_snapshot(
snapshot: RuntimeProfileRedeemCodeSnapshot,
) -> module_runtime::RuntimeProfileRedeemCodeSnapshot {
module_runtime::RuntimeProfileRedeemCodeSnapshot {
code: snapshot.code,
mode: map_runtime_profile_redeem_code_mode_back(snapshot.mode),
reward_points: snapshot.reward_points,
max_uses: snapshot.max_uses,
global_used_count: snapshot.global_used_count,
enabled: snapshot.enabled,
allowed_user_ids: snapshot.allowed_user_ids,
created_by: snapshot.created_by,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_played_world_snapshot(
snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
@@ -3277,6 +3388,41 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward
}
}
}
pub(crate) fn map_runtime_profile_redeem_code_mode(
value: module_runtime::RuntimeProfileRedeemCodeMode,
) -> crate::module_bindings::RuntimeProfileRedeemCodeMode {
match value {
module_runtime::RuntimeProfileRedeemCodeMode::Public => {
crate::module_bindings::RuntimeProfileRedeemCodeMode::Public
}
module_runtime::RuntimeProfileRedeemCodeMode::Unique => {
crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique
}
module_runtime::RuntimeProfileRedeemCodeMode::Private => {
crate::module_bindings::RuntimeProfileRedeemCodeMode::Private
}
}
}
pub(crate) fn map_runtime_profile_redeem_code_mode_back(
value: crate::module_bindings::RuntimeProfileRedeemCodeMode,
) -> module_runtime::RuntimeProfileRedeemCodeMode {
match value {
crate::module_bindings::RuntimeProfileRedeemCodeMode::Public => {
module_runtime::RuntimeProfileRedeemCodeMode::Public
}
crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique => {
module_runtime::RuntimeProfileRedeemCodeMode::Unique
}
crate::module_bindings::RuntimeProfileRedeemCodeMode::Private => {
module_runtime::RuntimeProfileRedeemCodeMode::Private
}
}
}

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

@@ -250,6 +250,9 @@ pub mod list_custom_world_works_procedure;
pub mod list_platform_browse_history_procedure;
pub mod list_profile_save_archives_procedure;
pub mod list_profile_wallet_ledger_procedure;
pub mod redeem_profile_reward_code_procedure;
pub mod admin_upsert_profile_redeem_code_procedure;
pub mod admin_disable_profile_redeem_code_procedure;
pub mod list_puzzle_gallery_procedure;
pub mod list_puzzle_works_procedure;
pub mod npc_battle_interaction_procedure_result_type;
@@ -413,6 +416,14 @@ pub mod runtime_profile_wallet_ledger_entry_snapshot_type;
pub mod runtime_profile_wallet_ledger_list_input_type;
pub mod runtime_profile_wallet_ledger_procedure_result_type;
pub mod runtime_profile_wallet_ledger_source_type_type;
pub mod runtime_profile_redeem_code_mode_type;
pub mod runtime_profile_reward_code_redeem_input_type;
pub mod runtime_profile_reward_code_redeem_snapshot_type;
pub mod runtime_profile_reward_code_redeem_procedure_result_type;
pub mod runtime_profile_redeem_code_admin_upsert_input_type;
pub mod runtime_profile_redeem_code_admin_disable_input_type;
pub mod runtime_profile_redeem_code_snapshot_type;
pub mod runtime_profile_redeem_code_admin_procedure_result_type;
pub mod runtime_referral_invite_center_get_input_type;
pub mod runtime_referral_invite_center_procedure_result_type;
pub mod runtime_referral_invite_center_snapshot_type;
@@ -719,6 +730,9 @@ pub use list_custom_world_works_procedure::list_custom_world_works;
pub use list_platform_browse_history_procedure::list_platform_browse_history;
pub use list_profile_save_archives_procedure::list_profile_save_archives;
pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger;
pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code;
pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code;
pub use list_puzzle_gallery_procedure::list_puzzle_gallery;
pub use list_puzzle_works_procedure::list_puzzle_works;
pub use npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult;
@@ -882,6 +896,14 @@ pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletL
pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput;
pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult;
pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType;
pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput;
pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot;
pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult;
pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput;
pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot;
pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput;
pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult;
pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot;

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,
AssetGenerationRefund,
RedeemCodeReward,
}
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {

View File

@@ -255,6 +255,97 @@ impl SpacetimeClient {
.await
}
pub async fn redeem_profile_reward_code(
&self,
user_id: String,
code: String,
redeemed_at_micros: i64,
) -> Result<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(
&self,
user_id: String,

View File

@@ -109,6 +109,8 @@ macro_rules! migration_tables {
user_browse_history,
profile_dashboard_state,
profile_wallet_ledger,
profile_redeem_code,
profile_redeem_code_usage,
profile_invite_code,
profile_referral_relation,
profile_played_world,

View File

@@ -28,6 +28,39 @@ pub struct ProfileWalletLedger {
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_redeem_code)]
pub struct ProfileRedeemCode {
#[primary_key]
pub(crate) code: String,
pub(crate) mode: RuntimeProfileRedeemCodeMode,
pub(crate) reward_points: u64,
pub(crate) max_uses: u32,
pub(crate) global_used_count: u32,
pub(crate) enabled: bool,
pub(crate) allowed_user_ids: Vec<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)]
pub struct ProfileInviteCode {
#[primary_key]
@@ -396,6 +429,64 @@ pub fn redeem_profile_referral_invite_code(
}
}
// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。
#[spacetimedb::procedure]
pub fn redeem_profile_reward_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileRewardCodeRedeemInput,
) -> RuntimeProfileRewardCodeRedeemProcedureResult {
match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_redeem_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileRedeemCodeAdminUpsertInput,
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_disable_profile_redeem_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileRedeemCodeAdminDisableInput,
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput,
@@ -1194,6 +1285,185 @@ fn redeem_profile_referral_invite_code_record(
})
}
fn redeem_profile_reward_code_record(
ctx: &ReducerContext,
input: RuntimeProfileRewardCodeRedeemInput,
) -> Result<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(
ctx: &ReducerContext,
user_id: &str,
@@ -1579,6 +1849,79 @@ fn latest_profile_recharge_order(
orders.into_iter().next()
}
fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 {
ctx.db
.profile_redeem_code_usage()
.iter()
.filter(|row| row.code == code && row.user_id == 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 = ctx
.db
.profile_redeem_code_usage()
.iter()
.filter(|row| row.code == code && row.user_id == user_id)
.count();
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(
row: &ProfileWalletLedger,
) -> RuntimeProfileWalletLedgerEntrySnapshot {

View File

@@ -8,7 +8,6 @@ import {
Clock3,
Coins,
Copy,
Crown,
House,
LogIn,
MessageCircle,
@@ -34,19 +33,17 @@ import type {
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary,
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import {
createRpgProfileRechargeOrder,
getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter,
redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -910,206 +907,68 @@ function ProfileShortcutButton({
);
}
function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
}
function formatMembershipDuration(days: number) {
if (days >= 365) {
return '365天';
}
return `${days}`;
}
function AccountRechargeModal({
center,
activeTab,
isLoading,
function RewardCodeRedeemModal({
value,
isSubmitting,
error,
onTabChange,
success,
onChange,
onSubmit,
onClose,
onSelectProduct,
}: {
center: ProfileRechargeCenterResponse | null;
activeTab: 'points' | 'membership';
isLoading: boolean;
isSubmitting: string | null;
value: string;
isSubmitting: boolean;
error: string | null;
onTabChange: (tab: 'points' | 'membership') => void;
success: string | null;
onChange: (value: string) => void;
onSubmit: () => void;
onClose: () => void;
onSelectProduct: (product: ProfileRechargeProduct) => void;
}) {
const visibleProducts =
activeTab === 'points'
? (center?.pointProducts ?? [])
: (center?.membershipProducts ?? []);
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,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">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
aria-label="关闭账户充值"
>
×
</button>
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
WALLET
</div>
<div className="mt-1 text-2xl font-black"></div>
<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">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
<button
type="button"
onClick={() => onTabChange('points')}
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
activeTab === 'points'
? 'bg-white text-[#ff4056] shadow-sm'
: 'text-zinc-500'
}`}
>
</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>
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-black"></div>
<button
type="button"
aria-label="关闭兑换码"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="space-y-3 px-5 py-5">
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSubmit();
}
}}
className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
placeholder="输入兑换码"
autoFocus
/>
<button
type="button"
onClick={onSubmit}
disabled={isSubmitting || !value.trim()}
className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? '兑换中' : '兑换'}
</button>
{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}
</div>
) : null}
{isLoading ? (
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
(_, index) => (
<div
key={index}
className="h-24 animate-pulse rounded-xl bg-zinc-100"
/>
),
)}
{success ? (
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
{success}
</div>
) : activeTab === 'points' ? (
<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>
</>
)}
) : null}
</div>
</div>
</div>
@@ -1294,16 +1153,13 @@ export function RpgEntryHomeView({
const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
'points',
const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
const [rewardCodeInput, setRewardCodeInput] = useState('');
const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
const [rewardCodeError, setRewardCodeError] = useState<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] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
@@ -1401,36 +1257,6 @@ export function RpgEntryHomeView({
}
authUi?.openLoginModal();
};
const openRechargePanel = () => {
setIsRechargeOpen(true);
setRechargeError(null);
setIsLoadingRecharge(true);
void getRpgProfileRechargeCenter()
.then(setRechargeCenter)
.catch((error: unknown) => {
setRechargeCenter(null);
setRechargeError(
error instanceof Error ? error.message : '读取账户充值失败',
);
})
.finally(() => setIsLoadingRecharge(false));
};
const submitRechargeProduct = (product: ProfileRechargeProduct) => {
if (submittingRechargeProductId) {
return;
}
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
void createRpgProfileRechargeOrder(product.productId)
.then((response) => {
setRechargeCenter(response.center);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setRechargeError(error instanceof Error ? error.message : '充值失败');
})
.finally(() => setSubmittingRechargeProductId(null));
};
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
setProfilePopupPanel(panel);
setReferralError(null);
@@ -1486,6 +1312,30 @@ export function RpgEntryHomeView({
})
.finally(() => setIsSubmittingReferral(false));
};
const openRewardCodeModal = () => {
setIsRewardCodeOpen(true);
setRewardCodeError(null);
setRewardCodeSuccess(null);
};
const submitRewardCode = () => {
if (isSubmittingRewardCode || !rewardCodeInput.trim()) {
return;
}
setIsSubmittingRewardCode(true);
setRewardCodeError(null);
setRewardCodeSuccess(null);
void redeemRpgProfileRewardCode(rewardCodeInput)
.then((response: RedeemProfileRewardCodeResponse) => {
setRewardCodeInput('');
setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setRewardCodeError(error instanceof Error ? error.message : '兑换失败');
})
.finally(() => setIsSubmittingRewardCode(false));
};
const submitDesktopSearch = () => {
const keyword = desktopSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
@@ -1833,17 +1683,13 @@ export function RpgEntryHomeView({
<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"
>
<Crown className="h-4 w-4" />
<Ticket className="h-4 w-4" />
<div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80">
{rechargeCenter?.membership.status === 'active'
? '叙世会员'
: '普通用户'}
</div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80"></div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
@@ -2291,18 +2137,6 @@ export function RpgEntryHomeView({
))}
</div>
</div>
{isRechargeOpen ? (
<AccountRechargeModal
center={rechargeCenter}
activeTab={rechargeTab}
isLoading={isLoadingRecharge}
isSubmitting={submittingRechargeProductId}
error={rechargeError}
onTabChange={setRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onSelectProduct={submitRechargeProduct}
/>
) : null}
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
@@ -2395,16 +2229,15 @@ export function RpgEntryHomeView({
</div>
</div>
</div>
{isRechargeOpen ? (
<AccountRechargeModal
center={rechargeCenter}
activeTab={rechargeTab}
isLoading={isLoadingRecharge}
isSubmitting={submittingRechargeProductId}
error={rechargeError}
onTabChange={setRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onSelectProduct={submitRechargeProduct}
{isRewardCodeOpen ? (
<RewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null}
{profilePopupPanel ? (

View File

@@ -11,6 +11,7 @@ import type {
ProfileSaveArchiveResumeResponse,
ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
RuntimeSettings,
} from '../../../packages/shared/src/contracts/runtime';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
@@ -125,6 +126,22 @@ export function redeemRpgProfileReferralInviteCode(
);
}
export function redeemRpgProfileRewardCode(
code: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<RedeemProfileRewardCodeResponse>(
'/profile/redeem-codes/redeem',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
},
'兑换失败',
options,
);
}
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
'/profile/play-stats',