领域层:新增会员计划目录种子与档位解析

- membership_catalog.rs:runtime_profile_membership_plan_catalog 一行一档(Starter ¥39/400/¥390、Plus ¥99/1150/¥990、Pro ¥299/3650/¥2990、Max ¥699/8650/¥6990、Normal 全 0 且不可购买)
- 新增 RuntimeProfileMembershipPlanCatalogRow 与 RuntimeProfileMembershipPlanSnapshot
- resolve_runtime_profile_membership_plan_snapshot 按所选周期取月价 / 年价;rank 与可购买判定只来自目录
- 单测覆盖档位覆盖与排序、价格与点数、年价独立存储、Normal 语义、周期取价
- 设计文档同步登记目录纯函数入口

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 14:10:10 +08:00
parent 969ee329d7
commit 33a4c8dbc3
3 changed files with 349 additions and 0 deletions
@@ -247,6 +247,16 @@ pub struct RuntimeProfileMembershipUpgradeQuote {
}
```
目录纯函数(`membership_catalog`,无 IO):
- `runtime_profile_membership_plan_catalog()`:后台 `profile_membership_plan` 表的种子真相源,
一行一档(`title` / `rank` / `month_price_cents` / `year_price_cents` / `period_points` /
`model_access` / `concurrent_job_limit` / `sort_order` / `enabled`)。
- `resolve_runtime_profile_membership_plan_snapshot(plan, cycle_kind)`:
「档位 + 周期 → 可下单套餐」的唯一解析入口,`price_cents` 取所选周期的价格字段。
- `runtime_profile_membership_plan_rank(plan)` / `is_runtime_profile_membership_plan_purchasable(plan)`:
升级比较与可购买判定的唯一入口(`Normal` 与下架档位都不可购买)。
`RuntimeProfileMembershipSnapshot` / `RuntimeProfileMembershipRecord`:
`tier` → `plan`(枚举),删 `cycle_period_days`,新增 `cycle_index` / `cycle_count` / `cycle_kind`。
@@ -8,6 +8,8 @@ mod membership_cycle;
pub use membership_cycle::*;
mod membership_limits;
pub use membership_limits::*;
mod membership_catalog;
pub use membership_catalog::*;
mod application;
pub use agc_models::*;
mod commands;
@@ -0,0 +1,337 @@
//! 会员计划目录(纯数据 + 解析)。
//!
//! 目录一行一档:标题、排序 `rank`、月价、独立可配置年价、每期泥点、模型权限、并发上限、
//! 排序与上下架。月付与年付共享同一 `period_points`(年付 = 12 次同额度发放),`cycle_count`
//! 由周期类型推导,不落目录字段。
//!
//! 本模块是后台 `profile_membership_plan` 表的种子真相源,也是升级比较 `rank` 的唯一来源;
//! 价格是**后端唯一真相**,前端展示价只用于渲染,下单时后端按同一份目录重算。
//!
//! 本期模型权限只落字段、只用于展示,不做服务端拦截;并发上限只做判定,同样不拦截。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{
RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipModelAccess,
RuntimeProfileMembershipPlan,
};
/// 目录一行:一个档位的可配置价格与权益。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileMembershipPlanCatalogRow {
pub plan: RuntimeProfileMembershipPlan,
pub title: String,
pub rank: u8,
pub month_price_cents: u64,
pub year_price_cents: u64,
pub period_points: u64,
pub model_access: RuntimeProfileMembershipModelAccess,
pub concurrent_job_limit: u32,
pub sort_order: i32,
pub enabled: bool,
}
/// 目录行按选定周期解析出的「可下单套餐」快照:`price_cents` 只保留所选周期的那一档价格。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileMembershipPlanSnapshot {
pub plan: RuntimeProfileMembershipPlan,
pub title: String,
pub rank: u8,
pub cycle_kind: RuntimeProfileMembershipCycleKind,
pub cycle_count: u32,
pub price_cents: u64,
pub period_points: u64,
pub model_access: RuntimeProfileMembershipModelAccess,
pub concurrent_job_limit: u32,
}
/// 会员目录种子(一行一档,按 `sort_order` 升序)。
///
/// 数值来自产品原型 `充值与会员.html`,`Max` 的并发上限用哨兵
/// [`MEMBERSHIP_UNLIMITED_CONCURRENCY`](crate::MEMBERSHIP_UNLIMITED_CONCURRENCY) 表示「不设上限」。
/// `Normal` 是非会员占位档位,不可购买,因此 `enabled = false`。
pub fn runtime_profile_membership_plan_catalog() -> Vec<RuntimeProfileMembershipPlanCatalogRow> {
use RuntimeProfileMembershipModelAccess::{Basic, Full};
use RuntimeProfileMembershipPlan::{Max, Normal, Plus, Pro, Starter};
vec![
RuntimeProfileMembershipPlanCatalogRow {
plan: Normal,
title: "非会员".to_string(),
rank: 0,
month_price_cents: 0,
year_price_cents: 0,
period_points: 0,
model_access: Basic,
concurrent_job_limit: 1,
sort_order: 0,
enabled: false,
},
RuntimeProfileMembershipPlanCatalogRow {
plan: Starter,
title: "Starter".to_string(),
rank: 1,
month_price_cents: 3_900,
year_price_cents: 39_000,
period_points: 400,
model_access: Basic,
concurrent_job_limit: 1,
sort_order: 10,
enabled: true,
},
RuntimeProfileMembershipPlanCatalogRow {
plan: Plus,
title: "Plus".to_string(),
rank: 2,
month_price_cents: 9_900,
year_price_cents: 99_000,
period_points: 1_150,
model_access: Full,
concurrent_job_limit: 3,
sort_order: 20,
enabled: true,
},
RuntimeProfileMembershipPlanCatalogRow {
plan: Pro,
title: "Pro".to_string(),
rank: 3,
month_price_cents: 29_900,
year_price_cents: 299_000,
period_points: 3_650,
model_access: Full,
concurrent_job_limit: 5,
sort_order: 30,
enabled: true,
},
RuntimeProfileMembershipPlanCatalogRow {
plan: Max,
title: "Max".to_string(),
rank: 4,
month_price_cents: 69_900,
year_price_cents: 699_000,
period_points: 8_650,
model_access: Full,
concurrent_job_limit: crate::MEMBERSHIP_UNLIMITED_CONCURRENCY,
sort_order: 40,
enabled: true,
},
]
}
/// 取某个档位的目录行;枚举本身永远有目录行,返回 `Option` 只是为了把「缺失」显式化。
pub fn resolve_runtime_profile_membership_plan_row(
plan: RuntimeProfileMembershipPlan,
) -> Option<RuntimeProfileMembershipPlanCatalogRow> {
runtime_profile_membership_plan_catalog()
.into_iter()
.find(|row| row.plan == plan)
}
/// 档位 + 周期 → 可下单套餐快照;金额按所选周期的价格字段取值。
pub fn resolve_runtime_profile_membership_plan_snapshot(
plan: RuntimeProfileMembershipPlan,
cycle_kind: RuntimeProfileMembershipCycleKind,
) -> Option<RuntimeProfileMembershipPlanSnapshot> {
let row = resolve_runtime_profile_membership_plan_row(plan)?;
let price_cents = match cycle_kind {
RuntimeProfileMembershipCycleKind::Monthly => row.month_price_cents,
RuntimeProfileMembershipCycleKind::Yearly => row.year_price_cents,
};
Some(RuntimeProfileMembershipPlanSnapshot {
plan: row.plan,
title: row.title,
rank: row.rank,
cycle_kind,
cycle_count: cycle_kind.cycle_count(),
price_cents,
period_points: row.period_points,
model_access: row.model_access,
concurrent_job_limit: row.concurrent_job_limit,
})
}
/// 升级比较基准:目录里的 `rank`(`Normal = 0`,越大越高)。
pub fn runtime_profile_membership_plan_rank(plan: RuntimeProfileMembershipPlan) -> u8 {
resolve_runtime_profile_membership_plan_row(plan)
.map(|row| row.rank)
.unwrap_or(0)
}
/// 只有在售档位可以下单:`Normal` 与下架档位都不允许购买。
pub fn is_runtime_profile_membership_plan_purchasable(plan: RuntimeProfileMembershipPlan) -> bool {
resolve_runtime_profile_membership_plan_row(plan)
.map(|row| row.enabled && row.plan.is_purchasable())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RuntimeProfileMembershipCycleKind, is_unlimited_concurrency};
fn row(plan: RuntimeProfileMembershipPlan) -> RuntimeProfileMembershipPlanCatalogRow {
resolve_runtime_profile_membership_plan_row(plan).expect("每个档位都应有目录行")
}
#[test]
fn catalog_covers_every_plan_once_in_rank_order() {
let catalog = runtime_profile_membership_plan_catalog();
assert_eq!(catalog.len(), 5);
let plans = catalog.iter().map(|row| row.plan).collect::<Vec<_>>();
assert_eq!(
plans,
vec![
RuntimeProfileMembershipPlan::Normal,
RuntimeProfileMembershipPlan::Starter,
RuntimeProfileMembershipPlan::Plus,
RuntimeProfileMembershipPlan::Pro,
RuntimeProfileMembershipPlan::Max,
]
);
let mut sorted = catalog.clone();
sorted.sort_by_key(|row| row.sort_order);
assert_eq!(catalog, sorted, "目录必须按 sort_order 升序");
assert!(
catalog.windows(2).all(|pair| pair[0].rank < pair[1].rank),
"rank 必须严格递增"
);
}
#[test]
fn catalog_prices_and_points_match_the_product_prototype() {
let starter = row(RuntimeProfileMembershipPlan::Starter);
assert_eq!(starter.month_price_cents, 3_900);
assert_eq!(starter.year_price_cents, 39_000);
assert_eq!(starter.period_points, 400);
assert_eq!(
starter.model_access,
RuntimeProfileMembershipModelAccess::Basic
);
assert_eq!(starter.concurrent_job_limit, 1);
let plus = row(RuntimeProfileMembershipPlan::Plus);
assert_eq!(
(
plus.month_price_cents,
plus.year_price_cents,
plus.period_points
),
(9_900, 99_000, 1_150)
);
assert_eq!(plus.model_access, RuntimeProfileMembershipModelAccess::Full);
assert_eq!(plus.concurrent_job_limit, 3);
let pro = row(RuntimeProfileMembershipPlan::Pro);
assert_eq!(
(
pro.month_price_cents,
pro.year_price_cents,
pro.period_points
),
(29_900, 299_000, 3_650)
);
assert_eq!(pro.concurrent_job_limit, 5);
let max = row(RuntimeProfileMembershipPlan::Max);
assert_eq!(
(
max.month_price_cents,
max.year_price_cents,
max.period_points
),
(69_900, 699_000, 8_650)
);
assert!(is_unlimited_concurrency(max.concurrent_job_limit));
}
#[test]
fn year_price_is_stored_independently_of_the_month_price() {
// 中文注释:年价是独立可配置字段,这里断言的是种子数值本身,而不是月价 ×10 的推导关系。
for (plan, year_price_cents) in [
(RuntimeProfileMembershipPlan::Starter, 39_000_u64),
(RuntimeProfileMembershipPlan::Plus, 99_000),
(RuntimeProfileMembershipPlan::Pro, 299_000),
(RuntimeProfileMembershipPlan::Max, 699_000),
] {
assert_eq!(row(plan).year_price_cents, year_price_cents);
}
}
#[test]
fn normal_plan_grants_nothing_and_is_not_purchasable() {
let normal = row(RuntimeProfileMembershipPlan::Normal);
assert_eq!(normal.rank, 0);
assert_eq!(
(
normal.month_price_cents,
normal.year_price_cents,
normal.period_points
),
(0, 0, 0)
);
assert_eq!(
normal.model_access,
RuntimeProfileMembershipModelAccess::Basic
);
assert_eq!(normal.concurrent_job_limit, 1);
assert!(!normal.enabled);
assert!(!is_runtime_profile_membership_plan_purchasable(
RuntimeProfileMembershipPlan::Normal
));
}
#[test]
fn plan_rank_follows_catalog_order() {
assert_eq!(
runtime_profile_membership_plan_rank(RuntimeProfileMembershipPlan::Normal),
0
);
assert_eq!(
runtime_profile_membership_plan_rank(RuntimeProfileMembershipPlan::Plus),
2
);
assert_eq!(
runtime_profile_membership_plan_rank(RuntimeProfileMembershipPlan::Max),
4
);
}
#[test]
fn snapshot_uses_the_price_of_the_selected_cycle() {
let monthly = resolve_runtime_profile_membership_plan_snapshot(
RuntimeProfileMembershipPlan::Pro,
RuntimeProfileMembershipCycleKind::Monthly,
)
.expect("Pro 应有套餐快照");
assert_eq!(monthly.price_cents, 29_900);
assert_eq!(monthly.cycle_count, 1);
let yearly = resolve_runtime_profile_membership_plan_snapshot(
RuntimeProfileMembershipPlan::Pro,
RuntimeProfileMembershipCycleKind::Yearly,
)
.expect("Pro 应有套餐快照");
assert_eq!(yearly.price_cents, 299_000);
assert_eq!(yearly.cycle_count, 12);
assert_eq!(yearly.period_points, 3_650);
assert_eq!(yearly.rank, monthly.rank);
}
#[test]
fn only_enabled_plans_are_purchasable() {
for plan in [
RuntimeProfileMembershipPlan::Starter,
RuntimeProfileMembershipPlan::Plus,
RuntimeProfileMembershipPlan::Pro,
RuntimeProfileMembershipPlan::Max,
] {
assert!(is_runtime_profile_membership_plan_purchasable(plan));
}
}
}