feat(会员): 管理接口校验调用方身份、写入侧拦倒挂目录、会员订单冻结结算快照
- profile.rs:10 个配置管理 procedure(任务/钱包/充值商品/会员档位/升级报价)补 require_editor_generation_runtime_service_identity,只允许 api-server 服务身份调用 - catalog.rs:新增 validate_runtime_profile_membership_plan_row_against_catalog,写入侧校验 rank 与月价/年价/每期泥点严格递增并拒绝倒挂 - errors.rs:新增 RuntimeProfileFieldError::InvalidMembershipPlanOrder 及中文文案 - lib.rs:导出 validate_runtime_profile_membership_plan_row_against_catalog - catalog.rs:新增 4 个单测覆盖种子目录、单调改价、倒挂价格/泥点与重复 rank - profile.rs:会员订单建单时冻结档位/价格/补点快照到订单行,支付结算只认快照,历史 Pending 订单回退按目录重算 - profile.rs:新增 project_profile_membership_cycle_at 只读投射账期,建单金额不再写库刷新,与预览/结算口径一致 - profile.rs:支付结算不再因档位下架拒绝已受理订单,避免付款回调卡死 - profile.rs:新增 2 个单测覆盖订单快照写入读回与无快照回退 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -92,6 +92,7 @@ pub enum RuntimeProfileFieldError {
|
||||
InvalidRechargeProductKind,
|
||||
InvalidRechargeProductFields,
|
||||
InvalidMembershipPlan,
|
||||
InvalidMembershipPlanOrder,
|
||||
MissingPaymentChannel,
|
||||
#[cfg(any())]
|
||||
MissingWorldKey,
|
||||
@@ -194,6 +195,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
Self::InvalidMembershipPlan => {
|
||||
f.write_str("会员档位配置无效:月价、年价、每期泥点与并发上限必须有效")
|
||||
}
|
||||
Self::InvalidMembershipPlanOrder => f.write_str(
|
||||
"会员档位必须按 rank 严格递增:更高档位的月价、年价与每期泥点都必须更高",
|
||||
),
|
||||
Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"),
|
||||
#[cfg(any())]
|
||||
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
|
||||
|
||||
@@ -28,6 +28,7 @@ pub use membership::{
|
||||
quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row,
|
||||
runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank,
|
||||
runtime_profile_membership_product_id,
|
||||
validate_runtime_profile_membership_plan_row_against_catalog,
|
||||
};
|
||||
|
||||
use shared_kernel::format_rfc3339 as format_shared_rfc3339;
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
//!
|
||||
//! 目录不变量:按 `rank` 升序,月价、年价、每期泥点必须**严格递增**(`Normal` 为 0)。
|
||||
//! 升级补差按 `rank` 判定、按价差 / 点差计价,若高 `rank` 的行反而更便宜或更少点,
|
||||
//! `saturating_sub` 会把补差静默算成 0;后台改价时不得破坏该不变量。
|
||||
//! 报价会直接报错;后台改价时由 [`validate_runtime_profile_membership_plan_row_against_catalog`]
|
||||
//! 在写入侧拦住,禁止把倒挂目录落库。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -19,8 +20,8 @@ use serde::{Deserialize, Serialize};
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
use crate::{
|
||||
RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipModelAccess,
|
||||
RuntimeProfileMembershipPlan,
|
||||
RuntimeProfileFieldError, RuntimeProfileMembershipCycleKind,
|
||||
RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan,
|
||||
};
|
||||
|
||||
/// 目录一行:一个档位的可配置价格与权益。
|
||||
@@ -140,6 +141,35 @@ pub fn resolve_runtime_profile_membership_plan_row(
|
||||
.find(|row| row.plan == plan)
|
||||
}
|
||||
|
||||
/// 校验后台写入的档位不会破坏目录不变量。
|
||||
///
|
||||
/// `candidate` 是本次要落库的行,`existing` 是目录里的其余行(同 `plan` 的旧行会被忽略)。
|
||||
/// 只比较 `candidate` 与其余每一行:这样任何单次改写都不能制造新的倒挂,而目录原本单调时
|
||||
/// 写完后仍严格单调;同时历史脏目录不会把后台彻底锁死,后台仍能逐档修正。
|
||||
pub fn validate_runtime_profile_membership_plan_row_against_catalog(
|
||||
candidate: &RuntimeProfileMembershipPlanRecord,
|
||||
existing: &[RuntimeProfileMembershipPlanRecord],
|
||||
) -> Result<(), RuntimeProfileFieldError> {
|
||||
for row in existing.iter().filter(|row| row.plan != candidate.plan) {
|
||||
if row.rank == candidate.rank {
|
||||
return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder);
|
||||
}
|
||||
let (lower, higher) = if row.rank < candidate.rank {
|
||||
(row, candidate)
|
||||
} else {
|
||||
(candidate, row)
|
||||
};
|
||||
// 中文注释:月价、年价、每期泥点都必须随 rank 严格递增,否则升级补差会算出 0 元 / 0 泥点。
|
||||
if higher.month_price_cents <= lower.month_price_cents
|
||||
|| higher.year_price_cents <= lower.year_price_cents
|
||||
|| higher.period_points <= lower.period_points
|
||||
{
|
||||
return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 目录行 → 可下单套餐快照,价格与权益来自目录表(后台可改)。
|
||||
pub fn build_runtime_profile_membership_plan_snapshot(
|
||||
row: &RuntimeProfileMembershipPlanRecord,
|
||||
@@ -334,6 +364,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_seed_passes_the_admin_upsert_invariant() {
|
||||
let catalog = runtime_profile_membership_plan_catalog();
|
||||
for candidate in &catalog {
|
||||
assert_eq!(
|
||||
validate_runtime_profile_membership_plan_row_against_catalog(candidate, &catalog),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_upsert_allows_a_monotonic_price_change() {
|
||||
let catalog = runtime_profile_membership_plan_catalog();
|
||||
let mut candidate = row(RuntimeProfileMembershipPlan::Plus);
|
||||
candidate.month_price_cents += 1;
|
||||
candidate.year_price_cents += 1;
|
||||
candidate.period_points += 1;
|
||||
assert_eq!(
|
||||
validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_upsert_rejects_inverted_price_or_points() {
|
||||
let catalog = runtime_profile_membership_plan_catalog();
|
||||
|
||||
// 中文注释:把 Plus 定得比 Starter 便宜,破坏「越高档越贵」。
|
||||
let mut candidate = row(RuntimeProfileMembershipPlan::Plus);
|
||||
candidate.month_price_cents = 1;
|
||||
assert_eq!(
|
||||
validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog),
|
||||
Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder)
|
||||
);
|
||||
|
||||
// 中文注释:每期泥点与下一档持平也算倒挂(必须严格递增)。
|
||||
let mut candidate = row(RuntimeProfileMembershipPlan::Pro);
|
||||
candidate.period_points = row(RuntimeProfileMembershipPlan::Plus).period_points;
|
||||
assert_eq!(
|
||||
validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog),
|
||||
Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder)
|
||||
);
|
||||
|
||||
// 中文注释:把 Plus 抬到比 Pro 还贵,同样是倒挂。
|
||||
let mut candidate = row(RuntimeProfileMembershipPlan::Plus);
|
||||
candidate.year_price_cents = row(RuntimeProfileMembershipPlan::Pro).year_price_cents + 1;
|
||||
assert_eq!(
|
||||
validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog),
|
||||
Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_upsert_rejects_duplicate_rank() {
|
||||
let catalog = runtime_profile_membership_plan_catalog();
|
||||
let mut candidate = row(RuntimeProfileMembershipPlan::Plus);
|
||||
candidate.rank = 1;
|
||||
assert_eq!(
|
||||
validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog),
|
||||
Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_price_is_stored_independently_of_the_month_price() {
|
||||
// 中文注释:年价是独立可配置字段,这里断言的是种子数值本身,而不是月价 ×10 的推导关系。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user