From bd06e42690c62835778b03f200e7f76829f22f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 18:40:54 +0800 Subject: [PATCH] =?UTF-8?q?refactor(error):=20profile=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=85=A8=E9=9D=A2=E8=BD=AC=20typed=20error?= =?UTF-8?q?=EF=BC=8C=E5=88=A0=E9=99=A4=E6=96=87=E6=A1=88=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 新增 RuntimeProfileRewardCodeRedeemErrorCode(NotFound/Disabled/NotStarted/Expired/UsesExhausted/NotAllowedForUser/InvalidReward)与 RuntimeProfileProcedureRejectionCode(聚合会员 / 兑换码两类码) - RuntimeProfileRewardCodeRedeemProcedureResult 末尾追加 error_code,spacetime-module 用 ProfileRewardCodeRedeemProcedureError 在拒绝点显式赋码,入参与钱包错误保持无码 - SpacetimeClientError::ProcedureRejected.code 改为 RuntimeProfileProcedureRejectionCode,Display 输出 [code] message,不再吞掉机器可读原因(评审第 12 条) - api-server 的 map_runtime_profile_client_error 只读变体字段,AppError.code 取 code.as_str(),删除 is_runtime_profile_domain_error / is_runtime_profile_redeem_code_domain_error 两个中文文案匹配函数 - 重新生成 Rust bindings;同步技术设计 §5.8 与 decision-log --- .../shared-memory/decision-log.md | 9 ++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 34 +++-- .../crates/api-server/src/runtime_profile.rs | 124 ++++++++-------- server-rs/crates/module-runtime/src/domain.rs | 71 +++++++++ .../crates/spacetime-client/src/active.rs | 13 +- .../src/active/mapper/runtime_profile.rs | 37 ++++- .../spacetime-client/src/module_bindings.rs | 6 +- ...file_reward_code_redeem_error_code_type.rs | 28 ++++ ...eward_code_redeem_procedure_result_type.rs | 2 + .../src/runtime/active/profile.rs | 138 ++++++++++++++++-- 10 files changed, 375 insertions(+), 87 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 385c81cd4..767ef029a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9674,3 +9674,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:选 ①。`init_profile_membership_plan_catalog` 只在数据库首次创建时执行,增量发布不重跑;「表已存在但为空」的存量库必须靠读取 helper 懒播种兜底,否则目录为空会让下单 / 报价失败。保留懒播种,并把「只读入口在目录为空的首次调用会写库、表非空后幂等只读」明确写进代码注释与设计文档 §7,消除注释与实现的矛盾。 - 未选 ②/③ 的原因:②依赖发布流程保证存量库被种上,当前没有这条保证;③改动读路径回退逻辑,风险与收益不匹配。将来若做发布期 migration,再删除懒播种。 - 影响范围:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`(`ensure_default_profile_membership_plan` / `init_profile_membership_plan_catalog` / `membership_plan_row` / `membership_plan_records` / 报价与建单函数注释)、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §7/§10.2、本文件。 + +## 2026-10-03 兑换码链路补机器可读错误码(typed error 收口) + +- 背景:评审第 12 项指出 `SpacetimeClientError::ProcedureRejected` 的 `Display` 丢掉 `code`,而 `api-server/src/runtime_profile.rs` 的 `is_runtime_profile_redeem_code_domain_error` 仍在精确匹配 7 条中文 `error_message` 来判断兑换码拒绝该返回 400 还是 502,文案一改就静默退化,与会员链路已落地的 typed error 不一致。 +- 决策(module 侧赋码):`module-runtime` 新增 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / `Disabled` / `NotStarted` / `Expired` / `UsesExhausted` / `NotAllowedForUser` / `InvalidReward`);`RuntimeProfileRewardCodeRedeemProcedureResult` 末尾追加 `error_code: Option<...>`;`spacetime-module` 用 `ProfileRewardCodeRedeemProcedureError::{Classified, Plain}` 在拒绝点显式赋码,入参与钱包类错误保持 `error_code = null`。 +- 决策(客户端 / BFF):`SpacetimeClientError::ProcedureRejected` 的 `code` 改为 `RuntimeProfileProcedureRejectionCode`(聚合 `Membership` 与 `RewardCodeRedeem` 两类),`Display` 输出 `[code] message` 保留机器原因;`api-server` 的 `map_runtime_profile_client_error` 只读变体的 `code` / `message`,`AppError.code` 取 `code.as_str()`,删除 `is_runtime_profile_domain_error` 与 `is_runtime_profile_redeem_code_domain_error` 两个文案匹配函数。 +- 决策(行为变化):兑换码的 `兑换码不存在` / `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效` 由原来的 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`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check -p api-server`;`cargo test -p api-server runtime_profile::tests` 41 passed;`cargo test -p module-runtime`、`cargo test -p spacetime-client`、`cargo test -p spacetime-module` 全通过;`npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index aae4bbf1a..c4f3dfde1 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -433,23 +433,31 @@ flowchart TD - `npm run check:generated-bindings` 以「重新生成并与工作区逐字节比较」做门禁,防止 Rust 改了而生成文件没重跑。 - 需要展示用派生字段(如 `totalPoints` 合计)时,在前端 `utils/mudPoints.ts` 里派生,不改 DTO。 -### 5.8 会员错误码契约(typed error,已落地) +### 5.8 Profile 错误码契约(typed error,已全面落地) -- 会员链路的业务拒绝不再靠中文 `error_message` 前缀分类。`module-runtime` 新增 - `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / - `CycleKindLocked` / `MembershipExpired` / `StateChanged` / `PlanNotPurchasable` / - `PlanCatalogMissing` / `ProductIdUnparsable` / `MembershipProductRetired` / `InvalidPlanConfig`), - 在拒绝点显式产出,`RuntimeProfileMembershipUpgradeQuoteProcedureResult` / +- Profile 链路的业务拒绝不再靠中文 `error_message` 分类,全部由 procedure 结果里的机器可读 + `error_code` 透传。 +- 会员链路:`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。 +- 兑换码链路:`module-runtime` 的 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / + `Disabled` / `NotStarted` / `Expired` / `UsesExhausted` / `NotAllowedForUser` / + `InvalidReward`)在拒绝点显式产出,`RuntimeProfileRewardCodeRedeemProcedureResult` 末尾同样追加 + `error_code`。 +- `spacetime-client` 的 `SpacetimeClientError::ProcedureRejected { code, message }` 用 + `RuntimeProfileProcedureRejectionCode` 聚合两条链路的 typed code;`api-server` 的 `runtime_profile` + 映射直接读变体字段返回 400(`provider = runtime-profile`),`AppError.code` 取 + `code.as_str()`,基础设施错误保持 502。字段校验类错误(如 user_id 缺失)不产出 `error_code`, + 仍按字符串错误落到 502。 +- 行为变化:`会员目录缺少档位 ...`、「已迁移的会员商品」以及兑换码的 `兑换码不存在` / `已停用` / + `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效` 原先落到 502,现在有明确 + 错误码,统一 400。`api-server` 已删除 `is_runtime_profile_redeem_code_domain_error` 文案匹配函数。 - 兼容边界:procedure result 是 wire 结构,新增字段要求 module 与 api-server 同版本部署; - 旧 module 不返回 `error_code` 时,新 client 绑定反序列化会失败。兑换码链路本轮未改造, - `api-server` 仍保留一个兑换码文案匹配函数。 + 旧 module 不返回 `error_code` 时,新 client 绑定反序列化会失败。 --- diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 74b18d86c..6c90babbd 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1463,57 +1463,28 @@ pub async fn get_profile_play_stats( } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { - 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") + match &error { + // profile procedure 的业务拒绝:机器码与人读文案都取自 typed 变体, + // HTTP 状态、错误码和 message 全部结构化透传,不做任何字符串匹配或解析。 + SpacetimeClientError::ProcedureRejected { code, message } => { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_code(code.as_str()) + .with_message(message.clone()) + .with_details(json!({ + "provider": "runtime-profile", + "message": message, + })) } - SpacetimeClientError::Procedure(_) if is_domain_error => { - (StatusCode::BAD_REQUEST, "runtime-profile") - } - _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), - }; - - let app_error = AppError::from_status(status).with_details(json!({ - "provider": provider, - "message": error.to_string(), - })); - if is_domain_error { - return app_error.with_message(error.to_string()); + SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ + "provider": "runtime-profile", + "message": message, + })), + _ => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })), } - app_error -} - -/// 模块侧的可读业务拒绝以 400 返回,基础设施错误保持 502。 -/// -/// 会员链路的拒绝已由 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, - } -} - -// TODO(refactor): 兑换码链路尚未接入 typed error code,仍靠中文文案匹配判 400; -// 后续按会员链路(RuntimeProfileMembershipErrorCode + SpacetimeClientError::ProcedureRejected) -// 的做法,让 module 侧兑换码拒绝也携带机器可读错误码。 -fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool { - matches!( - message, - "兑换码不存在" - | "兑换码已停用" - | "兑换码未生效" - | "兑换码已过期" - | "兑换次数已用完" - | "该兑换码不适用于当前账号" - | "兑换码奖励无效" - ) } fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response { @@ -2521,7 +2492,8 @@ mod tests { RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, + RuntimeProfileRechargeProductRecord, RuntimeProfileRewardCodeRedeemErrorCode, + RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2537,13 +2509,44 @@ mod tests { #[test] fn redeem_code_domain_errors_map_to_client_errors_without_hiding_infrastructure_failures() { - for message in ["兑换码未生效", "兑换码已过期"] { - let error = map_runtime_profile_client_error(SpacetimeClientError::Procedure( - message.to_string(), - )); + // 兑换码拒绝与会员链路同构:机器码从 typed 变体取,文案只做人读展示,不参与分类。 + for (code, message) in [ + ( + RuntimeProfileRewardCodeRedeemErrorCode::NotStarted, + "兑换码未生效", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::Expired, + "兑换码已过期", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::NotFound, + "兑换码不存在", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::Disabled, + "兑换码已停用", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted, + "兑换次数已用完", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser, + "该兑换码不适用于当前账号", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward, + "兑换码奖励无效", + ), + ] { + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code: code.into(), + message: message.to_string(), + }); - assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); - assert_eq!(error.code(), "BAD_REQUEST"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), code.as_str(), "{message}"); assert_eq!(error.message(), message); assert_eq!( error @@ -2565,6 +2568,12 @@ mod tests { .and_then(Value::as_str), Some("spacetimedb") ); + + // 旧实现按中文文案把「兑换码已过期」判成 400;typed 改造后同样的裸字符串必须落到 502。 + let legacy_string_error = map_runtime_profile_client_error( + SpacetimeClientError::Procedure("兑换码已过期".into()), + ); + assert_eq!(legacy_string_error.status_code(), StatusCode::BAD_GATEWAY); } #[test] @@ -2590,11 +2599,12 @@ mod tests { ), ] { let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { - code, + code: code.into(), message: message.to_string(), }); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), code.as_str(), "{message}"); assert_eq!(error.message(), message); assert_eq!( error diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 6477930ac..0bfe73391 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1517,6 +1517,75 @@ impl RuntimeProfileMembershipErrorCode { } } +/// 兑换码核销业务拒绝的机器可读错误码。 +/// +/// 与 [`RuntimeProfileMembershipErrorCode`] 同样的目的:核销失败的人读 `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 RuntimeProfileRewardCodeRedeemErrorCode { + /// 兑换码在目录里不存在。 + NotFound, + /// 兑换码已被后台停用。 + Disabled, + /// 兑换码尚未生效。 + NotStarted, + /// 兑换码已过期。 + Expired, + /// 兑换次数已用完。 + UsesExhausted, + /// 私有兑换码不适用于当前账号。 + NotAllowedForUser, + /// 兑换码奖励配置无效。 + InvalidReward, +} + +impl RuntimeProfileRewardCodeRedeemErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::NotFound => "not_found", + Self::Disabled => "disabled", + Self::NotStarted => "not_started", + Self::Expired => "expired", + Self::UsesExhausted => "uses_exhausted", + Self::NotAllowedForUser => "not_allowed_for_user", + Self::InvalidReward => "invalid_reward", + } + } +} + +/// profile procedure 业务拒绝的统一错误码:把会员与兑换码两条链路的 typed code 收在一处。 +/// +/// 只在 `spacetime-client` 进程内传递,不落表、不过 wire,因此不派生 `SpacetimeType`。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeProfileProcedureRejectionCode { + Membership(RuntimeProfileMembershipErrorCode), + RewardCodeRedeem(RuntimeProfileRewardCodeRedeemErrorCode), +} + +impl RuntimeProfileProcedureRejectionCode { + pub fn as_str(self) -> &'static str { + match self { + Self::Membership(code) => code.as_str(), + Self::RewardCodeRedeem(code) => code.as_str(), + } + } +} + +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileMembershipErrorCode) -> Self { + Self::Membership(code) + } +} + +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileRewardCodeRedeemErrorCode) -> Self { + Self::RewardCodeRedeem(code) + } +} + /// 会员领域错误:机器可读 `code` + 人读 `message`。 /// /// 只在模块内部与 procedure 结果装载之间传递,不落 SpacetimeDB 表,因此不派生 `SpacetimeType`。 @@ -2562,6 +2631,8 @@ pub struct RuntimeProfileRewardCodeRedeemProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + /// 核销业务拒绝的机器可读错误码;非业务错误(入参、钱包等)为 `None`。 + 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 5d275a1a5..8240f4a4d 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -234,9 +234,9 @@ pub enum SpacetimeClientError { Build(String), ConnectDropped, Procedure(String), - /// 会员 procedure 返回了带机器可读错误码的业务拒绝。 + /// profile procedure 返回了带机器可读错误码的业务拒绝(会员 / 兑换码核销)。 ProcedureRejected { - code: module_runtime::RuntimeProfileMembershipErrorCode, + code: module_runtime::RuntimeProfileProcedureRejectionCode, message: String, }, Runtime(String), @@ -998,11 +998,11 @@ impl SpacetimeClientError { } pub(crate) fn procedure_rejected( - code: module_runtime::RuntimeProfileMembershipErrorCode, + code: impl Into, message: Option, ) -> Self { Self::ProcedureRejected { - code, + code: code.into(), message: message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), } } @@ -1089,7 +1089,10 @@ 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::ProcedureRejected { code, message } => { + // `code` 是排障与审计要看的机器可读原因,Display 不能把它吞掉。 + write!(f, "[{}] {}", code.as_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 fda444938..51d067c9d 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 @@ -1115,7 +1115,14 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result( result: RuntimeProfileRewardCodeRedeemProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + // 业务拒绝自带机器可读 error_code 时走 typed 变体;入参 / 钱包等错误仍按普通字符串错误返回。 + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_reward_code_redeem_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } let snapshot = result @@ -2108,6 +2115,34 @@ pub(crate) fn map_runtime_profile_membership_error_code_back( } } +pub(crate) fn map_runtime_profile_reward_code_redeem_error_code_back( + value: crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode, +) -> module_runtime::RuntimeProfileRewardCodeRedeemErrorCode { + match value { + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::NotFound => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::NotFound + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::Disabled => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::Disabled + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::NotStarted => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::NotStarted + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::Expired => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::Expired + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward + } + } +} + pub(crate) fn map_runtime_profile_membership_model_access( value: module_runtime::RuntimeProfileMembershipModelAccess, ) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 55a56de2b..19783243f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -488,9 +488,9 @@ pub mod get_game_distribution_my_review_and_return_procedure; 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_agc_model_access_and_return_procedure; pub mod get_payment_order_and_return_procedure; pub mod get_payment_order_by_checkout_token_and_return_procedure; +pub mod get_profile_agc_model_access_and_return_procedure; pub mod get_profile_dashboard_procedure; pub mod get_profile_membership_snapshot_and_return_procedure; pub mod get_profile_membership_upgrade_quote_procedure; @@ -786,6 +786,7 @@ pub mod runtime_profile_redeem_code_admin_procedure_result_type; pub mod runtime_profile_redeem_code_admin_upsert_input_type; pub mod runtime_profile_redeem_code_mode_type; pub mod runtime_profile_redeem_code_snapshot_type; +pub mod runtime_profile_reward_code_redeem_error_code_type; pub mod runtime_profile_reward_code_redeem_input_type; pub mod runtime_profile_reward_code_redeem_procedure_result_type; pub mod runtime_profile_reward_code_redeem_snapshot_type; @@ -1369,9 +1370,9 @@ pub use get_game_distribution_my_review_and_return_procedure::get_game_distribut pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return; 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_agc_model_access_and_return_procedure::get_profile_agc_model_access_and_return; pub use get_payment_order_and_return_procedure::get_payment_order_and_return; pub use get_payment_order_by_checkout_token_and_return_procedure::get_payment_order_by_checkout_token_and_return; +pub use get_profile_agc_model_access_and_return_procedure::get_profile_agc_model_access_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; pub use get_profile_membership_snapshot_and_return_procedure::get_profile_membership_snapshot_and_return; pub use get_profile_membership_upgrade_quote_procedure::get_profile_membership_upgrade_quote; @@ -1667,6 +1668,7 @@ pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileR pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; +pub use runtime_profile_reward_code_redeem_error_code_type::RuntimeProfileRewardCodeRedeemErrorCode; pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs new file mode 100644 index 000000000..949523f68 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs @@ -0,0 +1,28 @@ +// 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 RuntimeProfileRewardCodeRedeemErrorCode { + NotFound, + + Disabled, + + NotStarted, + + Expired, + + UsesExhausted, + + NotAllowedForUser, + + InvalidReward, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs index dd8936d7e..13fb730fe 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_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_reward_code_redeem_error_code_type::RuntimeProfileRewardCodeRedeemErrorCode; use super::runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileRewardCodeRedeemProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult { 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 4747f3201..50c720011 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -58,6 +58,74 @@ impl ProfileMembershipProcedureError { } } +/// 兑换码核销 procedure 的错误载体。 +/// +/// 业务拒绝携带机器可读 [`RuntimeProfileRewardCodeRedeemErrorCode`];入参、钱包等其它错误保持 +/// 普通字符串,透传时 `error_code` 为 `null`(与会员链路同一约定)。 +enum ProfileRewardCodeRedeemProcedureError { + Classified(RuntimeProfileRewardCodeRedeemErrorCode, String), + Plain(String), +} + +impl From for ProfileRewardCodeRedeemProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileRewardCodeRedeemProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileRewardCodeRedeemProcedureError { + fn classified( + code: RuntimeProfileRewardCodeRedeemErrorCode, + message: impl Into, + ) -> Self { + Self::Classified(code, message.into()) + } + + fn into_parts(self) -> (String, Option) { + match self { + Self::Classified(code, message) => (message, Some(code)), + Self::Plain(message) => (message, None), + } + } +} + +/// 把兑换码校验的字段错误收敛成机器可读错误码;未列出的错误按普通错误处理(`error_code = null`)。 +fn reward_code_redeem_error_from_field_error( + error: RuntimeProfileFieldError, +) -> ProfileRewardCodeRedeemProcedureError { + let code = match error { + RuntimeProfileFieldError::RedeemCodeDisabled => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::Disabled) + } + RuntimeProfileFieldError::RedeemCodeNotStarted => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::NotStarted) + } + RuntimeProfileFieldError::RedeemCodeExpired => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::Expired) + } + RuntimeProfileFieldError::RedeemCodeUsesExhausted => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted) + } + RuntimeProfileFieldError::RedeemCodeNotAllowedForUser => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser) + } + RuntimeProfileFieldError::InvalidRedeemCodeReward => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward) + } + _ => None, + }; + match code { + Some(code) => ProfileRewardCodeRedeemProcedureError::Classified(code, error.to_string()), + None => ProfileRewardCodeRedeemProcedureError::Plain(error.to_string()), + } +} + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -2605,12 +2673,17 @@ pub fn redeem_profile_reward_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRewardCodeRedeemProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -3191,6 +3264,48 @@ mod tests { } } + #[test] + fn reward_code_redeem_field_errors_map_to_typed_codes() { + for (error, expected) in [ + ( + RuntimeProfileFieldError::RedeemCodeDisabled, + RuntimeProfileRewardCodeRedeemErrorCode::Disabled, + ), + ( + RuntimeProfileFieldError::RedeemCodeNotStarted, + RuntimeProfileRewardCodeRedeemErrorCode::NotStarted, + ), + ( + RuntimeProfileFieldError::RedeemCodeExpired, + RuntimeProfileRewardCodeRedeemErrorCode::Expired, + ), + ( + RuntimeProfileFieldError::RedeemCodeUsesExhausted, + RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted, + ), + ( + RuntimeProfileFieldError::RedeemCodeNotAllowedForUser, + RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser, + ), + ( + RuntimeProfileFieldError::InvalidRedeemCodeReward, + RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward, + ), + ] { + let (message, code) = + reward_code_redeem_error_from_field_error(error.clone()).into_parts(); + + assert_eq!(code, Some(expected), "{message}"); + assert_eq!(message, error.to_string()); + } + + // 未列入兑换码拒绝的错误(如入参校验)必须保持无码,避免把基础设施错误误判成业务 400。 + let (_, code) = + reward_code_redeem_error_from_field_error(RuntimeProfileFieldError::MissingUserId) + .into_parts(); + assert_eq!(code, None); + } + #[test] fn wallet_ledger_sort_follows_balance_settlement_chain_when_payment_time_is_delayed() { let mut entries = vec![ @@ -7535,13 +7650,13 @@ fn redeem_profile_referral_invite_code_record( fn redeem_profile_reward_code_record( ctx: &ReducerContext, input: RuntimeProfileRewardCodeRedeemInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_reward_code_redeem_input( input.user_id, input.code, input.redeemed_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(|error| ProfileRewardCodeRedeemProcedureError::Plain(error.to_string()))?; let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros); let user_id = validated_input.user_id; let code = validated_input.code; @@ -7550,7 +7665,12 @@ fn redeem_profile_reward_code_record( .profile_redeem_code() .code() .find(&code) - .ok_or_else(|| "兑换码不存在".to_string())?; + .ok_or_else(|| { + ProfileRewardCodeRedeemProcedureError::classified( + RuntimeProfileRewardCodeRedeemErrorCode::NotFound, + "兑换码不存在", + ) + })?; let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id); validate_runtime_profile_redeem_code_usage( @@ -7559,7 +7679,7 @@ fn redeem_profile_reward_code_record( user_used_count, validated_input.redeemed_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(reward_code_redeem_error_from_field_error)?; let usage_id = build_runtime_profile_redeem_code_usage_id( &code,