From 4f3faa04b51732a2d775c1aa574363d16d2dbed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:35:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=90=8E=E5=8F=B0=E5=85=91?= =?UTF-8?q?=E6=8D=A2=E7=A0=81):=20=E7=AE=A1=E7=90=86=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E8=A1=A5=E6=9C=BA=E5=99=A8=E5=8F=AF=E8=AF=BB=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A0=81=E8=B5=B0=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 新增 RuntimeProfileRedeemCodeAdminErrorCode(not_found / invalid_field / allowed_users_required),后台兑换码增改与列表 procedure 结果末尾追加 error_code - module 侧用 ProfileRedeemCodeAdminProcedureError 在拒绝点赋码,字段校验统一 invalid_field、私有码缺用户 allowed_users_required - spacetime-client 后台 mapper 有码走 ProcedureRejected,api-server 由此返回 400 而不是 502 - 重新生成 SpacetimeDB 绑定;补 client 与 api-server 单测;同步设计文档 §5.8 与决策日志 --- .../shared-memory/decision-log.md | 9 ++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 20 ++- .../crates/api-server/src/runtime_profile.rs | 36 +++++- server-rs/crates/module-runtime/src/domain.rs | 41 +++++- .../crates/spacetime-client/src/active.rs | 2 +- .../src/active/mapper/runtime_profile.rs | 74 ++++++++++- .../spacetime-client/src/module_bindings.rs | 2 + ...ofile_redeem_code_admin_error_code_type.rs | 20 +++ ...m_code_admin_list_procedure_result_type.rs | 2 + ...redeem_code_admin_procedure_result_type.rs | 2 + .../src/runtime/active/profile.rs | 120 ++++++++++++++---- 11 files changed, 290 insertions(+), 38 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_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 2050e1cd3..65fabd8ff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9719,3 +9719,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(结算归一,20):`frozen_membership_cycle_matches` 比较双方都取 `max(1)`,修复 `cycle_index == 0` 迁移行被永久判成「会员状态已变化」的问题;不新增写库副作用。 - 影响范围:`shared-contracts/{runtime,admin}.rs`、`module-runtime/{domain,commands,errors}.rs` 与 `module-runtime/src/{membership/upgrade,membership/cycle,agc_model_access}.rs`、`spacetime-client/{active,active/mapper/runtime_profile,module_bindings}`、`spacetime-module/src/runtime/active/profile.rs`、`api-server/src/{runtime_profile,llm/mod,agc_models}.rs`、`packages/shared/src/contracts/runtime.ts`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/AdminRechargeProductPage.tsx`、`scripts/admin-web-fake-api.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件。 - 验证:`cargo test -p api-server` 1206 passed;`-p module-runtime` / `-p spacetime-client` / `-p spacetime-module` 全通过;`npm run spacetime:generate`、`npm run check:spacetime-schema`(92 表)、`npm run check:generated-bindings`(117 文件)、`npm run admin-web:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-10-03 后台兑换码管理补机器可读错误码(typed error 收口) + +- 背景:`bd06e4269` 删除中文文案匹配后,`admin_disable_profile_redeem_code` / `admin_upsert_profile_redeem_code` / `admin_list_profile_redeem_codes` 仍只回传裸 `error_message`。这些 procedure 的 `ok = false` 是明确的业务拒绝(停用不存在的码、入参非法、私有码缺可兑换用户),却被 `spacetime-client` 拍平成 `Procedure(String)`,在 `api-server` 落 502,与会员 / 核销两条链路已落地的 typed error 不一致。 +- 决策(module 侧赋码):`module-runtime` 新增 `RuntimeProfileRedeemCodeAdminErrorCode`(`NotFound` / `InvalidField` / `AllowedUsersRequired`);`RuntimeProfileRedeemCodeAdminProcedureResult` 与 `RuntimeProfileRedeemCodeAdminListProcedureResult` 末尾追加 `error_code: Option<...>`;`spacetime-module` 用 `ProfileRedeemCodeAdminProcedureError::{Classified, Plain}` 在拒绝点显式赋码,`RuntimeProfileFieldError` 统一归 `invalid_field`,私有码缺可兑换用户归 `allowed_users_required`,停用不存在的码归 `not_found`。 +- 决策(客户端 / BFF):`RuntimeProfileProcedureRejectionCode` 增加 `RedeemCodeAdmin` 聚合变体,`spacetime-client` 两个后台 mapper 有 `error_code` 时走 `ProcedureRejected`、缺失时保持 `Procedure`;`api-server` 复用既有 `map_runtime_profile_client_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,module_bindings,module_bindings.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check`(module-runtime / spacetime-module / spacetime-client / api-server);`cargo test -p spacetime-client` 30 passed;`cargo test -p api-server runtime_profile::tests` 42 passed;`cargo test -p module-runtime --lib` 113 passed;`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 6bf7be8bb..e310e96e0 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -451,18 +451,24 @@ flowchart TD `RuntimeProfileMembershipUpgradeQuoteProcedureResult` / `RuntimeProfileRechargeCenterProcedureResult` / `RuntimeProfileMembershipPlanAdminProcedureResult` 末尾追加 `error_code: Option`(`error_message` 保留人读文案)。 -- 兑换码链路:`module-runtime` 的 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / +- 兑换码核销链路:`module-runtime` 的 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / `Disabled` / `NotStarted` / `Expired` / `UsesExhausted` / `NotAllowedForUser` / `InvalidReward`)在拒绝点显式产出,`RuntimeProfileRewardCodeRedeemProcedureResult` 末尾同样追加 `error_code`。 +- 后台兑换码管理链路:`module-runtime` 的 `RuntimeProfileRedeemCodeAdminErrorCode`(`NotFound` / + `InvalidField` / `AllowedUsersRequired`)在拒绝点显式产出, + `RuntimeProfileRedeemCodeAdminProcedureResult` / `RuntimeProfileRedeemCodeAdminListProcedureResult` + 末尾追加 `error_code`;字段校验错误统一归 `invalid_field`,私有码缺可兑换用户归 + `allowed_users_required`,停用不存在的码归 `not_found`。 - `spacetime-client` 的 `SpacetimeClientError::ProcedureRejected { code, message }` 用 - `RuntimeProfileProcedureRejectionCode` 聚合两条链路的 typed code;`api-server` 的 `runtime_profile` + `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` 文案匹配函数。 + `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 绑定反序列化会失败。 diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 8e88dcfe9..981272697 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2490,8 +2490,8 @@ mod tests { RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileRewardCodeRedeemErrorCode, - RuntimeProfileWalletLedgerSourceType, + RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeAdminErrorCode, + RuntimeProfileRewardCodeRedeemErrorCode, RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2574,6 +2574,38 @@ mod tests { assert_eq!(legacy_string_error.status_code(), StatusCode::BAD_GATEWAY); } + #[test] + fn redeem_code_admin_rejections_map_to_bad_request() { + // 后台兑换码管理的业务拒绝同样随 procedure 透传 error_code;「停用不存在的码」这类 + // 拒绝必须落 400,不能因为缺少机器码被当成 SpacetimeDB 基础设施故障落 502。 + for (code, expected_code, message) in [ + ( + RuntimeProfileRedeemCodeAdminErrorCode::NotFound, + "not_found", + "兑换码不存在", + ), + ( + RuntimeProfileRedeemCodeAdminErrorCode::InvalidField, + "invalid_field", + "兑换码奖励点数必须大于 0", + ), + ( + RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired, + "allowed_users_required", + "私有兑换码必须指定可兑换用户", + ), + ] { + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code: code.into(), + message: message.to_string(), + }); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), expected_code, "{message}"); + assert_eq!(error.message(), message); + } + } + #[test] fn membership_business_rejections_map_to_bad_request() { // 中文注释:会员业务拒绝由 procedure 透传机器可读 error_code,统一按 400 返回; diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index bfbbe7837..e09213f5b 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1567,13 +1567,41 @@ impl RuntimeProfileRewardCodeRedeemErrorCode { } } -/// profile procedure 业务拒绝的统一错误码:把会员与兑换码两条链路的 typed code 收在一处。 +/// 后台兑换码管理的机器可读拒绝码。 +/// +/// 与 [`RuntimeProfileRewardCodeRedeemErrorCode`] 同样的目的:后台兑换码的增改 / 停用拒绝 +/// 不能靠中文文案分类 HTTP 状态,每个拒绝点显式产出本枚举并随 procedure 结果透传。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileRedeemCodeAdminErrorCode { + /// 兑换码在目录里不存在。 + NotFound, + /// 兑换码管理入参不合法(缺码、奖励为 0、次数为 0、有效期倒挂等)。 + InvalidField, + /// 私有兑换码没有可用于兑换的用户。 + AllowedUsersRequired, +} + +impl RuntimeProfileRedeemCodeAdminErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::NotFound => "not_found", + Self::InvalidField => "invalid_field", + Self::AllowedUsersRequired => "allowed_users_required", + } + } +} + +/// profile procedure 业务拒绝的统一错误码:把会员、兑换码核销与后台兑换码管理的 typed code +/// 收在一处。 /// /// 只在 `spacetime-client` 进程内传递,不落表、不过 wire,因此不派生 `SpacetimeType`。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RuntimeProfileProcedureRejectionCode { Membership(RuntimeProfileMembershipErrorCode), RewardCodeRedeem(RuntimeProfileRewardCodeRedeemErrorCode), + RedeemCodeAdmin(RuntimeProfileRedeemCodeAdminErrorCode), } impl RuntimeProfileProcedureRejectionCode { @@ -1581,6 +1609,7 @@ impl RuntimeProfileProcedureRejectionCode { match self { Self::Membership(code) => code.as_str(), Self::RewardCodeRedeem(code) => code.as_str(), + Self::RedeemCodeAdmin(code) => code.as_str(), } } } @@ -1597,6 +1626,12 @@ impl From for RuntimeProfileProcedureRe } } +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileRedeemCodeAdminErrorCode) -> Self { + Self::RedeemCodeAdmin(code) + } +} + /// 会员领域错误:机器可读 `code` + 人读 `message`。 /// /// 只在模块内部与 procedure 结果装载之间传递,不落 SpacetimeDB 表,因此不派生 `SpacetimeType`。 @@ -2708,6 +2743,8 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + /// 后台管理业务拒绝的机器可读错误码;非业务错误(入参、系统等)为 `None`。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2717,6 +2754,8 @@ pub struct RuntimeProfileRedeemCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, 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 fd3d19a0d..ad74ec262 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -235,7 +235,7 @@ pub enum SpacetimeClientError { Build(String), ConnectDropped, Procedure(String), - /// profile procedure 返回了带机器可读错误码的业务拒绝(会员 / 兑换码核销)。 + /// profile procedure 返回了带机器可读错误码的业务拒绝(会员 / 兑换码核销 / 后台兑换码管理)。 ProcedureRejected { code: module_runtime::RuntimeProfileProcedureRejectionCode, message: String, 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 8367c0c9f..58e7fe632 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 @@ -1268,7 +1268,13 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( result: RuntimeProfileRedeemCodeAdminProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_redeem_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } let snapshot = result @@ -1284,7 +1290,13 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_list_procedure_result( result: RuntimeProfileRedeemCodeAdminListProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_redeem_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } Ok(RuntimeProfileRedeemCodeAdminListRecord { @@ -2141,6 +2153,22 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_error_code_back( } } +pub(crate) fn map_runtime_profile_redeem_code_admin_error_code_back( + value: crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode, +) -> module_runtime::RuntimeProfileRedeemCodeAdminErrorCode { + match value { + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::NotFound => { + module_runtime::RuntimeProfileRedeemCodeAdminErrorCode::NotFound + } + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::InvalidField => { + module_runtime::RuntimeProfileRedeemCodeAdminErrorCode::InvalidField + } + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired => { + module_runtime::RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired + } + } +} + pub(crate) fn map_runtime_profile_membership_model_access( value: module_runtime::RuntimeProfileMembershipModelAccess, ) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { @@ -2484,3 +2512,45 @@ pub(crate) fn map_runtime_profile_feedback_status_back( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn admin_redeem_code_rejection_uses_typed_code() { + let result = RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("兑换码不存在".to_string()), + error_code: Some( + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::NotFound, + ), + }; + + match map_runtime_profile_redeem_code_admin_procedure_result(result) { + Err(SpacetimeClientError::ProcedureRejected { code, message }) => { + assert_eq!(code.as_str(), "not_found"); + assert_eq!(message, "兑换码不存在"); + } + other => panic!("expected ProcedureRejected, got {other:?}"), + } + } + + #[test] + fn admin_redeem_code_without_error_code_stays_plain_procedure_error() { + let result = RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("SpacetimeDB 事务失败".to_string()), + error_code: None, + }; + + match map_runtime_profile_redeem_code_admin_procedure_result(result) { + Err(SpacetimeClientError::Procedure(message)) => { + assert_eq!(message, "SpacetimeDB 事务失败"); + } + other => panic!("expected Procedure, got {other:?}"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index e58aa6eb5..e7d05b5ab 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -783,6 +783,7 @@ pub mod runtime_profile_recharge_refund_recovery_status_type; pub mod runtime_profile_recharge_refund_snapshot_type; pub mod runtime_profile_recharge_refund_status_type; pub mod runtime_profile_redeem_code_admin_disable_input_type; +pub mod runtime_profile_redeem_code_admin_error_code_type; pub mod runtime_profile_redeem_code_admin_list_input_type; pub mod runtime_profile_redeem_code_admin_list_procedure_result_type; pub mod runtime_profile_redeem_code_admin_procedure_result_type; @@ -1669,6 +1670,7 @@ pub use runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRech pub use runtime_profile_recharge_refund_snapshot_type::RuntimeProfileRechargeRefundSnapshot; pub use runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +pub use runtime_profile_redeem_code_admin_error_code_type::RuntimeProfileRedeemCodeAdminErrorCode; pub use runtime_profile_redeem_code_admin_list_input_type::RuntimeProfileRedeemCodeAdminListInput; pub use runtime_profile_redeem_code_admin_list_procedure_result_type::RuntimeProfileRedeemCodeAdminListProcedureResult; pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs new file mode 100644 index 000000000..3a5e6fa15 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs @@ -0,0 +1,20 @@ +// 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 RuntimeProfileRedeemCodeAdminErrorCode { + NotFound, + + InvalidField, + + AllowedUsersRequired, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs index 86f26def6..5515525bd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; +use super::runtime_profile_redeem_code_admin_error_code_type::RuntimeProfileRedeemCodeAdminErrorCode; use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -14,6 +15,7 @@ pub struct RuntimeProfileRedeemCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRedeemCodeAdminListProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs index 62254ff97..ca771ad9a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_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_redeem_code_admin_error_code_type::RuntimeProfileRedeemCodeAdminErrorCode; use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult { 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 b41d69eb8..c240523c1 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -126,6 +126,53 @@ fn reward_code_redeem_error_from_field_error( } } +/// 后台兑换码管理 procedure 的错误载体。 +/// +/// 业务拒绝携带机器可读 [`RuntimeProfileRedeemCodeAdminErrorCode`];入参、系统等其它错误保持 +/// 普通字符串,透传时 `error_code` 为 `null`(与会员 / 核销链路同一约定)。 +enum ProfileRedeemCodeAdminProcedureError { + Classified(RuntimeProfileRedeemCodeAdminErrorCode, String), + Plain(String), +} + +impl From for ProfileRedeemCodeAdminProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileRedeemCodeAdminProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileRedeemCodeAdminProcedureError { + fn classified( + code: RuntimeProfileRedeemCodeAdminErrorCode, + 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), + } + } +} + +/// 后台兑换码的字段校验错误统一归入 `invalid_field`,保证入参拒绝也落 400 而不是 502。 +fn redeem_code_admin_error_from_field_error( + error: RuntimeProfileFieldError, +) -> ProfileRedeemCodeAdminProcedureError { + ProfileRedeemCodeAdminProcedureError::classified( + RuntimeProfileRedeemCodeAdminErrorCode::InvalidField, + error.to_string(), + ) +} + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -2697,12 +2744,17 @@ pub fn admin_upsert_profile_redeem_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2716,12 +2768,17 @@ pub fn admin_disable_profile_redeem_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2736,13 +2793,18 @@ pub fn admin_list_profile_redeem_codes( entries, operations, error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRedeemCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRedeemCodeAdminListProcedureResult { + ok: false, + entries: Vec::new(), + operations: Vec::new(), + error_message: Some(message), + error_code, + } + } } } @@ -7742,7 +7804,7 @@ fn redeem_profile_reward_code_record( fn admin_upsert_profile_redeem_code_record( ctx: &ReducerContext, input: RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_redeem_code_admin_upsert_input( input.admin_user_id, input.code, @@ -7756,7 +7818,7 @@ fn admin_upsert_profile_redeem_code_record( input.expires_at_micros, input.updated_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(redeem_code_admin_error_from_field_error)?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?; let existing = ctx @@ -7817,20 +7879,25 @@ fn admin_upsert_profile_redeem_code_record( fn admin_disable_profile_redeem_code_record( ctx: &ReducerContext, input: RuntimeProfileRedeemCodeAdminDisableInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_redeem_code_admin_disable_input( input.admin_user_id, input.code, input.updated_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(redeem_code_admin_error_from_field_error)?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let existing = ctx .db .profile_redeem_code() .code() .find(&validated_input.code) - .ok_or_else(|| "兑换码不存在".to_string())?; + .ok_or_else(|| { + ProfileRedeemCodeAdminProcedureError::classified( + RuntimeProfileRedeemCodeAdminErrorCode::NotFound, + "兑换码不存在", + ) + })?; ctx.db.profile_redeem_code().code().delete(&existing.code); let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode { @@ -8450,10 +8517,10 @@ fn admin_list_profile_redeem_code_records( Vec, Vec, ), - String, + ProfileRedeemCodeAdminProcedureError, > { let _validated_input = build_runtime_profile_redeem_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; + .map_err(redeem_code_admin_error_from_field_error)?; let mut entries = ctx .db @@ -12151,7 +12218,7 @@ fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_i fn resolve_profile_redeem_code_allowed_user_ids( ctx: &ReducerContext, input: &RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result, String> { +) -> Result, ProfileRedeemCodeAdminProcedureError> { if input.mode != RuntimeProfileRedeemCodeMode::Private { return Ok(Vec::new()); } @@ -12172,7 +12239,10 @@ fn resolve_profile_redeem_code_allowed_user_ids( allowed_user_ids.dedup(); if allowed_user_ids.is_empty() { - return Err("私有兑换码必须指定可兑换用户".to_string()); + return Err(ProfileRedeemCodeAdminProcedureError::classified( + RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired, + "私有兑换码必须指定可兑换用户", + )); } Ok(allowed_user_ids)