文档(会员目录):明确懒播种策略与只读入口首次写库的副作用

- ensure_default_profile_membership_plan 注释说明 init 与读取 helper 共用、目录空时首次读取会写库
- init_profile_membership_plan_catalog 注释改为不覆盖「表已存在但为空」的存放量库
- 报价与建单注释去掉「不写库」的绝对说法,注明目录空时的懒播种例外
- 同步设计文档 7 / 10.2 与决策日志(播种策略定为保留懒播种 + 明示副作用)
This commit is contained in:
2026-10-04 13:20:06 +08:00
parent fdf636ce95
commit 9d7ca1b3b7
3 changed files with 34 additions and 7 deletions
@@ -9594,3 +9594,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策(行为变化):`会员目录缺少档位 ...` 与「已迁移的会员商品」原先落 502,现在明确 400;兑换码链路本轮未改造,仍保留一个文案匹配函数。procedure result 新增字段属 wire 契约变更,要求 module 与 api-server 同版本部署。
- 影响范围:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`、`server-rs/crates/spacetime-client/src/{active.rs,active/mapper/runtime_profile.rs}`、生成绑定、`server-rs/crates/api-server/src/{runtime_profile.rs,asset_billing.rs,editor_project.rs}`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。
- 验证:`npm run spacetime:generate`;`cargo check --workspace`;`api-server runtime_profile::tests` 41 passed;`module-runtime --lib membership::` 39 passed;`npm run check:encoding`、`npm run check:spacetime-schema`、`cargo fmt -- --check`、`git diff --check` 通过。
## 2026-10-03 会员目录播种策略定为「保留懒播种 + 明示副作用」
- 背景:评审第 20 项指出「只读」procedure(报价 / 充值中心 / 后台查询)首次读取时会经 `ensure_default_profile_membership_plan` 写库,与代码注释「不写库」矛盾。三条候选:①保留懒播种并承认副作用;②只靠 init + 发布期 migration 播种;③只读投影不落表、内存回退代码内目录。
- 决策:选 ①。`init_profile_membership_plan_catalog` 只在数据库首次创建时执行,增量发布不重跑;「表已存在但为空」的存量库必须靠读取 helper 懒播种兜底,否则目录为空会让下单 / 报价失败。保留懒播种,并把「只读入口在目录为空的首次调用会写库、表非空后幂等只读」明确写进代码注释与设计文档 §7,消除注释与实现的矛盾。
- 未选 ②/③ 的原因:②依赖发布流程保证存量库被种上,当前没有这条保证;③改动读路径回退逻辑,风险与收益不匹配。将来若做发布期 migration,再删除懒播种。
- 影响范围:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`(`ensure_default_profile_membership_plan` / `init_profile_membership_plan_catalog` / `membership_plan_row` / `membership_plan_records` / 报价与建单函数注释)、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §7/§10.2、本文件。
@@ -471,6 +471,7 @@ flowchart TD
- 旧充点行不删除(复用 id 并去掉赠送);首充资格与历史订单继续可读。
- 旧会员档位枚举与会员商品行**删除**(会员侧未上线,无需兼容)。
- **不做** `tier → plan` 回填;若实施时发现意外存量会员行,先停下评估再补。
- 会员目录播种(第 20 项结论 = 保留懒播种):`init` 只在首次创建库时跑,增量发布不重跑;「表存在但为空」的存量库靠读取 helper 懒播种,代价是只读入口首次调用会写库。若要彻底移除副作用,需先把播种改到发布期 migration,再删懒播种——当前不做。
- 钱包侧(总额、每日免费、永久余数、账本)口径不允许漂移;充点订单不允许破坏性变更。
---
@@ -528,7 +529,11 @@ flowchart TD
- 会员领域模块 `module-runtime/src/membership/{catalog,cycle,limits,upgrade}.rs`,公历基元抽为 `civil_calendar`。
- 账本来源枚举末尾追加 `MembershipUpgradeGrant`,并同步契约常量、后台索引映射、前端来源标签与生成绑定。
- 新增表 `profile_membership_plan`(主键为档位枚举 `RuntimeProfileMembershipPlan`),同步 `migration.rs`、表目录与生成绑定;
播种 `ensure_default_profile_membership_plan`:表为空时按代码内目录整表播种,表非空时保留后台改价。
播种策略:表为空时按代码内目录整表播种,表非空时保留后台改价。
`#[reducer(init)] init_profile_membership_plan_catalog` 只在数据库首次创建时执行一次(增量发布不重跑),
因此「表已存在但为空」的存量库由读取 helper `membership_plan_row` / `membership_plan_records` 里的
`ensure_default_profile_membership_plan` 懒播种兜底。**副作用**:报价 / 充值中心 / 后台查询等只读入口在
目录为空的首次调用会写库,表非空后保持只读;这是有意保留的策略,见 §7。
- 删列与清退:`profile_membership` 去掉 `tier` / `cycle_period_days`、追加
`plan` / `cycle_index` / `cycle_count` / `cycle_kind`;`profile_recharge_product_config` 去掉
`tier` 与四个 `membership_*` 字段;`RuntimeProfileMembershipTier`、会员权益矩阵、
@@ -8404,9 +8404,12 @@ fn list_profile_membership_plan_records(
/// 补差升级报价。非会员、降档、月年互换、当前套餐重复购买都返回可读错误,不做静默兜底。
///
/// 全程只读:不刷新账期 / 钱包(不写库、不发账本),改用 [`project_profile_membership_cycle_at`]
/// 全程不刷新账期 / 钱包(不写会员行、不发账本),改用 [`project_profile_membership_cycle_at`]
/// 在内存里把会员行投射到当前时刻再报价,因此与建单、结算读到的账期口径一致,
/// 也不会让「预览报价」提前消耗用户的懒刷新。
///
/// 例外:读取档位目录走 [`membership_plan_snapshot`] → [`membership_plan_row`],若目录表尚为空
/// (存量库增量发布后首次读取)会触发懒播种写库;表非空后保持只读。
fn build_profile_membership_upgrade_quote_record(
ctx: &ReducerContext,
input: RuntimeProfileMembershipUpgradeQuoteGetInput,
@@ -9049,6 +9052,11 @@ fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig
}
/// 中文注释:会员档位目录以代码内目录为种子真相源,表为空时整表播种一次;后台改价后不再覆盖。
///
/// 播种策略(已定):`init` reducer 与读取 helper(`membership_plan_row` / `membership_plan_records`)
/// 共用本函数。`init` 只在数据库首次创建时执行,增量发布不会重跑,因此「表已存在但为空」的存放量库
/// 仍要靠读取 helper 懒播种。**代价**:报价 / 充值中心 / 后台查询等只读入口在目录为空的首次调用会写库;
/// 表非空后本函数直接返回,后续调用保持只读。若将来改为发布期统一播种,再删除这里的懒播种与注释。
fn ensure_default_profile_membership_plan(ctx: &ReducerContext) {
if ctx.db.profile_membership_plan().count() > 0 {
return;
@@ -9078,8 +9086,10 @@ fn ensure_default_profile_membership_plan(ctx: &ReducerContext) {
/// 模块初始化生命周期:只在数据库首次创建时把会员档位目录整表播种。
///
/// 迁移目的:报价 / 充值中心 / 后台查询等只读入口不再调用 `ensure_default_profile_membership_plan`
/// 写库;存量数据库此前已由懒播种写入了目录行,新建数据库则由本 reducer 播种。
/// 本 reducer **不覆盖**「目录表已存在但为空」的存放量库:增量发布不会重跑 `init`。这类库由
/// 读取 helper 里的 [`ensure_default_profile_membership_plan`] 懒播种兜底,因此报价 / 充值中心 /
/// 后台查询等「只读」入口首次调用仍可能写库(表非空后幂等只读)。这是当前有意保留的播种策略,
/// 不是漏改;若改为发布期统一播种,需同步删除懒播种调用并更新本注释。
#[spacetimedb::reducer(init)]
pub fn init_profile_membership_plan_catalog(ctx: &ReducerContext) {
ensure_default_profile_membership_plan(ctx);
@@ -9464,7 +9474,8 @@ fn membership_plan_row(
ctx: &ReducerContext,
plan: RuntimeProfileMembershipPlan,
) -> Option<ProfileMembershipPlan> {
// 存量库增量发布不会触发 init reducer;首次读取时懒播种,避免目录为空导致下单 / 报价失败。
// 存量库增量发布不会触发 init reducer;首次读取时懒播种(会写库),避免目录为空导致下单 / 报价失败;
// 表非空后本调用保持只读。
ensure_default_profile_membership_plan(ctx);
ctx.db.profile_membership_plan().plan().find(&plan)
}
@@ -9507,7 +9518,8 @@ fn membership_plan_snapshot(
/// 充值中心与后台共用的目录行读取,按 `rank` 升序;表为空时先补种代码内目录。
fn membership_plan_records(ctx: &ReducerContext) -> Vec<RuntimeProfileMembershipPlanRecord> {
// 存量库增量发布不会触发 init reducer;首次读取时懒播种,避免充值中心目录为空。
// 存量库增量发布不会触发 init reducer;首次读取时懒播种(会写库),避免充值中心目录为空;
// 表非空后本调用保持只读。
ensure_default_profile_membership_plan(ctx);
let mut rows = ctx.db.profile_membership_plan().iter().collect::<Vec<_>>();
rows.sort_by(|left, right| {
@@ -9683,7 +9695,10 @@ fn resolve_enabled_profile_recharge_order_product(
///
/// 会员订单应付金额:首次购买按目录价;有效期内升级按补差报价重算。
/// 计算补差前先用 [`project_profile_membership_cycle_at`] 只读地把账期投射到建单时刻,
/// 不用过期账期、也不写库,因此建单金额与「预览报价」「支付结算」三处口径一致。
/// 不用过期账期,因此建单金额与「预览报价」「支付结算」三处口径一致。
///
/// 例外:读取档位目录同样走懒播种,目录表为空的首次调用会写库(见
/// [`ensure_default_profile_membership_plan`] 的播种策略说明)。
fn build_profile_membership_order_snapshot(
ctx: &ReducerContext,
user_id: &str,