From c772600d594066e6c2f3fef83657d1fc1189920d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 17:36:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=A1=A5=E5=B7=AE=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E3=80=81=E5=90=8E=E5=8F=B0=E6=A1=A3=E4=BD=8D=E8=AF=BB?= =?UTF-8?q?=E5=86=99=E4=B8=8E=E8=AE=A2=E5=8D=95=E6=9D=83=E7=9B=8A=E5=BF=AB?= =?UTF-8?q?=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /api/profile/membership/upgrade-quote:只读报价,下单时后端用同一纯函数重算 新增后台 /admin/api/profile/membership-plans 的读取与按档位改价改权益接口 订单与管理端契约补上会员权益变更快照(变更前后档位、周期、补点、有效期与价格拆分) 报价结果统一走「模块侧快照 + application 记录侧投影」两段式,与既有 DTO 约定一致 Co-authored-by: Junie --- server-rs/crates/api-server/src/admin.rs | 6 + .../crates/api-server/src/modules/admin.rs | 10 +- .../crates/api-server/src/modules/profile.rs | 12 +- .../crates/api-server/src/runtime_profile.rs | 148 +++++++++++++++++- .../crates/module-runtime/src/application.rs | 20 +++ .../crates/module-runtime/src/commands.rs | 11 ++ server-rs/crates/module-runtime/src/domain.rs | 50 ++++++ .../crates/shared-contracts/src/runtime.rs | 54 +++++++ .../crates/spacetime-client/src/active.rs | 2 + .../src/active/mapper/runtime_profile.rs | 39 +++++ .../spacetime-client/src/active/runtime.rs | 33 ++++ .../spacetime-client/src/module_bindings.rs | 8 + ...file_membership_upgrade_quote_procedure.rs | 61 ++++++++ ...membership_upgrade_quote_get_input_type.rs | 18 +++ ...hip_upgrade_quote_procedure_result_type.rs | 19 +++ ...le_membership_upgrade_quote_record_type.rs | 25 +++ .../src/runtime/active/profile.rs | 75 +++++++++ 17 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 5b3e3afc5..3ca5513e3 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2238,6 +2238,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission "/admin/api/profile/tasks" | "/admin/api/profile/tasks/disable" => AnyTab(&["tasks"]), "/admin/api/profile/wallet-config" => AnyTab(&["profile-wallet"]), "/admin/api/profile/recharge-products" => AnyTab(&["recharge-products"]), + "/admin/api/profile/membership-plans" => AnyTab(&["membership-plans"]), path if path.starts_with("/admin/api/profile/recharge-") => AnyTab(&["recharge-orders"]), "/admin/api/profile/wallet-restriction" => AnyTab(&["recharge-orders"]), "/admin/api/profile/users/reconcile-consumption" => { @@ -7444,6 +7445,11 @@ mod tests { Method::GET, "/admin/api/profile/recharge-products", ), + ( + "membership-plans", + Method::GET, + "/admin/api/profile/membership-plans", + ), ( "recharge-orders", Method::GET, diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index e16b4a82b..e8eefc21c 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -28,8 +28,9 @@ use crate::{ runtime_profile::{ admin_disable_profile_redeem_code, admin_disable_profile_task_config, admin_get_profile_wallet_config, admin_list_profile_invite_codes, - admin_list_profile_recharge_products, admin_list_profile_redeem_codes, - admin_list_profile_task_configs, admin_upsert_profile_invite_code, + admin_list_profile_membership_plans, admin_list_profile_recharge_products, + admin_list_profile_redeem_codes, admin_list_profile_task_configs, + admin_upsert_profile_invite_code, admin_upsert_profile_membership_plan, admin_upsert_profile_recharge_product, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, admin_upsert_profile_wallet_config, }, @@ -170,6 +171,10 @@ pub fn router(state: AppState) -> Router { "/admin/api/profile/recharge-products", get(admin_list_profile_recharge_products).post(admin_upsert_profile_recharge_product), ), + ( + "/admin/api/profile/membership-plans", + get(admin_list_profile_membership_plans).post(admin_upsert_profile_membership_plan), + ), ( "/admin/api/profile/recharge-orders", get(admin_list_recharge_orders), @@ -277,6 +282,7 @@ mod route_contract_tests { ("/admin/api/profile/tasks/disable", &["POST"]), ("/admin/api/profile/wallet-config", &["GET", "POST"]), ("/admin/api/profile/recharge-products", &["GET", "POST"]), + ("/admin/api/profile/membership-plans", &["GET", "POST"]), ("/admin/api/profile/recharge-orders", &["GET"]), ("/admin/api/profile/recharge-refunds/preview", &["POST"]), ("/admin/api/profile/recharge-refunds/execute", &["POST"]), diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs index de6768102..633424889 100644 --- a/server-rs/crates/api-server/src/modules/profile.rs +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -13,8 +13,9 @@ use crate::{ runtime_profile::{ claim_profile_task_reward, confirm_wechat_profile_recharge_order, create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard, - get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center, - get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code, + get_profile_membership_upgrade_quote, get_profile_recharge_center, + get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger, + redeem_profile_referral_invite_code, redeem_profile_reward_code, stream_wechat_profile_recharge_order_events, submit_profile_feedback, }, state::AppState, @@ -73,6 +74,13 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/membership/upgrade-quote", + post(get_profile_membership_upgrade_quote).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/profile/recharge/orders", post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index c656ec873..8083f5065 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -34,9 +34,10 @@ use shared_contracts::runtime::{ ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, - AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, - AdminUpsertProfileTaskConfigRequest, AdminUpsertProfileWalletConfigRequest, - AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, + AdminUpsertProfileMembershipPlanRequest, AdminUpsertProfileRechargeProductRequest, + AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, + AdminUpsertProfileWalletConfigRequest, AnalyticsBucketMetricResponse, + AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP, PROFILE_RECHARGE_PRODUCT_KIND_POINTS, @@ -61,7 +62,9 @@ use shared_contracts::runtime::{ ProfileDailyFreePointsResponse, ProfileDashboardSummaryResponse, ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, - ProfileMembershipPlanResponse, ProfileMembershipResponse, ProfileMudPointBalanceResponse, + ProfileMembershipPlanAdminListResponse, ProfileMembershipPlanResponse, + ProfileMembershipResponse, ProfileMembershipUpgradeQuoteRequest, + ProfileMembershipUpgradeQuoteResponse, ProfileMudPointBalanceResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse, @@ -240,6 +243,50 @@ pub async fn get_profile_recharge_center( )) } +/// 补差升级报价:只读。入参是目标档位,周期沿用当前会员周期(有效期内不支持月年互换)。 +pub async fn get_profile_membership_upgrade_quote( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let target_plan = + parse_runtime_profile_membership_plan(&payload.target_plan).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let quote = state + .spacetime_client() + .get_profile_membership_upgrade_quote(user_id, target_plan) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileMembershipUpgradeQuoteResponse { + current_plan: quote.current_plan, + target_plan: quote.target_plan, + cycle_kind: quote.cycle_kind, + cycle_index: quote.cycle_index, + cycle_count: quote.cycle_count, + amount_cents: quote.amount_cents, + granted_points_delta: quote.granted_points_delta, + monthly_balance_after: quote.monthly_balance_after, + expires_at: Some(quote.expires_at), + cycle_resets_at: Some(quote.cycle_resets_at), + remaining_full_months: quote.remaining_full_months, + }, + )) +} + pub async fn create_profile_recharge_order( State(state): State, Extension(request_context): Extension, @@ -1117,6 +1164,83 @@ pub async fn admin_upsert_profile_recharge_product( )) } +pub async fn admin_list_profile_membership_plans( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, +) -> Result, Response> { + let entries = state + .spacetime_client() + .admin_list_profile_membership_plans(admin.session().subject.clone()) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileMembershipPlanAdminListResponse { + entries: entries + .into_iter() + .map(build_profile_membership_plan_response) + .collect(), + }, + )) +} + +/// 后台按档位改会员价格与权益。`rank` 不在请求里:升级比较基准由代码内目录决定。 +pub async fn admin_upsert_profile_membership_plan( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let plan = parse_runtime_profile_membership_plan(&payload.plan).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let model_access = parse_runtime_profile_membership_model_access(&payload.model_access) + .map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .admin_upsert_profile_membership_plan( + admin.session().subject.clone(), + plan, + payload.title, + payload.month_price_cents, + payload.year_price_cents, + payload.period_points, + model_access, + payload.concurrent_job_limit, + payload.enabled, + payload.sort_order.unwrap_or(10), + 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_membership_plan_response(record), + )) +} + pub async fn admin_list_profile_redeem_codes( State(state): State, Extension(request_context): Extension, @@ -2294,6 +2418,22 @@ fn parse_profile_recharge_product_kind( } } +/// 会员档位 token → 枚举。JSON 没有枚举,但解析必须严格:未知 token 一律 400。 +fn parse_runtime_profile_membership_plan( + raw: &str, +) -> Result { + module_runtime::RuntimeProfileMembershipPlan::parse(raw) + .ok_or_else(|| "会员档位无效".to_string()) +} + +/// 模型权限 token → 枚举;`basic` = 仅基础模型,`full` = 基础及高性能模型。 +fn parse_runtime_profile_membership_model_access( + raw: &str, +) -> Result { + module_runtime::RuntimeProfileMembershipModelAccess::parse(raw) + .ok_or_else(|| "模型权限无效".to_string()) +} + fn parse_tracking_scope_kind(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site), diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 4779ec1fa..38454c755 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -1128,6 +1128,26 @@ pub fn build_runtime_profile_membership_record( } } +pub fn build_runtime_profile_membership_upgrade_quote_record( + snapshot: RuntimeProfileMembershipUpgradeQuoteSnapshot, +) -> RuntimeProfileMembershipUpgradeQuoteRecord { + RuntimeProfileMembershipUpgradeQuoteRecord { + current_plan: snapshot.current_plan, + target_plan: snapshot.target_plan, + cycle_kind: snapshot.cycle_kind, + cycle_index: snapshot.cycle_index, + cycle_count: snapshot.cycle_count, + amount_cents: snapshot.amount_cents, + granted_points_delta: snapshot.granted_points_delta, + monthly_balance_after: snapshot.monthly_balance_after, + expires_at: format_utc_micros(snapshot.expires_at_micros), + expires_at_micros: snapshot.expires_at_micros, + cycle_resets_at: format_utc_micros(snapshot.cycle_resets_at_micros), + cycle_resets_at_micros: snapshot.cycle_resets_at_micros, + remaining_full_months: snapshot.remaining_full_months, + } +} + pub fn build_runtime_profile_recharge_order_record( snapshot: RuntimeProfileRechargeOrderSnapshot, ) -> RuntimeProfileRechargeOrderRecord { diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index ce8f521f3..02d11db91 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -499,6 +499,17 @@ pub fn build_runtime_profile_membership_plan_admin_upsert_input( }) } +pub fn build_runtime_profile_membership_upgrade_quote_input( + user_id: String, + target_plan: RuntimeProfileMembershipPlan, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + Ok(RuntimeProfileMembershipUpgradeQuoteGetInput { + user_id, + target_plan, + }) +} + pub fn build_runtime_profile_recharge_order_paid_input( order_id: String, paid_at_micros: i64, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 6f48a750d..dd5f8d519 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1846,6 +1846,56 @@ pub struct RuntimeProfileMembershipPlanAdminProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteGetInput { + pub user_id: String, + pub target_plan: RuntimeProfileMembershipPlan, +} + +/// 补差升级报价快照(模块侧,时间戳为 micros)。报价与下单重算共用同一套规则。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteSnapshot { + pub current_plan: String, + pub target_plan: String, + pub cycle_kind: String, + pub cycle_index: u32, + pub cycle_count: u32, + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub remaining_full_months: u32, +} + +/// 补差升级报价的只读投影(记录侧,附 RFC3339 时间)。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteRecord { + pub current_plan: String, + pub target_plan: String, + pub cycle_kind: String, + pub cycle_index: u32, + pub cycle_count: u32, + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at: String, + pub expires_at_micros: i64, + pub cycle_resets_at: String, + pub cycle_resets_at_micros: i64, + pub remaining_full_months: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { + pub ok: bool, + pub quote: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeCenterGetInput { diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index e00836bdb..6e21e9015 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -665,6 +665,60 @@ pub struct AdminUpsertProfileRechargeProductRequest { pub sort_order: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileMembershipUpgradeQuoteRequest { + /// 目标档位 token:`starter` / `plus` / `pro` / `max`。 + pub target_plan: String, +} + +/// 补差升级报价:付款前展示应付金额、补点与变更后的月度余额;下单时后端会用同一函数重算。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileMembershipUpgradeQuoteResponse { + pub current_plan: String, + pub target_plan: String, + /// 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致。 + pub cycle_kind: String, + pub cycle_index: u32, + pub cycle_count: u32, + /// 应付金额(分);月付 = 月价差,年付 = 年价差 ÷ 12 × (后续完整月数 + 本期剩余比例),向上取整到分。 + pub amount_cents: u64, + /// 本期补点;年付按本期剩余比例向下取整。 + pub granted_points_delta: u64, + /// 变更后月度余额 = 原剩余月度余额 + 补点(已用点不返还,永久泥点不动)。 + pub monthly_balance_after: u64, + pub expires_at: Option, + pub cycle_resets_at: Option, + pub remaining_full_months: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileMembershipPlanAdminListResponse { + pub entries: Vec, +} + +/// 后台按档位改会员价格与权益;`rank` 不在请求里,升级比较基准由代码内目录决定。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertProfileMembershipPlanRequest { + /// 档位 token,作为主键即档位身份。 + pub plan: String, + pub title: String, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + /// 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。 + pub model_access: String, + /// 并发上限;`128` 表示不设上限。 + pub concurrent_job_limit: u32, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub sort_order: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AdminUpsertProfileWalletConfigRequest { diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 86921c9ca..61f50ea68 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -89,6 +89,8 @@ use module_runtime::{ build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, build_runtime_profile_membership_plan_admin_list_input, build_runtime_profile_membership_plan_admin_upsert_input, + build_runtime_profile_membership_upgrade_quote_input, + build_runtime_profile_membership_upgrade_quote_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_close_input, build_runtime_profile_recharge_order_create_input, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index b61af2978..bc7d939be 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -2046,6 +2046,17 @@ pub(crate) fn map_runtime_profile_membership_order_change_snapshot( } } +impl From + for RuntimeProfileMembershipUpgradeQuoteGetInput +{ + fn from(input: module_runtime::RuntimeProfileMembershipUpgradeQuoteGetInput) -> Self { + Self { + user_id: input.user_id, + target_plan: map_runtime_profile_membership_plan(input.target_plan), + } + } +} + impl From for RuntimeProfileMembershipPlanAdminListInput { @@ -2076,6 +2087,34 @@ impl From } } +pub(crate) fn map_runtime_profile_membership_upgrade_quote_procedure_result( + result: RuntimeProfileMembershipUpgradeQuoteProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let quote = result + .quote + .ok_or_else(|| SpacetimeClientError::missing_snapshot("会员升级报价"))?; + + Ok(build_runtime_profile_membership_upgrade_quote_record( + module_runtime::RuntimeProfileMembershipUpgradeQuoteSnapshot { + current_plan: quote.current_plan, + target_plan: quote.target_plan, + cycle_kind: quote.cycle_kind, + cycle_index: quote.cycle_index, + cycle_count: quote.cycle_count, + amount_cents: quote.amount_cents, + granted_points_delta: quote.granted_points_delta, + monthly_balance_after: quote.monthly_balance_after, + expires_at_micros: quote.expires_at_micros, + cycle_resets_at_micros: quote.cycle_resets_at_micros, + remaining_full_months: quote.remaining_full_months, + }, + )) +} + pub(crate) fn map_runtime_profile_membership_plan_admin_list_procedure_result( result: RuntimeProfileMembershipPlanAdminListProcedureResult, ) -> Result, SpacetimeClientError> { diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index 866d78b5c..83895efd5 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -1695,6 +1695,39 @@ impl SpacetimeClient { .await } + /// 补差升级报价:只读,用来在付款前展示应付金额与补点;下单时后端会用同一函数重算。 + pub async fn get_profile_membership_upgrade_quote( + &self, + user_id: String, + target_plan: module_runtime::RuntimeProfileMembershipPlan, + ) -> Result + { + let procedure_input = + build_runtime_profile_membership_upgrade_quote_input(user_id, target_plan) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "get_profile_membership_upgrade_quote", + move |connection, sender| { + connection + .procedures() + .get_profile_membership_upgrade_quote_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_membership_upgrade_quote_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + /// 后台读取会员档位目录(价格与权益的唯一真相源)。 pub async fn admin_list_profile_membership_plans( &self, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 3615b2453..637214065 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -478,6 +478,7 @@ pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; pub mod get_profile_dashboard_procedure; +pub mod get_profile_membership_upgrade_quote_procedure; pub mod get_profile_recharge_center_procedure; pub mod get_profile_recharge_order_and_return_procedure; pub mod get_profile_recharge_refund_and_return_procedure; @@ -662,6 +663,9 @@ pub mod runtime_profile_membership_plan_record_type; pub mod runtime_profile_membership_plan_type; pub mod runtime_profile_membership_snapshot_type; pub mod runtime_profile_membership_status_type; +pub mod runtime_profile_membership_upgrade_quote_get_input_type; +pub mod runtime_profile_membership_upgrade_quote_procedure_result_type; +pub mod runtime_profile_membership_upgrade_quote_record_type; pub mod runtime_profile_recharge_center_get_input_type; pub mod runtime_profile_recharge_center_procedure_result_type; pub mod runtime_profile_recharge_center_snapshot_type; @@ -1291,6 +1295,7 @@ pub use get_game_distribution_version_and_return_procedure::get_game_distributio pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; +pub use get_profile_membership_upgrade_quote_procedure::get_profile_membership_upgrade_quote; pub use get_profile_recharge_center_procedure::get_profile_recharge_center; pub use get_profile_recharge_order_and_return_procedure::get_profile_recharge_order_and_return; pub use get_profile_recharge_refund_and_return_procedure::get_profile_recharge_refund_and_return; @@ -1475,6 +1480,9 @@ pub use runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPla pub use runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; +pub use runtime_profile_membership_upgrade_quote_get_input_type::RuntimeProfileMembershipUpgradeQuoteGetInput; +pub use runtime_profile_membership_upgrade_quote_procedure_result_type::RuntimeProfileMembershipUpgradeQuoteProcedureResult; +pub use runtime_profile_membership_upgrade_quote_record_type::RuntimeProfileMembershipUpgradeQuoteRecord; pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCenterGetInput; pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs new file mode 100644 index 000000000..dc37950df --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs @@ -0,0 +1,61 @@ +// 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_membership_upgrade_quote_get_input_type::RuntimeProfileMembershipUpgradeQuoteGetInput; +use super::runtime_profile_membership_upgrade_quote_procedure_result_type::RuntimeProfileMembershipUpgradeQuoteProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileMembershipUpgradeQuoteArgs { + pub input: RuntimeProfileMembershipUpgradeQuoteGetInput, +} + +impl __sdk::InModule for GetProfileMembershipUpgradeQuoteArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_membership_upgrade_quote`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_membership_upgrade_quote { + fn get_profile_membership_upgrade_quote( + &self, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, + ) { + self.get_profile_membership_upgrade_quote_then(input, |_, _| {}); + } + + fn get_profile_membership_upgrade_quote_then( + &self, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_membership_upgrade_quote for super::RemoteProcedures { + fn get_profile_membership_upgrade_quote_then( + &self, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileMembershipUpgradeQuoteProcedureResult>( + "get_profile_membership_upgrade_quote", + GetProfileMembershipUpgradeQuoteArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs new file mode 100644 index 000000000..2d554d508 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs @@ -0,0 +1,18 @@ +// 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_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipUpgradeQuoteGetInput { + pub user_id: String, + pub target_plan: RuntimeProfileMembershipPlan, +} + +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs new file mode 100644 index 000000000..92168b485 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs @@ -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_membership_upgrade_quote_record_type::RuntimeProfileMembershipUpgradeQuoteRecord; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { + pub ok: bool, + pub quote: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs new file mode 100644 index 000000000..504002690 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs @@ -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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipUpgradeQuoteRecord { + pub current_plan: String, + pub target_plan: String, + pub cycle_kind: String, + pub cycle_index: u32, + pub cycle_count: u32, + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub remaining_full_months: u32, +} + +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteRecord { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index e6da80f97..7a1867ce7 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -1192,6 +1192,26 @@ pub fn admin_upsert_profile_membership_plan( } } +// 补差升级报价:只读,下单时后端会用同一函数重算并落订单快照,前端展示的数字不是真相源。 +#[spacetimedb::procedure] +pub fn get_profile_membership_upgrade_quote( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, +) -> RuntimeProfileMembershipUpgradeQuoteProcedureResult { + match ctx.try_with_tx(|tx| build_profile_membership_upgrade_quote_record(tx, input.clone())) { + Ok(quote) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { + ok: true, + quote: Some(quote), + error_message: None, + }, + Err(message) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { + ok: false, + quote: None, + error_message: Some(message), + }, + } +} + // 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。 #[spacetimedb::procedure] pub fn grant_new_user_registration_wallet_reward( @@ -8044,6 +8064,61 @@ fn list_profile_membership_plan_records( Ok(membership_plan_records(ctx)) } +/// 补差升级报价。非会员、降档、月年互换、当前套餐重复购买都返回可读错误,不做静默兜底。 +fn build_profile_membership_upgrade_quote_record( + ctx: &ReducerContext, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, +) -> Result { + let validated = + build_runtime_profile_membership_upgrade_quote_input(input.user_id, input.target_plan) + .map_err(|error| error.to_string())?; + refresh_profile_wallet_expiring_points(ctx, &validated.user_id, ctx.timestamp); + + let Some(row) = ctx + .db + .profile_membership() + .user_id() + .find(&validated.user_id.to_string()) + .filter(|row| active_membership_row_at(row, ctx.timestamp)) + else { + return Err("当前不是会员,请直接选择套餐购买".to_string()); + }; + if row.plan == validated.target_plan { + return Err("当前套餐已生效,无需重复购买".to_string()); + } + let target = membership_plan_snapshot(ctx, validated.target_plan, row.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", validated.target_plan.as_str()))?; + if !target.enabled { + return Err(format!( + "会员档位 {} 当前不可购买", + validated.target_plan.as_str() + )); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + &row, + ¤t, + &target, + ctx.timestamp, + )) + .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + + Ok(RuntimeProfileMembershipUpgradeQuoteSnapshot { + current_plan: current.plan.as_str().to_string(), + target_plan: target.plan.as_str().to_string(), + cycle_kind: target.cycle_kind.as_str().to_string(), + cycle_index: quote.cycle_index, + cycle_count: target.cycle_count, + amount_cents: quote.amount_cents, + granted_points_delta: quote.granted_points_delta, + monthly_balance_after: quote.monthly_balance_after, + expires_at_micros: quote.expires_at_micros, + cycle_resets_at_micros: quote.cycle_resets_at_micros, + remaining_full_months: quote.remaining_full_months, + }) +} + /// 后台改价 / 改权益。`rank` 与 `plan` 不可改:`plan` 是主键(即身份),`rank` 是升级比较基准。 fn upsert_profile_membership_plan_record( ctx: &ReducerContext,