1
This commit is contained in:
@@ -55,6 +55,41 @@ pub struct ProfilePlayedWorld {
|
||||
pub(crate) last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_membership)]
|
||||
pub struct ProfileMembership {
|
||||
#[primary_key]
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) status: RuntimeProfileMembershipStatus,
|
||||
pub(crate) tier: RuntimeProfileMembershipTier,
|
||||
pub(crate) started_at: Timestamp,
|
||||
pub(crate) expires_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = profile_recharge_order,
|
||||
index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])),
|
||||
index(
|
||||
accessor = by_profile_recharge_order_user_created_at,
|
||||
btree(columns = [user_id, created_at])
|
||||
)
|
||||
)]
|
||||
pub struct ProfileRechargeOrder {
|
||||
#[primary_key]
|
||||
pub(crate) order_id: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) product_id: String,
|
||||
pub(crate) product_title: String,
|
||||
pub(crate) kind: RuntimeProfileRechargeProductKind,
|
||||
pub(crate) amount_cents: u64,
|
||||
pub(crate) status: RuntimeProfileRechargeOrderStatus,
|
||||
pub(crate) payment_channel: String,
|
||||
pub(crate) paid_at: Timestamp,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) points_delta: i64,
|
||||
pub(crate) membership_expires_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = profile_save_archive,
|
||||
index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])),
|
||||
@@ -195,6 +230,50 @@ pub fn get_profile_play_stats(
|
||||
}
|
||||
}
|
||||
|
||||
// 账户充值中心只读快照,套餐和权益由后端返回,前端不保存业务价格表。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_profile_recharge_center(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRechargeCenterGetInput,
|
||||
) -> RuntimeProfileRechargeCenterProcedureResult {
|
||||
match ctx.try_with_tx(|tx| get_profile_recharge_center_snapshot(tx, input.clone())) {
|
||||
Ok(record) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
order: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
order: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 当前阶段没有真实支付网关,下单后在服务端模拟支付成功并立即写入权益。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn create_profile_recharge_order_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRechargeOrderCreateInput,
|
||||
) -> RuntimeProfileRechargeCenterProcedureResult {
|
||||
match ctx.try_with_tx(|tx| create_profile_recharge_order_record(tx, input.clone())) {
|
||||
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
order: Some(order),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
order: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn list_profile_save_archive_rows(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileSaveArchiveListInput,
|
||||
@@ -775,6 +854,297 @@ fn get_profile_play_stats_snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
fn get_profile_recharge_center_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRechargeCenterGetInput,
|
||||
) -> Result<RuntimeProfileRechargeCenterSnapshot, String> {
|
||||
let validated_input = build_runtime_profile_recharge_center_get_input(input.user_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(build_profile_recharge_center_snapshot(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
))
|
||||
}
|
||||
|
||||
fn create_profile_recharge_order_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRechargeOrderCreateInput,
|
||||
) -> Result<
|
||||
(
|
||||
RuntimeProfileRechargeCenterSnapshot,
|
||||
RuntimeProfileRechargeOrderSnapshot,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let validated_input = build_runtime_profile_recharge_order_create_input(
|
||||
input.user_id,
|
||||
input.product_id,
|
||||
input.payment_channel,
|
||||
input.created_at_micros,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let product = runtime_profile_recharge_product_by_id(&validated_input.product_id)
|
||||
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
|
||||
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
|
||||
|
||||
let (points_delta, membership_expires_at) = match product.kind {
|
||||
RuntimeProfileRechargeProductKind::Points => {
|
||||
let has_recharged = has_profile_points_recharged(ctx, &validated_input.user_id);
|
||||
let bonus_points = if has_recharged {
|
||||
0
|
||||
} else {
|
||||
product.bonus_points
|
||||
};
|
||||
let points_delta = product.points_amount.saturating_add(bonus_points);
|
||||
apply_profile_wallet_delta(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
points_delta,
|
||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
|
||||
&format!(
|
||||
"{}:{}:{}",
|
||||
validated_input.user_id, validated_input.created_at_micros, product.product_id
|
||||
),
|
||||
created_at,
|
||||
)?;
|
||||
(points_delta as i64, None)
|
||||
}
|
||||
RuntimeProfileRechargeProductKind::Membership => {
|
||||
let expires_at = apply_profile_membership_purchase(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
product.tier,
|
||||
product.duration_days,
|
||||
created_at,
|
||||
);
|
||||
(0, Some(expires_at))
|
||||
}
|
||||
};
|
||||
|
||||
let order = ProfileRechargeOrder {
|
||||
order_id: format!(
|
||||
"recharge:{}:{}:{}",
|
||||
validated_input.user_id, validated_input.created_at_micros, product.product_id
|
||||
),
|
||||
user_id: validated_input.user_id.clone(),
|
||||
product_id: product.product_id.clone(),
|
||||
product_title: product.title.clone(),
|
||||
kind: product.kind,
|
||||
amount_cents: product.price_cents,
|
||||
status: RuntimeProfileRechargeOrderStatus::Paid,
|
||||
payment_channel: validated_input.payment_channel,
|
||||
paid_at: created_at,
|
||||
created_at,
|
||||
points_delta,
|
||||
membership_expires_at,
|
||||
};
|
||||
ctx.db.profile_recharge_order().insert(order);
|
||||
|
||||
let latest_order = latest_profile_recharge_order(ctx, &validated_input.user_id)
|
||||
.ok_or_else(|| "profile_recharge_order 写入后未能读取".to_string())?;
|
||||
Ok((
|
||||
build_profile_recharge_center_snapshot(ctx, &validated_input.user_id),
|
||||
build_profile_recharge_order_snapshot_from_row(&latest_order),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_profile_recharge_center_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
) -> RuntimeProfileRechargeCenterSnapshot {
|
||||
let wallet_balance = ctx
|
||||
.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.find(&user_id.to_string())
|
||||
.map(|row| row.wallet_balance)
|
||||
.unwrap_or(0);
|
||||
|
||||
RuntimeProfileRechargeCenterSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance,
|
||||
membership: build_profile_membership_snapshot(ctx, user_id),
|
||||
point_products: runtime_profile_recharge_point_products(),
|
||||
membership_products: runtime_profile_recharge_membership_products(),
|
||||
benefits: runtime_profile_membership_benefits(),
|
||||
latest_order: latest_profile_recharge_order(ctx, user_id)
|
||||
.map(|row| build_profile_recharge_order_snapshot_from_row(&row)),
|
||||
has_points_recharged: has_profile_points_recharged(ctx, user_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_membership_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
) -> RuntimeProfileMembershipSnapshot {
|
||||
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
|
||||
let membership = ctx
|
||||
.db
|
||||
.profile_membership()
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
match membership {
|
||||
Some(row) if row.expires_at.to_micros_since_unix_epoch() > now_micros => {
|
||||
RuntimeProfileMembershipSnapshot {
|
||||
user_id: row.user_id,
|
||||
status: row.status,
|
||||
tier: row.tier,
|
||||
started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()),
|
||||
expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
|
||||
}
|
||||
}
|
||||
Some(row) => RuntimeProfileMembershipSnapshot {
|
||||
user_id: row.user_id,
|
||||
status: RuntimeProfileMembershipStatus::Normal,
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()),
|
||||
expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
|
||||
},
|
||||
None => RuntimeProfileMembershipSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
status: RuntimeProfileMembershipStatus::Normal,
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
started_at_micros: None,
|
||||
expires_at_micros: None,
|
||||
updated_at_micros: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_profile_membership_purchase(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
tier: RuntimeProfileMembershipTier,
|
||||
duration_days: u32,
|
||||
purchased_at: Timestamp,
|
||||
) -> Timestamp {
|
||||
let current = ctx
|
||||
.db
|
||||
.profile_membership()
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
let purchased_at_micros = purchased_at.to_micros_since_unix_epoch();
|
||||
let start_at_micros = current
|
||||
.as_ref()
|
||||
.map(|row| row.expires_at.to_micros_since_unix_epoch())
|
||||
.filter(|expires_at_micros| *expires_at_micros > purchased_at_micros)
|
||||
.unwrap_or(purchased_at_micros);
|
||||
let expires_at = Timestamp::from_micros_since_unix_epoch(
|
||||
start_at_micros.saturating_add(duration_days as i64 * 86_400_000_000),
|
||||
);
|
||||
let created_at = current
|
||||
.as_ref()
|
||||
.map(|row| row.started_at)
|
||||
.unwrap_or(purchased_at);
|
||||
|
||||
if let Some(existing) = current {
|
||||
ctx.db
|
||||
.profile_membership()
|
||||
.user_id()
|
||||
.delete(&existing.user_id);
|
||||
}
|
||||
|
||||
ctx.db.profile_membership().insert(ProfileMembership {
|
||||
user_id: user_id.to_string(),
|
||||
status: RuntimeProfileMembershipStatus::Active,
|
||||
tier,
|
||||
started_at: created_at,
|
||||
expires_at,
|
||||
updated_at: purchased_at,
|
||||
});
|
||||
|
||||
expires_at
|
||||
}
|
||||
|
||||
fn apply_profile_wallet_delta(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
amount_delta: u64,
|
||||
source_type: RuntimeProfileWalletLedgerSourceType,
|
||||
ledger_id: &str,
|
||||
created_at: Timestamp,
|
||||
) -> Result<u64, String> {
|
||||
let current = ctx
|
||||
.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
|
||||
let next_balance = previous_balance
|
||||
.checked_add(amount_delta)
|
||||
.ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?;
|
||||
let created_state_at = current
|
||||
.as_ref()
|
||||
.map(|row| row.created_at)
|
||||
.unwrap_or(created_at);
|
||||
|
||||
if let Some(existing) = current {
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.delete(&existing.user_id);
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: next_balance,
|
||||
total_play_time_ms: existing.total_play_time_ms,
|
||||
created_at: existing.created_at,
|
||||
updated_at: created_at,
|
||||
});
|
||||
} else {
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: next_balance,
|
||||
total_play_time_ms: 0,
|
||||
created_at: created_state_at,
|
||||
updated_at: created_at,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
|
||||
wallet_ledger_id: ledger_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
amount_delta: amount_delta as i64,
|
||||
balance_after: next_balance,
|
||||
source_type,
|
||||
created_at,
|
||||
});
|
||||
|
||||
Ok(next_balance)
|
||||
}
|
||||
|
||||
fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool {
|
||||
ctx.db.profile_wallet_ledger().iter().any(|row| {
|
||||
row.user_id == user_id
|
||||
&& row.source_type == RuntimeProfileWalletLedgerSourceType::PointsRecharge
|
||||
})
|
||||
}
|
||||
|
||||
fn latest_profile_recharge_order(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
) -> Option<ProfileRechargeOrder> {
|
||||
let mut orders = ctx
|
||||
.db
|
||||
.profile_recharge_order()
|
||||
.iter()
|
||||
.filter(|row| row.user_id == user_id)
|
||||
.collect::<Vec<_>>();
|
||||
orders.sort_by(|left, right| {
|
||||
right
|
||||
.created_at
|
||||
.to_micros_since_unix_epoch()
|
||||
.cmp(&left.created_at.to_micros_since_unix_epoch())
|
||||
.then_with(|| left.order_id.cmp(&right.order_id))
|
||||
});
|
||||
orders.into_iter().next()
|
||||
}
|
||||
|
||||
fn build_profile_wallet_ledger_snapshot_from_row(
|
||||
row: &ProfileWalletLedger,
|
||||
) -> RuntimeProfileWalletLedgerEntrySnapshot {
|
||||
@@ -788,6 +1158,27 @@ fn build_profile_wallet_ledger_snapshot_from_row(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_recharge_order_snapshot_from_row(
|
||||
row: &ProfileRechargeOrder,
|
||||
) -> RuntimeProfileRechargeOrderSnapshot {
|
||||
RuntimeProfileRechargeOrderSnapshot {
|
||||
order_id: row.order_id.clone(),
|
||||
user_id: row.user_id.clone(),
|
||||
product_id: row.product_id.clone(),
|
||||
product_title: row.product_title.clone(),
|
||||
kind: row.kind,
|
||||
amount_cents: row.amount_cents,
|
||||
status: row.status,
|
||||
payment_channel: row.payment_channel.clone(),
|
||||
paid_at_micros: row.paid_at.to_micros_since_unix_epoch(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
points_delta: row.points_delta,
|
||||
membership_expires_at_micros: row
|
||||
.membership_expires_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_played_world_snapshot_from_row(
|
||||
row: &ProfilePlayedWorld,
|
||||
) -> RuntimeProfilePlayedWorldSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user