领域层:新增会员计划目录种子与档位解析
- 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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user