feat: 支持充值商品配置和档位首充
This commit is contained in:
@@ -193,6 +193,7 @@ macro_rules! migration_tables {
|
||||
public_work_play_daily_stat,
|
||||
public_work_like,
|
||||
profile_membership,
|
||||
profile_recharge_product_config,
|
||||
profile_recharge_order,
|
||||
profile_feedback_submission,
|
||||
profile_save_archive,
|
||||
|
||||
@@ -6,6 +6,7 @@ const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
|
||||
const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20;
|
||||
const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration";
|
||||
const PROFILE_TASK_SYSTEM_USER_ID: &str = "system:profile-task";
|
||||
const PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID: &str = "system:recharge-product";
|
||||
const PROFILE_TASK_LOGIN_EVENT_ID_PREFIX: &str = "daily-login";
|
||||
const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile";
|
||||
|
||||
@@ -328,6 +329,28 @@ pub struct ProfileMembership {
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_recharge_product_config)]
|
||||
#[derive(Clone)]
|
||||
pub struct ProfileRechargeProductConfig {
|
||||
#[primary_key]
|
||||
pub(crate) product_id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) price_cents: u64,
|
||||
pub(crate) kind: RuntimeProfileRechargeProductKind,
|
||||
pub(crate) points_amount: u64,
|
||||
pub(crate) bonus_points: u64,
|
||||
pub(crate) duration_days: u32,
|
||||
pub(crate) badge_label: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) tier: RuntimeProfileMembershipTier,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) sort_order: i32,
|
||||
pub(crate) created_by: String,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_by: String,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = profile_recharge_order,
|
||||
index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])),
|
||||
@@ -655,6 +678,44 @@ pub fn admin_disable_profile_task_config(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn admin_list_profile_recharge_products(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRechargeProductAdminListInput,
|
||||
) -> RuntimeProfileRechargeProductAdminListProcedureResult {
|
||||
match ctx.try_with_tx(|tx| list_profile_recharge_product_config_snapshots(tx, input.clone())) {
|
||||
Ok(entries) => RuntimeProfileRechargeProductAdminListProcedureResult {
|
||||
ok: true,
|
||||
entries,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRechargeProductAdminListProcedureResult {
|
||||
ok: false,
|
||||
entries: Vec::new(),
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn admin_upsert_profile_recharge_product(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRechargeProductAdminUpsertInput,
|
||||
) -> RuntimeProfileRechargeProductAdminProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_profile_recharge_product_config_record(tx, input.clone())) {
|
||||
Ok(record) => RuntimeProfileRechargeProductAdminProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRechargeProductAdminProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn grant_new_user_registration_wallet_reward(
|
||||
@@ -1454,6 +1515,22 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str)
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn point_recharge_display_is_resolved_per_product() {
|
||||
let products = runtime_profile_recharge_point_products();
|
||||
let repeated = resolve_profile_recharge_product_display(products[0].clone(), true);
|
||||
let untouched = resolve_profile_recharge_product_display(products[1].clone(), false);
|
||||
|
||||
assert_eq!(repeated.product_id, "points_60");
|
||||
assert_eq!(repeated.bonus_points, 0);
|
||||
assert_eq!(repeated.badge_label, "");
|
||||
assert_eq!(repeated.description, "60泥点");
|
||||
assert_eq!(untouched.product_id, "points_180");
|
||||
assert_eq!(untouched.bonus_points, 180);
|
||||
assert_eq!(untouched.badge_label, "首充双倍");
|
||||
assert_eq!(untouched.description, "首充送180泥点");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() {
|
||||
assert!(should_skip_existing_tracking_event_id(true));
|
||||
@@ -2091,8 +2168,8 @@ fn create_profile_recharge_order_record(
|
||||
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 product = enabled_profile_recharge_product_by_id(ctx, &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 should_settle_immediately =
|
||||
validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK;
|
||||
@@ -2201,7 +2278,7 @@ fn mark_profile_recharge_order_paid_record(
|
||||
return Err("profile_recharge_order 当前状态不能确认支付".to_string());
|
||||
}
|
||||
|
||||
let product = runtime_profile_recharge_product_by_id(&order.product_id)
|
||||
let product = profile_recharge_product_by_id(ctx, &order.product_id)
|
||||
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
|
||||
let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros);
|
||||
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
|
||||
@@ -2238,7 +2315,7 @@ fn apply_profile_recharge_purchase(
|
||||
) -> Result<(i64, Option<Timestamp>), String> {
|
||||
match product.kind {
|
||||
RuntimeProfileRechargeProductKind::Points => {
|
||||
let has_recharged = has_profile_points_recharged(ctx, user_id);
|
||||
let has_recharged = has_profile_product_recharged(ctx, user_id, &product.product_id);
|
||||
let points_delta =
|
||||
resolve_runtime_profile_points_recharge_delta(product, has_recharged);
|
||||
apply_profile_wallet_delta(
|
||||
@@ -2907,6 +2984,7 @@ fn build_profile_recharge_center_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
) -> RuntimeProfileRechargeCenterSnapshot {
|
||||
ensure_default_profile_recharge_product_config(ctx);
|
||||
let wallet_balance = ctx
|
||||
.db
|
||||
.profile_dashboard_state()
|
||||
@@ -2916,13 +2994,31 @@ fn build_profile_recharge_center_snapshot(
|
||||
.unwrap_or(0);
|
||||
|
||||
let has_points_recharged = has_profile_points_recharged(ctx, user_id);
|
||||
let mut point_products = Vec::new();
|
||||
let mut membership_products = Vec::new();
|
||||
for row in profile_recharge_product_config_rows(ctx, false) {
|
||||
let product = build_profile_recharge_product_snapshot_from_config_row(&row);
|
||||
match product.kind {
|
||||
RuntimeProfileRechargeProductKind::Points => {
|
||||
let has_product_recharged =
|
||||
has_profile_product_recharged(ctx, user_id, &product.product_id);
|
||||
point_products.push(resolve_profile_recharge_product_display(
|
||||
product,
|
||||
has_product_recharged,
|
||||
));
|
||||
}
|
||||
RuntimeProfileRechargeProductKind::Membership => {
|
||||
membership_products.push(product);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RuntimeProfileRechargeCenterSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance,
|
||||
membership: build_profile_membership_snapshot(ctx, user_id),
|
||||
point_products: resolve_runtime_profile_recharge_point_products(has_points_recharged),
|
||||
membership_products: runtime_profile_recharge_membership_products(),
|
||||
point_products,
|
||||
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)),
|
||||
@@ -3052,6 +3148,21 @@ fn list_profile_task_config_snapshots(
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn list_profile_recharge_product_config_snapshots(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRechargeProductAdminListInput,
|
||||
) -> Result<Vec<RuntimeProfileRechargeProductConfigSnapshot>, String> {
|
||||
let _validated_input =
|
||||
build_runtime_profile_recharge_product_admin_list_input(input.admin_user_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
ensure_default_profile_recharge_product_config(ctx);
|
||||
|
||||
Ok(profile_recharge_product_config_rows(ctx, true)
|
||||
.iter()
|
||||
.map(build_profile_recharge_product_config_snapshot_from_row)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn admin_list_profile_redeem_code_records(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRedeemCodeAdminListInput,
|
||||
@@ -3097,6 +3208,74 @@ fn admin_list_profile_invite_code_records(
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn upsert_profile_recharge_product_config_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRechargeProductAdminUpsertInput,
|
||||
) -> Result<RuntimeProfileRechargeProductConfigSnapshot, String> {
|
||||
let validated_input = build_runtime_profile_recharge_product_admin_upsert_input(
|
||||
input.admin_user_id,
|
||||
input.product_id,
|
||||
input.title,
|
||||
input.price_cents,
|
||||
input.kind,
|
||||
input.points_amount,
|
||||
input.bonus_points,
|
||||
input.duration_days,
|
||||
input.badge_label,
|
||||
input.description,
|
||||
input.tier,
|
||||
input.enabled,
|
||||
input.sort_order,
|
||||
input.updated_at_micros,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
ensure_default_profile_recharge_product_config(ctx);
|
||||
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
|
||||
let existing = ctx
|
||||
.db
|
||||
.profile_recharge_product_config()
|
||||
.product_id()
|
||||
.find(&validated_input.product_id);
|
||||
if let Some(row) = existing.as_ref() {
|
||||
ctx.db
|
||||
.profile_recharge_product_config()
|
||||
.product_id()
|
||||
.delete(&row.product_id);
|
||||
}
|
||||
|
||||
let inserted = ctx
|
||||
.db
|
||||
.profile_recharge_product_config()
|
||||
.insert(ProfileRechargeProductConfig {
|
||||
product_id: validated_input.product_id,
|
||||
title: validated_input.title,
|
||||
price_cents: validated_input.price_cents,
|
||||
kind: validated_input.kind,
|
||||
points_amount: validated_input.points_amount,
|
||||
bonus_points: validated_input.bonus_points,
|
||||
duration_days: validated_input.duration_days,
|
||||
badge_label: validated_input.badge_label,
|
||||
description: validated_input.description,
|
||||
tier: validated_input.tier,
|
||||
enabled: validated_input.enabled,
|
||||
sort_order: validated_input.sort_order,
|
||||
created_by: existing
|
||||
.as_ref()
|
||||
.map(|row| row.created_by.clone())
|
||||
.unwrap_or_else(|| validated_input.admin_user_id.clone()),
|
||||
created_at: existing
|
||||
.as_ref()
|
||||
.map(|row| row.created_at)
|
||||
.unwrap_or(updated_at),
|
||||
updated_by: validated_input.admin_user_id,
|
||||
updated_at,
|
||||
});
|
||||
Ok(build_profile_recharge_product_config_snapshot_from_row(
|
||||
&inserted,
|
||||
))
|
||||
}
|
||||
|
||||
fn upsert_profile_task_config_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileTaskConfigAdminUpsertInput,
|
||||
@@ -3518,6 +3697,96 @@ fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) {
|
||||
if ctx.db.profile_recharge_product_config().count() > 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = ctx.timestamp;
|
||||
for (sort_order, product) in runtime_profile_recharge_point_products()
|
||||
.into_iter()
|
||||
.chain(runtime_profile_recharge_membership_products())
|
||||
.enumerate()
|
||||
{
|
||||
ctx.db
|
||||
.profile_recharge_product_config()
|
||||
.insert(ProfileRechargeProductConfig {
|
||||
product_id: product.product_id,
|
||||
title: product.title,
|
||||
price_cents: product.price_cents,
|
||||
kind: product.kind,
|
||||
points_amount: product.points_amount,
|
||||
bonus_points: product.bonus_points,
|
||||
duration_days: product.duration_days,
|
||||
badge_label: product.badge_label,
|
||||
description: product.description,
|
||||
tier: product.tier,
|
||||
enabled: true,
|
||||
sort_order: sort_order as i32,
|
||||
created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(),
|
||||
created_at: now,
|
||||
updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(),
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_recharge_product_config_rows(
|
||||
ctx: &ReducerContext,
|
||||
include_disabled: bool,
|
||||
) -> Vec<ProfileRechargeProductConfig> {
|
||||
ensure_default_profile_recharge_product_config(ctx);
|
||||
let mut rows = ctx
|
||||
.db
|
||||
.profile_recharge_product_config()
|
||||
.iter()
|
||||
.filter(|row| include_disabled || row.enabled)
|
||||
.collect::<Vec<_>>();
|
||||
rows.sort_by(|left, right| {
|
||||
left.sort_order
|
||||
.cmp(&right.sort_order)
|
||||
.then_with(|| left.product_id.cmp(&right.product_id))
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
fn profile_recharge_product_by_id(
|
||||
ctx: &ReducerContext,
|
||||
product_id: &str,
|
||||
) -> Option<RuntimeProfileRechargeProductSnapshot> {
|
||||
ensure_default_profile_recharge_product_config(ctx);
|
||||
ctx.db
|
||||
.profile_recharge_product_config()
|
||||
.product_id()
|
||||
.find(&product_id.to_string())
|
||||
.map(|row| build_profile_recharge_product_snapshot_from_config_row(&row))
|
||||
}
|
||||
|
||||
fn enabled_profile_recharge_product_by_id(
|
||||
ctx: &ReducerContext,
|
||||
product_id: &str,
|
||||
) -> Option<RuntimeProfileRechargeProductSnapshot> {
|
||||
ensure_default_profile_recharge_product_config(ctx);
|
||||
ctx.db
|
||||
.profile_recharge_product_config()
|
||||
.product_id()
|
||||
.find(&product_id.to_string())
|
||||
.filter(|row| row.enabled)
|
||||
.map(|row| build_profile_recharge_product_snapshot_from_config_row(&row))
|
||||
}
|
||||
|
||||
fn resolve_profile_recharge_product_display(
|
||||
mut product: RuntimeProfileRechargeProductSnapshot,
|
||||
has_product_recharged: bool,
|
||||
) -> RuntimeProfileRechargeProductSnapshot {
|
||||
if product.kind == RuntimeProfileRechargeProductKind::Points && has_product_recharged {
|
||||
product.bonus_points = 0;
|
||||
product.badge_label.clear();
|
||||
product.description = product.title.clone();
|
||||
}
|
||||
product
|
||||
}
|
||||
|
||||
fn build_profile_membership_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
@@ -3754,9 +4023,19 @@ fn apply_profile_wallet_signed_delta(
|
||||
}
|
||||
|
||||
fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool {
|
||||
ctx.db.profile_wallet_ledger().iter().any(|row| {
|
||||
ctx.db.profile_recharge_order().iter().any(|row| {
|
||||
row.user_id == user_id
|
||||
&& row.source_type == RuntimeProfileWalletLedgerSourceType::PointsRecharge
|
||||
&& row.kind == RuntimeProfileRechargeProductKind::Points
|
||||
&& row.status == RuntimeProfileRechargeOrderStatus::Paid
|
||||
})
|
||||
}
|
||||
|
||||
fn has_profile_product_recharged(ctx: &ReducerContext, user_id: &str, product_id: &str) -> bool {
|
||||
ctx.db.profile_recharge_order().iter().any(|row| {
|
||||
row.user_id == user_id
|
||||
&& row.product_id == product_id
|
||||
&& row.kind == RuntimeProfileRechargeProductKind::Points
|
||||
&& row.status == RuntimeProfileRechargeOrderStatus::Paid
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3894,6 +4173,46 @@ fn build_profile_task_config_snapshot_from_row(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_recharge_product_config_snapshot_from_row(
|
||||
row: &ProfileRechargeProductConfig,
|
||||
) -> RuntimeProfileRechargeProductConfigSnapshot {
|
||||
RuntimeProfileRechargeProductConfigSnapshot {
|
||||
product_id: row.product_id.clone(),
|
||||
title: row.title.clone(),
|
||||
price_cents: row.price_cents,
|
||||
kind: row.kind,
|
||||
points_amount: row.points_amount,
|
||||
bonus_points: row.bonus_points,
|
||||
duration_days: row.duration_days,
|
||||
badge_label: row.badge_label.clone(),
|
||||
description: row.description.clone(),
|
||||
tier: row.tier,
|
||||
enabled: row.enabled,
|
||||
sort_order: row.sort_order,
|
||||
created_by: row.created_by.clone(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
updated_by: row.updated_by.clone(),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_recharge_product_snapshot_from_config_row(
|
||||
row: &ProfileRechargeProductConfig,
|
||||
) -> RuntimeProfileRechargeProductSnapshot {
|
||||
RuntimeProfileRechargeProductSnapshot {
|
||||
product_id: row.product_id.clone(),
|
||||
title: row.title.clone(),
|
||||
price_cents: row.price_cents,
|
||||
kind: row.kind,
|
||||
points_amount: row.points_amount,
|
||||
bonus_points: row.bonus_points,
|
||||
duration_days: row.duration_days,
|
||||
badge_label: row.badge_label.clone(),
|
||||
description: row.description.clone(),
|
||||
tier: row.tier,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_recharge_order_snapshot_from_row(
|
||||
row: &ProfileRechargeOrder,
|
||||
) -> RuntimeProfileRechargeOrderSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user