diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9f35ea21c..448e75896 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9585,3 +9585,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`server-rs/crates/shared-contracts/src/admin.rs`、`server-rs/crates/api-server/src/admin_recharge.rs`、`apps/admin-web/src/{api/adminApiTypes.ts,config/membershipDisplay.ts,pages/AdminMembershipPlanPage.tsx,pages/AdminRechargeOrderPage.tsx,components/AdminUserDetailDialog.tsx}`、`scripts/admin-web-fake-api.mjs`、设计文档 §5.6/§10.2、本文件。 - 验证:`cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web` → 26 files / 248 tests passed(新增「会员订单在发放泥点列展示会员变更快照」及用户详情会员断言)。 - 边界:`/admin/api/*` 不在 `/api/external/v1` OpenAPI 门禁内,本次未补接口级契约测试,会员快照只在管理端页面用例层面取证。 + +## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) + +- 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 +- 决策(错误码随 procedure 结果透传):`module-runtime` 新增 `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / `CycleKindLocked` / `MembershipExpired` / `StateChanged` / `PlanNotPurchasable` / `PlanCatalogMissing` / `ProductIdUnparsable` / `MembershipProductRetired` / `InvalidPlanConfig`)与内部 `RuntimeProfileMembershipDomainError { code, message }`;三个会员 procedure 结果(升级报价、充值中心 / 下单、后台档位 upsert)末尾追加 `error_code: Option<...>`,拒绝点显式赋码,不再做字符串分类。 +- 决策(客户端错误类型):`spacetime-client` 新增 `SpacetimeClientError::ProcedureRejected { code, message }`;`api-server` 据此返回 400(provider `runtime-profile`),基础设施错误保持 502。字段校验类错误不产码,仍落 502。 +- 决策(行为变化):`会员目录缺少档位 ...` 与「已迁移的会员商品」原先落 502,现在明确 400;兑换码链路本轮未改造,仍保留一个文案匹配函数。procedure result 新增字段属 wire 契约变更,要求 module 与 api-server 同版本部署。 +- 影响范围:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`、`server-rs/crates/spacetime-client/src/{active.rs,active/mapper/runtime_profile.rs}`、生成绑定、`server-rs/crates/api-server/src/{runtime_profile.rs,asset_billing.rs,editor_project.rs}`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check --workspace`;`api-server runtime_profile::tests` 41 passed;`module-runtime --lib membership::` 39 passed;`npm run check:encoding`、`npm run check:spacetime-schema`、`cargo fmt -- --check`、`git diff --check` 通过。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 20e29c7ad..9b6af65ab 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -433,6 +433,24 @@ flowchart TD - `npm run check:generated-bindings` 以「重新生成并与工作区逐字节比较」做门禁,防止 Rust 改了而生成文件没重跑。 - 需要展示用派生字段(如 `totalPoints` 合计)时,在前端 `utils/mudPoints.ts` 里派生,不改 DTO。 +### 5.8 会员错误码契约(typed error,已落地) + +- 会员链路的业务拒绝不再靠中文 `error_message` 前缀分类。`module-runtime` 新增 + `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / + `CycleKindLocked` / `MembershipExpired` / `StateChanged` / `PlanNotPurchasable` / + `PlanCatalogMissing` / `ProductIdUnparsable` / `MembershipProductRetired` / `InvalidPlanConfig`), + 在拒绝点显式产出,`RuntimeProfileMembershipUpgradeQuoteProcedureResult` / + `RuntimeProfileRechargeCenterProcedureResult` / `RuntimeProfileMembershipPlanAdminProcedureResult` + 末尾追加 `error_code: Option`(`error_message` 保留人读文案)。 +- `spacetime-client` 的 `SpacetimeClientError` 新增 + `ProcedureRejected { code, message }`;`api-server` 的 `runtime_profile` 映射据此返回 400 + (`provider = runtime-profile`),基础设施错误保持 502。字段校验类错误(如 user_id 缺失) + 不产出 `error_code`,仍按字符串错误落到 502。 +- 行为变化:`会员目录缺少档位 ...` 与「已迁移的会员商品」原先落到 502,现在有明确错误码,统一 400。 +- 兼容边界:procedure result 是 wire 结构,新增字段要求 module 与 api-server 同版本部署; + 旧 module 不返回 `error_code` 时,新 client 绑定反序列化会失败。兑换码链路本轮未改造, + `api-server` 仍保留一个兑换码文案匹配函数。 + --- ## 6. 计算规则(后端唯一真相源) diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 9ba5795db..0b37b9980 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -695,6 +695,8 @@ fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> boo || message.contains("连接已断开") || message.contains("连接在返回结果前已断开") } + // 业务拒绝是服务端已明确应答,不应触发退款应急 spool。 + SpacetimeClientError::ProcedureRejected { .. } => false, } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index db2e78e56..602771e28 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1149,6 +1149,7 @@ fn is_editor_generation_retryable_transport_error(error: &SpacetimeClientError) || message.contains("连接在返回结果前已断开") } SpacetimeClientError::Procedure(_) => false, + SpacetimeClientError::ProcedureRejected { .. } => false, } } @@ -1160,6 +1161,7 @@ fn is_editor_generation_unknown_result_error( && !matches!( error, SpacetimeClientError::Procedure(_) + | SpacetimeClientError::ProcedureRejected { .. } | SpacetimeClientError::Timeout( SpacetimeClientStage::PoolAcquire | SpacetimeClientStage::ConnectBuild diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index c4d22d258..acd944add 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1463,13 +1463,12 @@ pub async fn get_profile_play_stats( } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { - let is_domain_error = matches!( - &error, - SpacetimeClientError::Procedure(message) - if is_runtime_profile_domain_error(message) - ); + let is_domain_error = is_runtime_profile_domain_error(&error); let (status, provider) = match &error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), + SpacetimeClientError::ProcedureRejected { .. } => { + (StatusCode::BAD_REQUEST, "runtime-profile") + } SpacetimeClientError::Procedure(_) if is_domain_error => { (StatusCode::BAD_REQUEST, "runtime-profile") } @@ -1487,24 +1486,18 @@ fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { } /// 模块侧的可读业务拒绝以 400 返回,基础设施错误保持 502。 -fn is_runtime_profile_domain_error(message: &str) -> bool { - is_runtime_profile_redeem_code_domain_error(message) - || is_runtime_profile_membership_domain_error(message) -} - -fn is_runtime_profile_membership_domain_error(message: &str) -> bool { - message.starts_with("会员升级被拒绝: ") - || message.starts_with("会员档位") - || message.starts_with("会员商品 product_id 无法解析: ") - || message.starts_with("充值商品类型无效:会员商品已迁移到会员档位目录") - || matches!( - message, - "当前不是会员,请直接选择套餐购买" - | "当前套餐已生效,无需重复购买" - | "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择" - | "会员已到期,请按新购价格重新下单" - | "会员状态已变化,请重新下单" - ) +/// +/// 会员链路的拒绝已由 SpacetimeDB procedure 透传机器可读错误码,走 +/// [`SpacetimeClientError::ProcedureRejected`];兑换码尚未改造,仍按可读文案匹配(见 +/// [`is_runtime_profile_redeem_code_domain_error`])。 +fn is_runtime_profile_domain_error(error: &SpacetimeClientError) -> bool { + match error { + SpacetimeClientError::ProcedureRejected { .. } => true, + SpacetimeClientError::Procedure(message) => { + is_runtime_profile_redeem_code_domain_error(message) + } + _ => false, + } } fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool { @@ -2534,12 +2527,12 @@ mod tests { use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, RuntimeProfileDailyFreePointsRecord, RuntimeProfileMembershipCycleKind, - RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, - RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipRecord, - RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, - RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, - RuntimeProfileWalletLedgerSourceType, + RuntimeProfileMembershipErrorCode, RuntimeProfileMembershipModelAccess, + RuntimeProfileMembershipPlan, RuntimeProfileMembershipPlanRecord, + RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, + RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, + RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2587,20 +2580,48 @@ mod tests { #[test] fn membership_business_rejections_map_to_bad_request() { - // 中文注释:会员商品迁档位目录后的业务拒绝必须按 400 返回;漏掉前缀匹配会落到 502, - // 让后台与下单端把可读的业务提示当成基础设施故障。目录缺行属于部署配置错误,保持 502。 - for message in [ - "会员升级被拒绝: cycle_kind_change_unsupported", - "会员商品 product_id 无法解析: membership-pro-yearly", - "充值商品类型无效:会员商品已迁移到会员档位目录", + // 中文注释:会员业务拒绝由 procedure 透传机器可读 error_code,统一按 400 返回; + // 中文文案不再参与分类,普通 Procedure 字符串错误保持 502。 + for (code, message) in [ + ( + RuntimeProfileMembershipErrorCode::UpgradeRejected, + "会员升级被拒绝: cycle_kind_change_unsupported", + ), + ( + RuntimeProfileMembershipErrorCode::ProductIdUnparsable, + "会员商品 product_id 无法解析: membership-pro-yearly", + ), + ( + RuntimeProfileMembershipErrorCode::MembershipProductRetired, + "充值商品类型无效:会员商品已迁移到会员档位目录", + ), + ( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + "会员目录缺少档位 pro", + ), ] { - let error = map_runtime_profile_client_error(SpacetimeClientError::Procedure( - message.to_string(), - )); + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code, + message: message.to_string(), + }); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); assert_eq!(error.message(), message); + assert_eq!( + error + .details() + .and_then(|details| details.get("provider")) + .and_then(Value::as_str), + Some("runtime-profile") + ); } + + // 中文注释:删掉字符串前缀分类后,同样的文案若没有 error_code 必须落到 502。 + let legacy_string_error = + map_runtime_profile_client_error(SpacetimeClientError::Procedure( + "会员升级被拒绝: cycle_kind_change_unsupported".to_string(), + )); + assert_eq!(legacy_string_error.status_code(), StatusCode::BAD_GATEWAY); } #[test] diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 55e978e73..94860d76b 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1467,6 +1467,82 @@ impl From } } +/// 会员业务拒绝的机器可读错误码。 +/// +/// 会员链路的人读 `error_message` 会随文案调整,调用方不得再按中文字符串判断 HTTP 状态; +/// 每个拒绝点显式产出本枚举,随 procedure 结果透传到 Axum 侧。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileMembershipErrorCode { + /// 补差升级报价 / 下单被升级规则拒绝。 + UpgradeRejected, + /// 当前不是会员,应直接购买套餐。 + NotMember, + /// 当前套餐已生效,无需重复购买。 + AlreadyActive, + /// 有效期内不支持月付 / 年付转换。 + CycleKindLocked, + /// 会员已到期,需按新购价格重新下单。 + MembershipExpired, + /// 下单后会员状态已变化,需要重新下单。 + StateChanged, + /// 目标档位当前不可购买。 + PlanNotPurchasable, + /// 代码内档位目录缺少该档位。 + PlanCatalogMissing, + /// 会员商品 product_id 无法解析。 + ProductIdUnparsable, + /// 会员商品已迁移到档位目录,旧商品不再可用。 + MembershipProductRetired, + /// 后台会员档位改价 / 改权益配置校验失败。 + InvalidPlanConfig, +} + +impl RuntimeProfileMembershipErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::UpgradeRejected => "upgrade_rejected", + Self::NotMember => "not_member", + Self::AlreadyActive => "already_active", + Self::CycleKindLocked => "cycle_kind_locked", + Self::MembershipExpired => "membership_expired", + Self::StateChanged => "state_changed", + Self::PlanNotPurchasable => "plan_not_purchasable", + Self::PlanCatalogMissing => "plan_catalog_missing", + Self::ProductIdUnparsable => "product_id_unparsable", + Self::MembershipProductRetired => "membership_product_retired", + Self::InvalidPlanConfig => "invalid_plan_config", + } + } +} + +/// 会员领域错误:机器可读 `code` + 人读 `message`。 +/// +/// 只在模块内部与 procedure 结果装载之间传递,不落 SpacetimeDB 表,因此不派生 `SpacetimeType`。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeProfileMembershipDomainError { + pub code: RuntimeProfileMembershipErrorCode, + pub message: String, +} + +impl RuntimeProfileMembershipDomainError { + pub fn new(code: RuntimeProfileMembershipErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +impl std::fmt::Display for RuntimeProfileMembershipDomainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for RuntimeProfileMembershipDomainError {} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileRechargeOrderStatus { @@ -1888,6 +1964,7 @@ pub struct RuntimeProfileRechargeCenterProcedureResult { pub record: Option, pub order: Option, pub error_message: Option, + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1969,6 +2046,7 @@ pub struct RuntimeProfileMembershipPlanAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2019,6 +2097,7 @@ pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { pub ok: bool, pub quote: Option, pub error_message: Option, + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 809e881df..1d4dc086c 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -233,6 +233,11 @@ pub enum SpacetimeClientError { Build(String), ConnectDropped, Procedure(String), + /// 会员 procedure 返回了带机器可读错误码的业务拒绝。 + ProcedureRejected { + code: module_runtime::RuntimeProfileMembershipErrorCode, + message: String, + }, Runtime(String), Timeout(SpacetimeClientStage), } @@ -991,6 +996,16 @@ impl SpacetimeClientError { Self::Procedure(message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string())) } + pub(crate) fn procedure_rejected( + code: module_runtime::RuntimeProfileMembershipErrorCode, + message: Option, + ) -> Self { + Self::ProcedureRejected { + code, + message: message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + } + } + pub(crate) fn missing_snapshot(label: &'static str) -> Self { Self::Procedure(format!("SpacetimeDB procedure 未返回{label}")) } @@ -1073,6 +1088,7 @@ impl fmt::Display for SpacetimeClientError { Self::Build(message) | Self::Procedure(message) | Self::Runtime(message) => { f.write_str(message) } + Self::ProcedureRejected { message, .. } => f.write_str(message), Self::ConnectDropped => f.write_str("SpacetimeDB 连接在返回结果前已断开"), Self::Timeout(stage) => { f.write_str(&format!("SpacetimeDB {} 调用超时", stage.as_str())) 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 676c6e721..73a65cdc2 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 @@ -1,5 +1,19 @@ use super::*; +/// 会员 procedure 失败统一落错误:有机器可读 `error_code` 时走 `ProcedureRejected`,否则回退字符串错误。 +pub(crate) fn map_runtime_profile_membership_procedure_failure( + error_code: Option, + error_message: Option, +) -> SpacetimeClientError { + match error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_membership_error_code_back(code), + error_message, + ), + None => SpacetimeClientError::procedure_failed(error_message), + } +} + impl From for RuntimeProfileDashboardGetInput { fn from(input: module_runtime::RuntimeProfileDashboardGetInput) -> Self { Self { @@ -736,7 +750,10 @@ pub(crate) fn map_runtime_profile_recharge_center_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let snapshot = result @@ -772,7 +789,10 @@ pub(crate) fn map_runtime_profile_recharge_order_procedure_result( SpacetimeClientError, > { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let center = result @@ -2032,6 +2052,46 @@ pub(crate) fn map_runtime_profile_membership_change_kind_back( } } +pub(crate) fn map_runtime_profile_membership_error_code_back( + value: crate::module_bindings::RuntimeProfileMembershipErrorCode, +) -> module_runtime::RuntimeProfileMembershipErrorCode { + match value { + crate::module_bindings::RuntimeProfileMembershipErrorCode::UpgradeRejected => { + module_runtime::RuntimeProfileMembershipErrorCode::UpgradeRejected + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::NotMember => { + module_runtime::RuntimeProfileMembershipErrorCode::NotMember + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::AlreadyActive => { + module_runtime::RuntimeProfileMembershipErrorCode::AlreadyActive + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::CycleKindLocked => { + module_runtime::RuntimeProfileMembershipErrorCode::CycleKindLocked + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::MembershipExpired => { + module_runtime::RuntimeProfileMembershipErrorCode::MembershipExpired + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::StateChanged => { + module_runtime::RuntimeProfileMembershipErrorCode::StateChanged + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::PlanNotPurchasable => { + module_runtime::RuntimeProfileMembershipErrorCode::PlanNotPurchasable + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::PlanCatalogMissing => { + module_runtime::RuntimeProfileMembershipErrorCode::PlanCatalogMissing + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::ProductIdUnparsable => { + module_runtime::RuntimeProfileMembershipErrorCode::ProductIdUnparsable + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::MembershipProductRetired => { + module_runtime::RuntimeProfileMembershipErrorCode::MembershipProductRetired + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::InvalidPlanConfig => { + module_runtime::RuntimeProfileMembershipErrorCode::InvalidPlanConfig + } + } +} + pub(crate) fn map_runtime_profile_membership_model_access( value: module_runtime::RuntimeProfileMembershipModelAccess, ) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { @@ -2131,7 +2191,10 @@ 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)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let quote = result @@ -2173,7 +2236,10 @@ pub(crate) fn map_runtime_profile_membership_plan_admin_procedure_result( result: RuntimeProfileMembershipPlanAdminProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let record = result diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 928f87ec3..b865b2c03 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -705,6 +705,7 @@ pub mod runtime_profile_invite_code_admin_upsert_input_type; pub mod runtime_profile_invite_code_snapshot_type; pub mod runtime_profile_membership_change_kind_type; pub mod runtime_profile_membership_cycle_kind_type; +pub mod runtime_profile_membership_error_code_type; pub mod runtime_profile_membership_get_input_type; pub mod runtime_profile_membership_model_access_type; pub mod runtime_profile_membership_order_change_snapshot_type; @@ -1579,6 +1580,7 @@ pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInvit pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; pub use runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; pub use runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +pub use runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; pub use runtime_profile_membership_get_input_type::RuntimeProfileMembershipGetInput; pub use runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; pub use runtime_profile_membership_order_change_snapshot_type::RuntimeProfileMembershipOrderChangeSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs new file mode 100644 index 000000000..984004de4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs @@ -0,0 +1,36 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipErrorCode { + UpgradeRejected, + + NotMember, + + AlreadyActive, + + CycleKindLocked, + + MembershipExpired, + + StateChanged, + + PlanNotPurchasable, + + PlanCatalogMissing, + + ProductIdUnparsable, + + MembershipProductRetired, + + InvalidPlanConfig, +} + +impl __sdk::InModule for RuntimeProfileMembershipErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs index 78ee5407e..db0ada966 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; use super::runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileMembershipPlanAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileMembershipPlanAdminProcedureResult { 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 index 213059e5e..37356951d 100644 --- 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 @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; use super::runtime_profile_membership_upgrade_quote_snapshot_type::RuntimeProfileMembershipUpgradeQuoteSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { pub ok: bool, pub quote: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs index e9e055e02..202c1a1a1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; use super::runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; @@ -14,6 +15,7 @@ pub struct RuntimeProfileRechargeCenterProcedureResult { pub record: Option, pub order: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRechargeCenterProcedureResult { 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 303530227..32c9b84d3 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -22,6 +22,42 @@ const ASSET_OPERATION_REFUND_LEDGER_PREFIX: &str = "asset_operation_refund:"; const PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING: &str = "pending"; const PROFILE_WALLET_REFUND_OUTBOX_MAX_BATCH_SIZE: u32 = 100; +/// 会员 / 充值 procedure 的错误载体。 +/// +/// 会员领域拒绝携带机器可读 [`RuntimeProfileMembershipErrorCode`];其它来源的错误(身份校验、 +/// 钱包溢出、订单不存在等)保持普通字符串,透传时 `error_code` 为 `null`。 +enum ProfileMembershipProcedureError { + Membership(RuntimeProfileMembershipDomainError), + Plain(String), +} + +impl From for ProfileMembershipProcedureError { + fn from(error: RuntimeProfileMembershipDomainError) -> Self { + Self::Membership(error) + } +} + +impl From for ProfileMembershipProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileMembershipProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileMembershipProcedureError { + fn into_parts(self) -> (String, Option) { + match self { + Self::Membership(error) => (error.message, Some(error.code)), + Self::Plain(message) => (message, None), + } + } +} + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -1247,17 +1283,23 @@ pub fn admin_upsert_profile_membership_plan( tx, caller, )?; upsert_profile_membership_plan_record(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) }) { Ok(record) => RuntimeProfileMembershipPlanAdminProcedureResult { ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileMembershipPlanAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileMembershipPlanAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -1272,18 +1314,29 @@ pub fn get_profile_membership_upgrade_quote( crate::editor_project_storage::require_editor_generation_runtime_service_identity( tx, caller, )?; - build_profile_membership_upgrade_quote_record(tx, input.clone()) + let validated = build_runtime_profile_membership_upgrade_quote_input( + input.user_id.clone(), + input.target_plan, + ) + .map_err(|error| error.to_string())?; + build_profile_membership_upgrade_quote_record(tx, validated) + .map_err(ProfileMembershipProcedureError::from) }) { Ok(quote) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { ok: true, quote: Some(quote), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { - ok: false, - quote: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileMembershipUpgradeQuoteProcedureResult { + ok: false, + quote: None, + error_message: Some(message), + error_code, + } + } } } @@ -1619,19 +1672,27 @@ 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())) { + match ctx.try_with_tx(|tx| { + get_profile_recharge_center_snapshot(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { Ok(record) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: None, error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -1671,19 +1732,27 @@ pub fn get_profile_recharge_order_and_return( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeOrderGetInput, ) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_recharge_order_snapshot(tx, input.clone())) { + match ctx.try_with_tx(|tx| { + get_profile_recharge_order_snapshot(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -1698,13 +1767,18 @@ pub fn create_profile_recharge_order_and_return( record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -1719,19 +1793,25 @@ pub fn mark_profile_recharge_order_paid_and_return( tx, caller, )?; mark_profile_recharge_order_paid_record(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -2382,19 +2462,27 @@ pub fn close_profile_recharge_order_and_return( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeOrderCloseInput, ) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| close_profile_recharge_order_record(tx, input.clone())) { + match ctx.try_with_tx(|tx| { + close_profile_recharge_order_record(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -4893,7 +4981,7 @@ fn create_profile_recharge_order_record( RuntimeProfileRechargeCenterSnapshot, RuntimeProfileRechargeOrderSnapshot, ), - String, + ProfileMembershipProcedureError, > { let validated_input = build_runtime_profile_recharge_order_create_input( input.user_id, @@ -4902,7 +4990,19 @@ fn create_profile_recharge_order_record( ) .map_err(|error| error.to_string())?; let product = resolve_enabled_profile_recharge_order_product(ctx, &validated_input.product_id) - .ok_or_else(|| "recharge.product_id 不存在或已下架".to_string())?; + .ok_or_else(|| { + let message = "recharge.product_id 不存在或已下架"; + if parse_runtime_profile_membership_product_id(&validated_input.product_id).is_some() { + ProfileMembershipProcedureError::Membership( + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::MembershipProductRetired, + message, + ), + ) + } else { + ProfileMembershipProcedureError::Plain(message.to_string()) + } + })?; // 中文注释:建单时刻一律取服务端事务时间,procedure 入参不再携带时间; // 否则客户端可把时间设到会员到期前夕,压低升级补差金额。 let created_at_micros = ctx.timestamp.to_micros_since_unix_epoch(); @@ -5035,7 +5135,7 @@ fn mark_profile_recharge_order_paid_record( RuntimeProfileRechargeCenterSnapshot, RuntimeProfileRechargeOrderSnapshot, ), - String, + ProfileMembershipProcedureError, > { let validated_input = build_runtime_profile_recharge_order_paid_input( input.order_id, @@ -5068,7 +5168,9 @@ fn mark_profile_recharge_order_paid_record( order.status, RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired ) { - return Err("profile_recharge_order 当前状态不能确认支付".to_string()); + return Err(ProfileMembershipProcedureError::Plain( + "profile_recharge_order 当前状态不能确认支付".to_string(), + )); } let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros); @@ -7155,7 +7257,7 @@ fn apply_profile_recharge_purchase( ctx: &ReducerContext, order: &ProfileRechargeOrder, paid_at: Timestamp, -) -> Result { +) -> Result { let user_id = order.user_id.as_str(); match order.kind { RuntimeProfileRechargeProductKind::Points => { @@ -8308,42 +8410,58 @@ fn list_profile_membership_plan_records( 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())?; - +) -> Result { let Some(row) = ctx .db .profile_membership() .user_id() - .find(&validated.user_id.to_string()) + .find(&input.user_id.to_string()) .filter(|row| active_membership_row_at(row, ctx.timestamp)) else { - return Err("当前不是会员,请直接选择套餐购买".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::NotMember, + "当前不是会员,请直接选择套餐购买", + )); }; // 只读投射账期到当前时刻:不落库、不发账本,避免预览就改会员 / 钱包状态。 let row = project_profile_membership_cycle_at(ctx, row, ctx.timestamp); - 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() + if row.plan == input.target_plan { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::AlreadyActive, + "当前套餐已生效,无需重复购买", )); } - let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let target = + membership_plan_snapshot(ctx, input.target_plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", input.target_plan.as_str()), + ) + })?; + if !target.enabled { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanNotPurchasable, + format!("会员档位 {} 当前不可购买", input.target_plan.as_str()), + )); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + 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()))?; + .map_err(|rejection| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::UpgradeRejected, + format!("会员升级被拒绝: {}", rejection.as_str()), + ) + })?; Ok(RuntimeProfileMembershipUpgradeQuoteSnapshot { current_plan: current.plan, @@ -8364,7 +8482,7 @@ fn build_profile_membership_upgrade_quote_record( fn upsert_profile_membership_plan_record( ctx: &ReducerContext, input: RuntimeProfileMembershipPlanAdminUpsertInput, -) -> Result { +) -> Result { let validated = build_runtime_profile_membership_plan_admin_upsert_input( input.admin_user_id, input.plan, @@ -8390,9 +8508,12 @@ fn upsert_profile_membership_plan_record( // 缺失档位必须显式报错:回退成 rank 0 会与 Normal 占位同档,产生误导性的校验结果。 let rank = resolve_runtime_profile_membership_plan_row(validated.plan) .ok_or_else(|| { - format!( - "会员目录缺少档位 {},无法解析升级比较基准", - validated.plan.as_str() + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!( + "会员目录缺少档位 {},无法解析升级比较基准", + validated.plan.as_str() + ), ) })? .rank; @@ -8432,7 +8553,12 @@ fn upsert_profile_membership_plan_record( &candidate, &catalog_rows, ) - .map_err(|error| error.to_string())?; + .map_err(|error| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::InvalidPlanConfig, + error.to_string(), + ) + })?; if let Some(row) = existing { ctx.db.profile_membership_plan().plan().delete(&row.plan); @@ -9563,13 +9689,25 @@ fn build_profile_membership_order_snapshot( user_id: &str, product_id: &str, created_at: Timestamp, -) -> Result { - let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id) - .ok_or_else(|| format!("会员商品 product_id 无法解析: {product_id}"))?; - let target = membership_plan_snapshot(ctx, plan, cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; +) -> Result { + let (plan, cycle_kind) = + parse_runtime_profile_membership_product_id(product_id).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::ProductIdUnparsable, + format!("会员商品 product_id 无法解析: {product_id}"), + ) + })?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", plan.as_str()), + ) + })?; if !target.enabled { - return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanNotPurchasable, + format!("会员档位 {} 当前不可购买", plan.as_str()), + )); } // 只读地把账期投射到建单时刻(不写库),避免用到过期账期导致金额与预览 / 结算不一致。 @@ -9602,17 +9740,32 @@ fn build_profile_membership_order_snapshot( }); }; if row.plan == plan { - return Err("当前套餐已生效,无需重复购买".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::AlreadyActive, + "当前套餐已生效,无需重复购买", + )); } if row.cycle_kind != cycle_kind { - return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::CycleKindLocked, + "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择", + )); } - let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", row.plan.as_str()), + ) + })?; let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( &row, ¤t, &target, created_at, )) - .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + .map_err(|rejection| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::UpgradeRejected, + format!("会员升级被拒绝: {}", rejection.as_str()), + ) + })?; Ok(ProfileMembershipOrderSnapshot { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan, @@ -10071,7 +10224,7 @@ fn apply_profile_membership_purchase( user_id: &str, order: &ProfileRechargeOrder, purchased_at: Timestamp, -) -> Result { +) -> Result { refresh_profile_wallet_expiring_points(ctx, user_id, purchased_at); let active_row = ctx @@ -10098,7 +10251,11 @@ fn apply_profile_membership_purchase( if settlement.change_kind == RuntimeProfileMembershipChangeKind::Purchase && active_row.is_some() { - return Err("会员状态已变化,请重新下单".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::StateChanged, + "会员状态已变化,请重新下单", + ) + .into()); } if settlement.change_kind == RuntimeProfileMembershipChangeKind::Upgrade { @@ -10109,12 +10266,20 @@ fn apply_profile_membership_purchase( if settlement.plan_before != Some(row.plan) || settlement.cycle_kind_before != Some(row.cycle_kind) { - return Err("会员状态已变化,请重新下单".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::StateChanged, + "会员状态已变化,请重新下单", + ) + .into()); } // 中文注释:冻结快照记录了下单当期的期号与刷新时刻。若支付前账期已推进, // 按旧周期剩余比例算出的补点会叠加到已重置的新周期上(多发泥点),因此拒绝。 if !frozen_membership_cycle_matches(&settlement, &row) { - return Err("会员状态已变化,请重新下单".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::StateChanged, + "会员状态已变化,请重新下单", + ) + .into()); } return apply_profile_membership_upgrade( ctx, @@ -10123,15 +10288,26 @@ fn apply_profile_membership_purchase( &order.order_id, &settlement, purchased_at, - ); + ) + .map_err(ProfileMembershipProcedureError::from); } // 中文注释:会员在支付前已到期,冻结的补差价只覆盖当期剩余权益的差额, // 不再覆盖整期新购价;只有订单金额达到目标周期当前目录价才按新购结算, // 否则拒绝,避免用户用零头补差价换到一个完整周期。 let target = membership_plan_snapshot(ctx, settlement.plan, settlement.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", settlement.plan.as_str()))?; + .ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", settlement.plan.as_str()), + ) + })?; check_expired_membership_upgrade_settlement(order.amount_cents, target.price_cents) - .map_err(|_| "会员已到期,请按新购价格重新下单".to_string())?; + .map_err(|_| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::MembershipExpired, + "会员已到期,请按新购价格重新下单", + ) + })?; } apply_profile_membership_fresh_purchase( @@ -10141,6 +10317,7 @@ fn apply_profile_membership_purchase( &settlement, purchased_at, ) + .map_err(ProfileMembershipProcedureError::from) } /// 历史 Pending 订单(建单时还没有冻结快照)的结算口径:按结算时的目录与会员状态重算。 @@ -10150,11 +10327,20 @@ fn build_recomputed_profile_membership_settlement( product_id: &str, order_amount_cents: u64, purchased_at: Timestamp, -) -> Result { - let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id) - .ok_or_else(|| format!("会员商品 product_id 无法解析: {product_id}"))?; - let target = membership_plan_snapshot(ctx, plan, cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; +) -> Result { + let (plan, cycle_kind) = + parse_runtime_profile_membership_product_id(product_id).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::ProductIdUnparsable, + format!("会员商品 product_id 无法解析: {product_id}"), + ) + })?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", plan.as_str()), + ) + })?; let Some(row) = active_row else { return Ok(ProfileMembershipSettlement { change_kind: RuntimeProfileMembershipChangeKind::Purchase, @@ -10175,20 +10361,35 @@ fn build_recomputed_profile_membership_settlement( }); }; if row.plan == plan { - return Err("当前套餐已生效,无需重复购买".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::AlreadyActive, + "当前套餐已生效,无需重复购买", + )); } if row.cycle_kind != cycle_kind { - return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::CycleKindLocked, + "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择", + )); } - let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", row.plan.as_str()), + ) + })?; let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( row, ¤t, &target, purchased_at, )) - .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + .map_err(|rejection| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::UpgradeRejected, + format!("会员升级被拒绝: {}", rejection.as_str()), + ) + })?; Ok(ProfileMembershipSettlement { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan,