diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 833f397c0..ee069594f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2656,6 +2656,11 @@ mod tests { RuntimeProfileMembershipErrorCode::PlanCatalogMissing, "会员目录缺少档位 pro", ), + // 目录价格 / 每期泥点倒挂属后端配置错误,与 UpgradeRejected 分开暴露机器码。 + ( + RuntimeProfileMembershipErrorCode::InvalidPlanConfig, + "会员升级被拒绝: catalog_pricing_inverted", + ), ] { let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { code: code.into(), diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 057336e08..1569ce7d2 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1506,7 +1506,7 @@ pub enum RuntimeProfileMembershipErrorCode { ProductIdUnparseable, /// 会员商品已迁移到档位目录,旧商品不再可用。 MembershipProductRetired, - /// 后台会员档位改价 / 改权益配置校验失败。 + /// 后台会员档位改价 / 改权益配置校验失败,或读取目录时发现价格 / 每期泥点倒挂。 InvalidPlanConfig, } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 116e51d10..a38eb90b7 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -27,12 +27,13 @@ pub use llm_billing::*; pub use membership::{ MEMBERSHIP_UNLIMITED_CONCURRENCY, PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipPlanSnapshot, - RuntimeProfileMembershipUpgradeQuoteInput, beijing_local_micros, - build_runtime_profile_membership_plan_snapshot, check_expired_membership_upgrade_settlement, - is_unlimited_concurrency, membership_cycle_window, membership_expires_at, - parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade, - resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, - runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, + RuntimeProfileMembershipUpgradeQuoteInput, RuntimeProfileMembershipUpgradeRejection, + beijing_local_micros, build_runtime_profile_membership_plan_snapshot, + check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window, + membership_expires_at, parse_runtime_profile_membership_product_id, + quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, + runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, + runtime_profile_membership_product_id, validate_runtime_profile_membership_plan_row_against_catalog, }; diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 3a6786325..e2677ec35 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -77,6 +77,11 @@ pub enum RuntimeProfileMembershipUpgradeRejection { TargetPlanNotPurchasable, /// 报价入参的数值不变量被破坏(期号越界、账期窗口非法或 `now` 不在窗口内):拒绝,而不是静默夹取。 InvalidQuoteInput, + /// 目录价格 / 每期泥点未随 rank 严格递增(相等或倒挂)。 + /// + /// 与 [`Self::InvalidQuoteInput`] 分开:这是后端目录配置错误,不是调用方入参错误, + /// 对外应映射为配置类错误码(`invalid_plan_config`),便于监控区分。 + CatalogPricingInverted, /// 补差价订单结算时会员已到期,且订单金额不够覆盖整期新购价:应重新下单购买。 MembershipExpiredBeforeSettlement, } @@ -89,9 +94,21 @@ impl RuntimeProfileMembershipUpgradeRejection { Self::NotUpgrade => "not_upgrade", Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", Self::InvalidQuoteInput => "invalid_quote_input", + Self::CatalogPricingInverted => "catalog_pricing_inverted", Self::MembershipExpiredBeforeSettlement => "membership_expired_before_settlement", } } + + /// 对外 procedure 错误码:目录价格 / 每期泥点倒挂属后端配置错误,与调用方入参错误分开, + /// 便于按机器码定位;其余拒绝仍统一为 `UpgradeRejected`。 + pub fn error_code(&self) -> crate::RuntimeProfileMembershipErrorCode { + match self { + Self::CatalogPricingInverted => { + crate::RuntimeProfileMembershipErrorCode::InvalidPlanConfig + } + _ => crate::RuntimeProfileMembershipErrorCode::UpgradeRejected, + } + } } /// 本期剩余比例(ppm):`剩余微秒 / 本期实际微秒`,向下取整,已过期或时长非法时为 0。 @@ -184,8 +201,9 @@ pub fn quote_runtime_profile_membership_upgrade( // 目录价格 / 每期泥点必须严格随 rank 递增;相等或倒挂都直接拒绝, // 否则 checked_sub 会把「相等」算成 0 元升级 / 0 补点,违背目录不变量。 + // 这是后端目录配置错误,用独立变体与调用方入参错误区分。 if target.price_cents <= current.price_cents || target.period_points <= current.period_points { - return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); + return Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted); } let price_delta_cents = target.price_cents - current.price_cents; let points_delta = target.period_points - current.period_points; @@ -247,6 +265,7 @@ fn ceil_div(numerator: u128, denominator: u128) -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::RuntimeProfileMembershipErrorCode; use crate::RuntimeProfileMembershipPlan; use crate::membership::catalog::resolve_runtime_profile_membership_plan_snapshot; use crate::membership::cycle::beijing_local_micros; @@ -485,7 +504,7 @@ mod tests { target: target.clone(), ..base_input() }), - Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) ); target.price_cents = current.price_cents + 1; @@ -496,7 +515,7 @@ mod tests { target: target.clone(), ..base_input() }), - Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) ); // 中文注释:相等同样破坏「严格随 rank 递增」,必须拒绝;旧实现用 checked_sub @@ -509,7 +528,17 @@ mod tests { target, ..base_input() }), - Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) + ); + + // 中文注释:目录倒挂对外映射为配置错误码,与调用方入参错误(UpgradeRejected)区分。 + assert_eq!( + RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted.error_code(), + RuntimeProfileMembershipErrorCode::InvalidPlanConfig + ); + assert_eq!( + RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput.error_code(), + RuntimeProfileMembershipErrorCode::UpgradeRejected ); } 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 18ad8f4ed..0bffc6c79 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8764,12 +8764,7 @@ fn build_profile_membership_upgrade_quote_record( &target, ctx.timestamp, )) - .map_err(|rejection| { - RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::UpgradeRejected, - format!("会员升级被拒绝: {}", rejection.as_str()), - ) - })?; + .map_err(membership_upgrade_rejection_error)?; Ok(RuntimeProfileMembershipUpgradeQuoteSnapshot { current_plan: current.plan, @@ -8786,6 +8781,19 @@ fn build_profile_membership_upgrade_quote_record( }) } +/// 会员升级拒绝 → procedure 错误码。 +/// +/// 目录价格 / 每期泥点倒挂是后端配置错误,映射为 `InvalidPlanConfig`,与客户端入参错误 +/// (`UpgradeRejected`)区分开,便于按错误码定位;人读文案仍带具体 rejection 原因。 +fn membership_upgrade_rejection_error( + rejection: RuntimeProfileMembershipUpgradeRejection, +) -> RuntimeProfileMembershipDomainError { + RuntimeProfileMembershipDomainError::new( + rejection.error_code(), + format!("会员升级被拒绝: {}", rejection.as_str()), + ) +} + /// 后台改价 / 改权益。`rank` 与 `plan` 不可改:`plan` 是主键(即身份),`rank` 是升级比较基准。 fn upsert_profile_membership_plan_record( ctx: &ReducerContext, @@ -10107,12 +10115,7 @@ fn build_profile_membership_order_snapshot( let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( &row, ¤t, &target, created_at, )) - .map_err(|rejection| { - RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::UpgradeRejected, - format!("会员升级被拒绝: {}", rejection.as_str()), - ) - })?; + .map_err(membership_upgrade_rejection_error)?; Ok(ProfileMembershipOrderSnapshot { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan, @@ -10731,12 +10734,7 @@ fn build_recomputed_profile_membership_settlement( &target, purchased_at, )) - .map_err(|rejection| { - RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::UpgradeRejected, - format!("会员升级被拒绝: {}", rejection.as_str()), - ) - })?; + .map_err(membership_upgrade_rejection_error)?; Ok(ProfileMembershipSettlement { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan,