refactor(error): profile 错误分类全面转 typed error,删除文案匹配

- 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
This commit is contained in:
2026-10-04 18:40:54 +08:00
parent e91c319aa8
commit bd06e42690
10 changed files with 375 additions and 87 deletions
@@ -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` 通过。
@@ -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<RuntimeProfileMembershipErrorCode>`(`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 绑定反序列化会失败。
---
@@ -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
@@ -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<RuntimeProfileMembershipErrorCode> for RuntimeProfileProcedureRejectionCode {
fn from(code: RuntimeProfileMembershipErrorCode) -> Self {
Self::Membership(code)
}
}
impl From<RuntimeProfileRewardCodeRedeemErrorCode> 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<RuntimeProfileRewardCodeRedeemSnapshot>,
pub error_message: Option<String>,
/// 核销业务拒绝的机器可读错误码;非业务错误(入参、钱包等)为 `None`。
pub error_code: Option<RuntimeProfileRewardCodeRedeemErrorCode>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -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<module_runtime::RuntimeProfileProcedureRejectionCode>,
message: Option<String>,
) -> 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()))
@@ -1115,7 +1115,14 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result(
result: RuntimeProfileRewardCodeRedeemProcedureResult,
) -> Result<RuntimeProfileRewardCodeRedeemRecord, SpacetimeClientError> {
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 {
@@ -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;
@@ -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;
}
@@ -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<RuntimeProfileRewardCodeRedeemSnapshot>,
pub error_message: Option<String>,
pub error_code: Option<RuntimeProfileRewardCodeRedeemErrorCode>,
}
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult {
@@ -58,6 +58,74 @@ impl ProfileMembershipProcedureError {
}
}
/// 兑换码核销 procedure 的错误载体。
///
/// 业务拒绝携带机器可读 [`RuntimeProfileRewardCodeRedeemErrorCode`];入参、钱包等其它错误保持
/// 普通字符串,透传时 `error_code` 为 `null`(与会员链路同一约定)。
enum ProfileRewardCodeRedeemProcedureError {
Classified(RuntimeProfileRewardCodeRedeemErrorCode, String),
Plain(String),
}
impl From<String> 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<String>,
) -> Self {
Self::Classified(code, message.into())
}
fn into_parts(self) -> (String, Option<RuntimeProfileRewardCodeRedeemErrorCode>) {
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<RuntimeProfileRewardCodeRedeemSnapshot, String> {
) -> Result<RuntimeProfileRewardCodeRedeemSnapshot, ProfileRewardCodeRedeemProcedureError> {
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,