This commit is contained in:
2026-04-25 22:19:04 +08:00
parent 2ebfd1cf55
commit 8404081d7b
149 changed files with 10508 additions and 2732 deletions

View File

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