From 33a4c8dbc36f371377b0f2b4bf91222edb73fc2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 14:10:10 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=86=E5=9F=9F=E5=B1=82=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=BC=9A=E5=91=98=E8=AE=A1=E5=88=92=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=E4=B8=8E=E6=A1=A3=E4=BD=8D=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 10 + server-rs/crates/module-runtime/src/lib.rs | 2 + .../module-runtime/src/membership_catalog.rs | 337 ++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 server-rs/crates/module-runtime/src/membership_catalog.rs diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index d1a41128d..cdb9c9fbd 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -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`。 diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 966ba291b..3c905bf55 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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; diff --git a/server-rs/crates/module-runtime/src/membership_catalog.rs b/server-rs/crates/module-runtime/src/membership_catalog.rs new file mode 100644 index 000000000..8e17f9c83 --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership_catalog.rs @@ -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 { + 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 { + 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 { + 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::>(); + 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)); + } + } +}