重构(会员升级): 目录倒挂用独立 rejection 与配置错误码

- 新增 RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted
- 报价遇到价格 / 每期泥点相等或倒挂时返回该变体,不再复用 InvalidQuoteInput
- rejection.error_code() 把目录倒挂映射为 InvalidPlanConfig,人读文案保留原因
- profile.rs 三处统一用 membership_upgrade_rejection_error,并重导出该 rejection 类型
- api-server 补 400 + invalid_plan_config 映射断言
This commit is contained in:
2026-10-05 19:23:00 +08:00
parent 8788c47a97
commit 6374c7c445
5 changed files with 62 additions and 29 deletions
@@ -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(),
@@ -1506,7 +1506,7 @@ pub enum RuntimeProfileMembershipErrorCode {
ProductIdUnparseable,
/// 会员商品已迁移到档位目录,旧商品不再可用。
MembershipProductRetired,
/// 后台会员档位改价 / 改权益配置校验失败。
/// 后台会员档位改价 / 改权益配置校验失败,或读取目录时发现价格 / 每期泥点倒挂。
InvalidPlanConfig,
}
+7 -6
View File
@@ -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,
};
@@ -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
);
}
@@ -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, &current, &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,