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:
2026-10-03 12:46:50 +08:00
parent 484db6cb7b
commit e5de57f7f8
4 changed files with 595 additions and 210 deletions
@@ -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