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:
@@ -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;
|
||||
|
||||
+28
@@ -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;
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user