diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 448e75896..38a8a29cf 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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、本文件。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 9b6af65ab..a97893ab5 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -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`、会员权益矩阵、 diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 32c9b84d3..aabd8c845 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -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 { - // 存量库增量发布不会触发 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 { - // 存量库增量发布不会触发 init reducer;首次读取时懒播种,避免充值中心目录为空。 + // 存量库增量发布不会触发 init reducer;首次读取时懒播种(会写库),避免充值中心目录为空; + // 表非空后本调用保持只读。 ensure_default_profile_membership_plan(ctx); let mut rows = ctx.db.profile_membership_plan().iter().collect::>(); 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,