diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 65fabd8ff..f390729dc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9728,3 +9728,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(行为变化):后台兑换码的 `兑换码不存在`、入参校验失败、私有码缺可兑换用户由 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` 通过。 + +## 2026-10-03 后台邀请码管理补机器可读错误码 + +- 背景:后台邀请码的 `admin_upsert_profile_invite_code` / `admin_list_profile_invite_codes` 与上一轮的后台兑换码同病——`map_runtime_profile_invite_code_admin_procedure_result` 把 `ok = false` 一律拍成 `Procedure(String)`,`邀请码已被其他用户占用` 这类明确业务拒绝在 `api-server` 落 502,且 `map_runtime_profile_client_error` 的注释夸大成 «profile procedure 的业务拒绝都已 typed»。 +- 决策:`module-runtime` 新增 `RuntimeProfileInviteCodeAdminErrorCode`(`InvalidField` / `InUse`);两个后台邀请码 procedure 结果末尾追加 `error_code: Option<...>`;`spacetime-module` 用 `ProfileInviteCodeAdminProcedureError::{Classified, Plain}` 赋码,字段校验归 `invalid_field`、被他人占用归 `in_use`;`RuntimeProfileProcedureRejectionCode` 增加 `InviteCodeAdmin` 聚合变体,`spacetime-client` mapper 有码走 `ProcedureRejected`、缺失时保持 `Procedure`;`api-server` 复用既有映射,并把注释收窄为 «带 typed error_code 的四条链路»。 +- 行为变化:后台邀请码的入参校验失败与 `邀请码已被其他用户占用` 由 502 变为 400 且带机器码;procedure result 新增字段仍要求 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` 32 passed;`cargo test -p api-server runtime_profile::tests` 43 passed;`cargo test -p module-runtime --lib` 114 passed;`cargo test -p spacetime-module` 271 passed。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index e310e96e0..5f300ab28 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -460,15 +460,20 @@ flowchart TD `RuntimeProfileRedeemCodeAdminProcedureResult` / `RuntimeProfileRedeemCodeAdminListProcedureResult` 末尾追加 `error_code`;字段校验错误统一归 `invalid_field`,私有码缺可兑换用户归 `allowed_users_required`,停用不存在的码归 `not_found`。 +- 后台邀请码管理链路:`module-runtime` 的 `RuntimeProfileInviteCodeAdminErrorCode`(`InvalidField` / + `InUse`)在拒绝点显式产出,`RuntimeProfileInviteCodeAdminProcedureResult` / + `RuntimeProfileInviteCodeAdminListProcedureResult` 末尾追加 `error_code`;字段校验错误统一归 + `invalid_field`,邀请码被他人占用归 `in_use`。 - `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。 + `code.as_str()`,基础设施错误保持 502。尚未接 typed code 的 procedure 字段校验类错误(如 + user_id 缺失)不产出 `error_code`,仍按字符串错误落到 502。 - 行为变化:`会员目录缺少档位 ...`、「已迁移的会员商品」、兑换码核销的 `兑换码不存在` / - `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效`,以及后台兑换码 - 管理的 `兑换码不存在` / 入参校验失败 / 私有码缺可兑换用户原先落到 502,现在有明确错误码,统一 - 400。`api-server` 已删除 `is_runtime_profile_redeem_code_domain_error` 文案匹配函数。 + `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效`,后台兑换码 + 管理的 `兑换码不存在` / 入参校验失败 / 私有码缺可兑换用户,以及后台邀请码管理的入参校验失败 / + `邀请码已被其他用户占用` 原先落到 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 981272697..18ee6dd6c 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1462,8 +1462,9 @@ pub async fn get_profile_play_stats( fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { match &error { - // profile procedure 的业务拒绝:机器码与人读文案都取自 typed 变体, - // HTTP 状态、错误码和 message 全部结构化透传,不做任何字符串匹配或解析。 + // 带 typed error_code 的 profile procedure(会员、兑换码核销、后台兑换码管理、后台邀请码 + // 管理)业务拒绝:机器码与人读文案都取自 typed 变体,HTTP 状态、错误码和 message 全部 + // 结构化透传,不做任何字符串匹配或解析;其余 procedure 仍按裸字符串落下面的 502。 SpacetimeClientError::ProcedureRejected { code, message } => { AppError::from_status(StatusCode::BAD_REQUEST) .with_code(code.as_str()) @@ -2484,14 +2485,15 @@ mod tests { use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile}; use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, - RuntimeProfileDailyFreePointsRecord, RuntimeProfileMembershipCycleKind, - RuntimeProfileMembershipErrorCode, RuntimeProfileMembershipModelAccess, - RuntimeProfileMembershipPlan, RuntimeProfileMembershipPlanRecord, - RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeAdminErrorCode, - RuntimeProfileRewardCodeRedeemErrorCode, RuntimeProfileWalletLedgerSourceType, + RuntimeProfileDailyFreePointsRecord, RuntimeProfileInviteCodeAdminErrorCode, + RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipErrorCode, + RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, + RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipRecord, + RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeAdminErrorCode, RuntimeProfileRewardCodeRedeemErrorCode, + RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2606,6 +2608,33 @@ mod tests { } } + #[test] + fn invite_code_admin_rejections_map_to_bad_request() { + // 后台邀请码管理与后台兑换码管理同构:「邀请码已被占用」/入参非法必须落 400 并带机器码, + // 不能因为缺 error_code 被当成 SpacetimeDB 基础设施故障落 502。 + for (code, expected_code, message) in [ + ( + RuntimeProfileInviteCodeAdminErrorCode::InvalidField, + "invalid_field", + "邀请码不能为空", + ), + ( + RuntimeProfileInviteCodeAdminErrorCode::InUse, + "in_use", + "邀请码已被其他用户占用", + ), + ] { + 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 e09213f5b..2196436ce 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1593,8 +1593,31 @@ impl RuntimeProfileRedeemCodeAdminErrorCode { } } -/// profile procedure 业务拒绝的统一错误码:把会员、兑换码核销与后台兑换码管理的 typed code -/// 收在一处。 +/// 后台邀请码管理的机器可读拒绝码。 +/// +/// 与后台兑换码管理同一约定:邀请码增改的拒绝点显式产出本枚举并随 procedure 结果透传, +/// `api-server` 据此返回 400 而不是把业务拒绝当成 502。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileInviteCodeAdminErrorCode { + /// 邀请码管理入参不合法(缺码、元数据不是对象、有效期倒挂等)。 + InvalidField, + /// 邀请码已被其他用户占用,不能改绑给当前用户。 + InUse, +} + +impl RuntimeProfileInviteCodeAdminErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::InvalidField => "invalid_field", + Self::InUse => "in_use", + } + } +} + +/// profile procedure 业务拒绝的统一错误码:把会员、兑换码核销、后台兑换码管理与后台邀请码管理 +/// 的 typed code 收在一处。 /// /// 只在 `spacetime-client` 进程内传递,不落表、不过 wire,因此不派生 `SpacetimeType`。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1602,6 +1625,7 @@ pub enum RuntimeProfileProcedureRejectionCode { Membership(RuntimeProfileMembershipErrorCode), RewardCodeRedeem(RuntimeProfileRewardCodeRedeemErrorCode), RedeemCodeAdmin(RuntimeProfileRedeemCodeAdminErrorCode), + InviteCodeAdmin(RuntimeProfileInviteCodeAdminErrorCode), } impl RuntimeProfileProcedureRejectionCode { @@ -1610,6 +1634,7 @@ impl RuntimeProfileProcedureRejectionCode { Self::Membership(code) => code.as_str(), Self::RewardCodeRedeem(code) => code.as_str(), Self::RedeemCodeAdmin(code) => code.as_str(), + Self::InviteCodeAdmin(code) => code.as_str(), } } } @@ -1632,6 +1657,12 @@ impl From for RuntimeProfileProcedureRej } } +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileInviteCodeAdminErrorCode) -> Self { + Self::InviteCodeAdmin(code) + } +} + /// 会员领域错误:机器可读 `code` + 人读 `message`。 /// /// 只在模块内部与 procedure 结果装载之间传递,不落 SpacetimeDB 表,因此不派生 `SpacetimeType`。 @@ -2793,6 +2824,8 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + /// 业务拒绝的机器可读码;入参 / 系统错误为 `None`,由 `api-server` 落 502。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2802,6 +2835,8 @@ pub struct RuntimeProfileInviteCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + /// 业务拒绝的机器可读码;入参 / 系统错误为 `None`,由 `api-server` 落 502。 + 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 ad74ec262..96dd91f75 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -235,7 +235,8 @@ 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 58e7fe632..52720ef66 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 @@ -1325,11 +1325,13 @@ pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result( result: RuntimeProfileInviteCodeAdminProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_invite_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } let snapshot = result.record.ok_or_else(|| { @@ -1345,7 +1347,13 @@ pub(crate) fn map_runtime_profile_invite_code_admin_list_procedure_result( result: RuntimeProfileInviteCodeAdminListProcedureResult, ) -> 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_invite_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } Ok(RuntimeProfileInviteCodeAdminListRecord { @@ -2169,6 +2177,19 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_error_code_back( } } +pub(crate) fn map_runtime_profile_invite_code_admin_error_code_back( + value: crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode, +) -> module_runtime::RuntimeProfileInviteCodeAdminErrorCode { + match value { + crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode::InvalidField => { + module_runtime::RuntimeProfileInviteCodeAdminErrorCode::InvalidField + } + crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode::InUse => { + module_runtime::RuntimeProfileInviteCodeAdminErrorCode::InUse + } + } +} + pub(crate) fn map_runtime_profile_membership_model_access( value: module_runtime::RuntimeProfileMembershipModelAccess, ) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { @@ -2553,4 +2574,39 @@ mod tests { other => panic!("expected Procedure, got {other:?}"), } } + + #[test] + fn admin_invite_code_rejection_uses_typed_code() { + let result = RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("邀请码已被其他用户占用".to_string()), + error_code: Some(crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode::InUse), + }; + + match map_runtime_profile_invite_code_admin_procedure_result(result) { + Err(SpacetimeClientError::ProcedureRejected { code, message }) => { + assert_eq!(code.as_str(), "in_use"); + assert_eq!(message, "邀请码已被其他用户占用"); + } + other => panic!("expected ProcedureRejected, got {other:?}"), + } + } + + #[test] + fn admin_invite_code_without_error_code_stays_plain_procedure_error() { + let result = RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("SpacetimeDB 事务失败".to_string()), + error_code: None, + }; + + match map_runtime_profile_invite_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 e7d05b5ab..ce355243b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -707,6 +707,7 @@ pub mod runtime_profile_feedback_status_type; pub mod runtime_profile_feedback_submission_input_type; pub mod runtime_profile_feedback_submission_procedure_result_type; pub mod runtime_profile_feedback_submission_snapshot_type; +pub mod runtime_profile_invite_code_admin_error_code_type; pub mod runtime_profile_invite_code_admin_list_input_type; pub mod runtime_profile_invite_code_admin_list_procedure_result_type; pub mod runtime_profile_invite_code_admin_procedure_result_type; @@ -1594,6 +1595,7 @@ pub use runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus; pub use runtime_profile_feedback_submission_input_type::RuntimeProfileFeedbackSubmissionInput; pub use runtime_profile_feedback_submission_procedure_result_type::RuntimeProfileFeedbackSubmissionProcedureResult; pub use runtime_profile_feedback_submission_snapshot_type::RuntimeProfileFeedbackSubmissionSnapshot; +pub use runtime_profile_invite_code_admin_error_code_type::RuntimeProfileInviteCodeAdminErrorCode; pub use runtime_profile_invite_code_admin_list_input_type::RuntimeProfileInviteCodeAdminListInput; pub use runtime_profile_invite_code_admin_list_procedure_result_type::RuntimeProfileInviteCodeAdminListProcedureResult; pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs new file mode 100644 index 000000000..cd112299d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs @@ -0,0 +1,18 @@ +// 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 RuntimeProfileInviteCodeAdminErrorCode { + InvalidField, + + InUse, +} + +impl __sdk::InModule for RuntimeProfileInviteCodeAdminErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs index db56e3c0f..359435447 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_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_invite_code_admin_error_code_type::RuntimeProfileInviteCodeAdminErrorCode; use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -14,6 +15,7 @@ pub struct RuntimeProfileInviteCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileInviteCodeAdminListProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs index 70f543006..4acbdce5a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_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_invite_code_admin_error_code_type::RuntimeProfileInviteCodeAdminErrorCode; use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult { 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 c240523c1..c9df58b41 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -173,6 +173,53 @@ fn redeem_code_admin_error_from_field_error( ) } +/// 后台邀请码管理 procedure 的错误载体。 +/// +/// 与 [`ProfileRedeemCodeAdminProcedureError`] 同一约定:业务拒绝带机器可读 +/// [`RuntimeProfileInviteCodeAdminErrorCode`],其它错误保持普通字符串。 +enum ProfileInviteCodeAdminProcedureError { + Classified(RuntimeProfileInviteCodeAdminErrorCode, String), + Plain(String), +} + +impl From for ProfileInviteCodeAdminProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileInviteCodeAdminProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileInviteCodeAdminProcedureError { + fn classified( + code: RuntimeProfileInviteCodeAdminErrorCode, + 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 invite_code_admin_error_from_field_error( + error: RuntimeProfileFieldError, +) -> ProfileInviteCodeAdminProcedureError { + ProfileInviteCodeAdminProcedureError::classified( + RuntimeProfileInviteCodeAdminErrorCode::InvalidField, + error.to_string(), + ) +} + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -2818,12 +2865,17 @@ pub fn admin_upsert_profile_invite_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileInviteCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2838,13 +2890,18 @@ pub fn admin_list_profile_invite_codes( entries, operations, error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileInviteCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileInviteCodeAdminListProcedureResult { + ok: false, + entries: Vec::new(), + operations: Vec::new(), + error_message: Some(message), + error_code, + } + } } } @@ -7919,7 +7976,7 @@ fn admin_disable_profile_redeem_code_record( fn admin_upsert_profile_invite_code_record( ctx: &ReducerContext, input: RuntimeProfileInviteCodeAdminUpsertInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_invite_code_admin_upsert_input( input.admin_user_id, input.invite_code, @@ -7928,7 +7985,7 @@ fn admin_upsert_profile_invite_code_record( input.expires_at_micros, input.updated_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(invite_code_admin_error_from_field_error)?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let user_id = build_admin_profile_invite_code_user_id( &validated_input.admin_user_id, @@ -7944,7 +8001,10 @@ fn admin_upsert_profile_invite_code_record( .find(&validated_input.invite_code) { if existing.user_id != user_id { - return Err("邀请码已被其他用户占用".to_string()); + return Err(ProfileInviteCodeAdminProcedureError::classified( + RuntimeProfileInviteCodeAdminErrorCode::InUse, + "邀请码已被其他用户占用", + )); } ctx.db .profile_invite_code() @@ -8545,10 +8605,10 @@ fn admin_list_profile_invite_code_records( Vec, Vec, ), - String, + ProfileInviteCodeAdminProcedureError, > { let _validated_input = build_runtime_profile_invite_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; + .map_err(invite_code_admin_error_from_field_error)?; let mut entries = ctx .db