feat: add profile redeem code flow

This commit is contained in:
2026-04-28 12:56:38 +08:00
parent bb4100fca4
commit 6611852a97
27 changed files with 1671 additions and 279 deletions

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 {