From 482af923278d21d05114d9213b2d6fe326492ae2 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:06:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=B3=A5=E7=82=B9?= =?UTF-8?q?=E4=B8=89=E6=B1=A0=E4=B8=8E=E4=BC=9A=E5=91=98=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=96=B9=E6=A1=88=E5=AE=9A=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增【技术设计】泥点三池与会员计费后端设计-2026-10-02.md:三池投影、会员档位枚举与目录表、账期与补差升级、接口契约、破坏性迁移计划与遗留清理清单 - 新增【实施计划】双余额泥点与会员补差升级-2026-10-02.md:规则细则、与现状差异、里程碑与验收判据 - 新增【ADR】泥点三池以单一总额为权威-2026-10-02.md - 新增【ADR】会员账期按开通日自然月-2026-10-02.md - 新增【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md - 新增【ADR】会员档位以枚举为权威-2026-10-02.md - CONTEXT.md 新增会员档位、会员计划目录、模型权限、并发上限等规范词 - decision-log.md 追加 2026-10-02 双余额与会员计费决策条目 - docs/README.md 新增「钱包与会员计费」文档索引分区 Co-authored-by: Junie --- CONTEXT.md | 46 ++ docs/README.md | 9 + .../【ADR】会员档位以枚举为权威-2026-10-02.md | 57 +++ ...¢单复用充值订单与补差升级幂等锚点-2026-10-02.md | 57 +++ ...€�ADR】会员账期按开通日自然月-2026-10-02.md | 58 +++ ...ADR】泥点三池以单一总额为权威-2026-10-02.md | 60 +++ .../shared-memory/decision-log.md | 13 + ...½计划】双余额泥点与会员补差升级-2026-10-02.md | 207 +++++++++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 428 ++++++++++++++++++ 9 files changed, 935 insertions(+) create mode 100644 docs/adr/【ADR】会员档位以枚举为权威-2026-10-02.md create mode 100644 docs/adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md create mode 100644 docs/adr/【ADR】会员账期按开通日自然月-2026-10-02.md create mode 100644 docs/adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md create mode 100644 docs/technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md create mode 100644 docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md diff --git a/CONTEXT.md b/CONTEXT.md index 00f7c178e..1f3c268af 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -287,6 +287,52 @@ _Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另 聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。 _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件 +### 泥点与会员计费 + +**每日免费泥点**: +账号每天按固定额度自动获得的泥点,只在北京时间当天有效,当日 24 点清零后重新发放;不受会员状态影响。 +_Avoid_: 永久赠送、会员额度、可累计的每日额度 + +**月度泥点**: +会员在当前会员账期内可用的泥点,只在本期有效;换期时本期余额清零并由新一期额度覆盖,会员到期时一并清零。 +_Avoid_: 会员点数、周期点数、限时点数、永久可用的会员赠送 + +**永久泥点**: +不过期、不受会员状态影响的泥点,来源是充值与非会员奖励,创作扣点与月度泥点完全一致。 +_Avoid_: 不限时泥点、充值余额、无限泥点、免费泥点 + +**永久余数**: +当前实现中「钱包总额扣除每日免费泥点与月度泥点之后剩下的部分」,即永久泥点在存储层的临时形态,不表示一种来源。 +_Avoid_: 独立余额、第四个池、充值本金 + +**会员账期**: +会员权益的一期有效期,从开通日时刻起按自然月推进到同一天的下一月;年付包含 12 期。 +_Avoid_: 自然月、固定 30 天、账单周期 + +**补差升级**: +在会员有效期内升到更高档位,只补付本期剩余时间段与新档位后续各期的差价,并补发当前期额度差额;已用泥点不返还。 +_Avoid_: 套餐切换、退订重买、即时月转年 + +**可用总额**: +界面上的「现在可用」口径,等于月度泥点加永久泥点,不含每日免费泥点。 +_Avoid_: 钱包总额、总泥点、含每日免费的总和 + +**会员档位**: +会员等级身份,取值为「非会员」与 `Starter` / `Plus` / `Pro` / `Max`,决定每期泥点额度、可用的模型范围、同时运行的独立任务数与升级比较次序;每个档位分月付与年付两种周期。 +_Avoid_: 旧的月卡 / 季卡 / 年卡命名、把档位写成自由字符串、用 `tier` 当档位的规范词 + +**会员计划目录**: +会员档位可配置的价格与权益集合:月价、年价、每期泥点、模型权限、并发上限、排序与上下架。 +_Avoid_: 把价格写死在代码、按档位名字硬编码权益 + +**模型权限**: +会员档位允许选择的模型范围;`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。 +_Avoid_: 计价分辨率档、模型定价档位 + +**并发上限**: +同一账号可同时运行的独立生成任务数上限;`Max` 不设上限。 +_Avoid_: 旧的排队展示字段、把「不设上限」写成空值 + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 diff --git a/docs/README.md b/docs/README.md index d038b7fba..f03df27e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -98,6 +98,15 @@ - [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md) - [编辑器模型定价配置](./【编辑器】模型定价配置管理方案-2026-06-22.md) +## 钱包与会员计费 + +- [双余额泥点与会员补差升级实施计划](./technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md):每日免费 / 月度 / 永久三池并列投影、按开通日自然月计期、年付 12 期与补差升级的规则细则、里程碑与验收判据;方案定稿,未实施。 +- [泥点三池与会员计费后端设计](./technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md):可直接评审的后端 DB schema、账本与幂等、领域数据结构与 HTTP 契约;会员档位改为枚举、目录表以枚举为主键,含破坏性迁移计划与遗留清理清单;待评审,未实施。 +- [泥点三池以单一总额为权威](./adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md):存储层保留 `wallet_balance` 单一权威总额,对外并列三池;永久泥点暂为推导余数,另记独立存储的 TODO。 +- [会员账期按开通日自然月](./adr/【ADR】会员账期按开通日自然月-2026-10-02.md):北京时间开通日锚点、自然月推进与月末夹取、年付 12 期的期数表达与取整口径。 +- [会员订单复用充值订单与补差升级幂等锚点](./adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md):会员订单复用 `profile_recharge_order`、`MembershipUpgradeGrant` 账本幂等、后端只读报价与退款人工复核边界。 +- [会员档位以枚举为权威](./adr/【ADR】会员档位以枚举为权威-2026-10-02.md):档位身份改为 Rust 枚举、目录表 `profile_membership_plan` 以枚举为主键、删除两处 `tier` 列与旧档位枚举、并发用哨兵值 `128` 表示不设上限。 + ## 后端、运维与测试 - [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md) diff --git a/docs/adr/【ADR】会员档位以枚举为权威-2026-10-02.md b/docs/adr/【ADR】会员档位以枚举为权威-2026-10-02.md new file mode 100644 index 000000000..7833da484 --- /dev/null +++ b/docs/adr/【ADR】会员档位以枚举为权威-2026-10-02.md @@ -0,0 +1,57 @@ +# 【ADR】会员档位以枚举为权威 + +状态:已接受(2026-10-02;字段级设计与迁移见 +[`【技术设计】泥点三池与会员计费后端设计-2026-10-02`](../technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md)) + +## 背景 + +- 会员档位今天由 `RuntimeProfileMembershipTier` 枚举承载,但同一个「级别」有两套拼写:旧时长卡命名 + `Month/Season/Year` 与现档位命名 `Starter/Basic/Pro/Ultimate` 互为别名(`runtime_profile_membership_tier_rank` + 把它们映射到同一 rank);其中 `profile_recharge_product_config.tier` 这个枚举列还长在**在线**表上。 +- 会员目录今天不是表:它是 `profile_recharge_product_config` 的四行会员商品(`member_*`)加一组 + `module-runtime` 静态查表(rank / 每期泥点 / 排队上限 / 折扣 / 权益矩阵)。 +- 新产品档位为 `Starter ¥39/400/¥390 · Plus ¥99/1150/¥990 · Pro ¥299/3650/¥2990 · Max ¥699/8650/¥6990`, + 并新增**模型权限**(基础 / 基础+高性能)与**独立任务并发**(1 / 3 / 5 / 不设上限);旧命名与旧档位全部作废。 +- 会员侧未上线,无存量会员行;但 SpacetimeDB 的枚举按变体索引编码,删变体会让已落库的行错位。 + +## 决策 + +1. **档位身份是 Rust 枚举**:新增 `RuntimeProfileMembershipPlan { Normal, Starter, Plus, Pro, Max }`, + 替代字符串 plan id;`Normal` 表示非会员,必须存在。对外 JSON 只传枚举 token(如 `"pro"`), + 服务端**严格解析**,不接受自由字符串。 +2. **目录表以该枚举为主键**:新建 `profile_membership_plan`,主键 `plan`,字段含 + `title` / `rank` / `month_price_cents` / `year_price_cents` / `period_points` / `model_access` / + `concurrent_job_limit` / `sort_order` / `enabled`。一行一档;年价是独立可配置字段, + 不做 `月价 × 10` 推导。后台按档位改价与权益。 +3. **删除旧档位枚举与两处 `tier` 列**:`profile_membership.tier` 与 `profile_recharge_product_config.tier` + 一并删除,`RuntimeProfileMembershipTier` 整体退役;`profile_membership` 新增 `plan`(枚举)与 + `cycle_index` / `cycle_count` / `cycle_kind`。 +4. **并发用哨兵值而不是 `Option`**:`concurrent_job_limit: u32`,`128` 表示「不设上限」; + `Normal = 1`、`Starter = 1`、`Plus = 3`、`Pro = 5`、`Max = 128`。 +5. **模型权限本期只落字段**:`RuntimeProfileMembershipModelAccess { Basic, Full }` 进目录与投影, + 但服务端提交路径本期不做拦截。 +6. **会员订单复用充值订单**,变更快照字段一律用枚举(`RuntimeProfileMembershipPlan` / + `RuntimeProfileMembershipCycleKind` / `RuntimeProfileMembershipChangeKind`),不用字符串。 + +## 影响与代价 + +- 这是对会员侧的一次**破坏性 schema 变更**(删列 + 删枚举),需要明确授权覆盖仓库「枚举只追加」规则: + 会员侧无存量行,`profile_recharge_product_config` 是种子配置,可重建 + 重播种。 +- 账本来源枚举仍只允许**末尾追加**(新增 `MembershipUpgradeGrant`),钱包侧形状不动。 +- 档位比较从「按 `tier` 的别名查表」改为「按目录 `rank`」,升级判断与金额解析都要改到枚举 + 目录。 +- 会员目录成为可配置数据后价格不再写死在代码;代价是必须保证 `Normal` 与上下架字段的种子正确, + 否则会出现不可购买或语义错误的档位。 + +## 备选方案与取舍 + +1. **保留 `plan: String` 当权威,`tier` 只标废弃**:改动最小;代价是档位身份仍有字符串与枚举两套真相, + 且后台改价与升级比较继续绑在旧枚举上。已作废。 +2. **目录写死在代码(枚举 + 静态映射)**:少一张表、价格可审计;代价是后台无法改价改权益。已作废。 +3. **只追加枚举变体(保留 `Month/Season/Year/Basic/Ultimate`)**:最保守;代价是把两套命名与废弃档位 + 永久留在权威模型里,与新目录语义直接冲突。已作废。 + +## 明确不做 + +- 不做 `tier → plan` 的存量回填(会员侧无存量)。 +- 本期不执行模型权限与并发上限的服务端拦截,只落字段与展示。 +- 不删除任何 `points_*` 充点商品行(历史订单与首充资格引用 `product_id`)。 diff --git a/docs/adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md b/docs/adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md new file mode 100644 index 000000000..7b09adc1c --- /dev/null +++ b/docs/adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md @@ -0,0 +1,57 @@ +# 【ADR】会员订单复用充值订单与补差升级幂等锚点 + +状态:已接受(2026-10-02;规则细则、里程碑与验收见 +[`【实施计划】双余额泥点与会员补差升级-2026-10-02`](../technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md)) + +## 背景 + +现有 `profile_recharge_order` 是「商品 + 金额 + 支付状态 + `points_delta`」的形状,微信支付下单与回调、 +未支付订单过期监听、退款结算与对账、后台订单列表五套链路全部围绕它。会员侧未上线,表里没有会员订单; +但会员订单要额外承载「变更前后的档位、周期、补点数、有效期」,而且升级补点必须防重复回调重复发点。 + +现有钱包幂等全靠**稳定账本 id**:扣费与退款是同一 id 的前缀互换(`asset_operation_consume:` ↔ +`asset_operation_refund:`),池子级退款回填依赖这条对称性。账本来源枚举里没有任何「升级补点」类型。 + +另外,权威契约已经把泥点支付视为「只能扣永久泥点的负余额不变量」,并明确会员充值没有可逆 grant 快照、 +退款统一 `manual_review`;新方案引入补差升级后,一条订单对应的权益变化不再只是「加一档」。 + +## 决策 + +1. **会员购买 / 升级订单复用 `profile_recharge_order`**,新增 kind 值(会员购买 / 会员升级), + 变更快照放到表末尾新增的带默认值字段里;`points_delta` 仍然只表示「钱包里加了几个泥点」。 + 不新建会员订单专表——复制五套支付与对账链路的代价远大于加宽一张表,而会员侧未上线没有存量要兼容。 +2. **补差升级新增账本来源类型 `MembershipUpgradeGrant`**,账本 id 用订单维度 + `membership-upgrade-grant:{user}:{order_id}`。重复回调落在同一个账本 id 上,天然幂等; + 同一期内连续升级(Starter→Plus→Pro)产生不同 `order_id`,因此不会互相吞掉。 +3. **升级报价由后端只读接口计算**(入参:目标档位 + 目标周期),返回应付金额、补点、变更后月度余额、有效期; + 下单时后端按同一组输入**重算并落订单快照**,前端只展示。取整规则(金额向上到分、补点向下取整) + 只允许有一处实现,前端展示价不是真相源。 +4. **支付成功才变更**:失败 / 关闭不动原状态;以 `profile_recharge_order` 的支付成功作为唯一事实, + 补点在支付成功回调里落账本并发点。 +5. **会员退款维持 `manual_review`**,但订单快照要能回答「变更前后档位、周期、补点数、有效期」, + 让人工复核有据可依。自动回滚不做。 +6. **连续升级以当前套餐为比较基准**,不回到最初套餐;降档、月转年、年转月一律等到期后再选。 + +## 影响与代价 + +- 订单表继续变宽;`kind` 取值会成为契约的一部分,需要在 `shared-contracts` 与后台筛选里同步。 +- 报价接口是纯读路径,但必须有契约测试保证「报价返回值 == 下单重算值」,否则会出现「看到的价和扣的钱不一致」。 +- 升级补点写的是月度池,退款回填的账本对称性不适用于升级(它没有对应的「扣费」半边), + 因此升级的补点只能靠 `manual_review` 回收,不能自动回缴——这是刻意的。 +- 账本来源枚举扩容后,后台按来源类型展示与筛选要一起更新;契约文档里的来源全集清单必须同批修订。 + +## 备选方案与取舍 + +1. **新建 `profile_membership_order` 专表**:语义最干净、字段不多不少;代价是支付 / 回调 / 退款 / + 过期监听 / 后台列表五套链路都要再实现一遍。已作废。 +2. **复用 `MembershipPeriodGrant` + metadata `action=upgrade`**:少一个枚举值;代价是来源类型语义变宽, + 后台按来源类型统计会混。已作废。 +3. **前端本地计算报价,后端下单时校验**:少一个接口;代价是两处取整实现必须逐字段对齐,且报价可被篡改。已作废。 +4. **升级走自动回收(像泥点退款一样扣永久池 + 欠账冻结)**:体验一致;代价是要定义「月度点已花掉」的 + 回滚顺序,且与「只有永久池可被回收」的现有契约冲突。已作废。 + +## 明确不做 + +- 不做会员权益的自动回滚与 grant 快照反向执行。 +- 不为会员订单新建独立支付通道;不改变现有微信支付回调语义。 +- 不做「重复购买」:当前同套餐同周期只显示「当前套餐」,不产生订单。 diff --git a/docs/adr/【ADR】会员账期按开通日自然月-2026-10-02.md b/docs/adr/【ADR】会员账期按开通日自然月-2026-10-02.md new file mode 100644 index 000000000..de13a093f --- /dev/null +++ b/docs/adr/【ADR】会员账期按开通日自然月-2026-10-02.md @@ -0,0 +1,58 @@ +# 【ADR】会员账期按开通日自然月 + +状态:已接受(2026-10-02;规则细则、里程碑与验收见 +[`【实施计划】双余额泥点与会员补差升级-2026-10-02`](../technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md)) + +## 背景 + +现有会员周期池是**固定 30 天**的:`profile_membership.cycle_period_days` 默认 +`PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS = 30`,`refresh_profile_membership_cycle` 按固定天数步进 +`cycle_started_at` / `cycle_resets_at`,并且内置会员商品的 `duration_days` 全是 30——**当前不存在年付, +也没有「一次发 12 期」这回事**。 + +新产品要求:按开通日计期(9/20 开通 → 10/20 到期,不按自然月)、年付一次购买后按 11 期 + 首发共 12 期逐月发放、 +每期刷新先清上期余额再发新额度、到期未续清月度余额但保留永久泥点。同时年付升级要按 +「后续完整月数 + 本期剩余时间比例」折算补付金额与补点。 + +会员侧未上线,没有存量会员数据需要迁移;但 `cycle_*` 字段、惰性刷新与退款回填已经围绕现有口径写好, +改造要保住这三条链路。 + +## 决策 + +1. **账期锚点 = 北京时间的开通日时刻**,按自然月推进,且**每一期都从原始开通日重算**而不是从上一期递推, + 月末夹取到当月最后一天(1/31 开通 → 2/28 → 3/31,不会漂移成 3/28)。 + 业务日与每日免费池已经在北京时间口径上,账期必须同口径。 +2. **年付表达为「同一 `expires_at` 内的第 n 期」**:新增 `cycle_index`(第几期)与 `cycle_count`(12 或 1), + `cycle_resets_at` 继续当刷新锚点,`profile_membership` 仍然只有一行活跃记录。 + `expires_at` = 开通时刻 + 12 个月(同样夹取)。 +3. **每期刷新先清上期余额再发新额度**;到期未续:清月度余额、停发,永久泥点保留可用;重新选择套餐 + (含月转年、年转月、降档)一律在到期后进行,本期不即时转换。 +4. **取整口径固定**:金额向上取到分、补点向下取整。 + 年付应付 = `(新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余毫秒 / 本期实际毫秒)`; + 年付本期补点 = `(新档每期额度 − 当前档每期额度) × 本期剩余比例`; + 后续各期按新档额度发放,原刷新日与到期日不变。 +5. **升级后月度余额的上限是自动成立的**:余额 ≤ 当期已发放额度、新档每期额度 ≥ 旧的,因此 + 「原剩余 + 补点 ≤ 新档每期额度」不需要额外夹取;`cycle_granted_points` 同步补上本次补点, + 否则退款回填的上限判据会算错。 + +## 影响与代价 + +- 惰性刷新(无 cron,靠每次带符号变动与快照构建触发)保持不变,改造点是推进函数与期数字段。 +- 月末夹取与闰年把「本期剩余比例」的分母绑定到当期实际毫秒,需要在领域层用测试固化 + (1/31、2/28、闰年 2 月、12/31 跨年)。 +- `cycle_period_days` 不再是权威口径,需要明确它是兼容残留还是被 `cycle_count` / 自然月规则取代。 +- 旧档位枚举(`Month` / `Season` / `Year` 与 `Starter` / `Basic` / `Pro` / `Ultimate`)与会员权益表里的 + 按档位列出的字段要一并退役,否则后台会出现无法解释的选项。 + +## 备选方案与取舍 + +1. **继续固定 30 天,年付做成 12 次 30 天**:改动最小;代价是与「按开通日计期」和年付周年到期直接矛盾。 + 已作废。 +2. **自然月 + 每期一行会员记录**:期数可直接查询;代价是主键冲突、活跃行唯一性与扣减选行都要重做。已作废。 +3. **UTC 锚点**:与每日免费池口径不一致,会出现「同一天两个到期时间」。已作废。 + +## 明确不做 + +- 不做自动续费;本期是单次购买,到期后由用户重新选择套餐。 +- 不支持本期即时月转年、年转月与降档。 +- 不引入 cron 或后台定时任务来推进期数,继续用现有的惰性刷新。 diff --git a/docs/adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md b/docs/adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md new file mode 100644 index 000000000..08dc0b339 --- /dev/null +++ b/docs/adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md @@ -0,0 +1,60 @@ +# 【ADR】泥点三池以单一总额为权威 + +状态:已接受(2026-10-02;规则细则、里程碑与验收见 +[`【实施计划】双余额泥点与会员补差升级-2026-10-02`](../technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md)) + +## 背景 + +钱包今天只有一个权威数字:`profile_dashboard_state.wallet_balance`。它**包含**每日免费泥点和会员月度泥点, +不是「永久余额」,也不是「充值余额」。三个池子不是三份余额,而是对这一个数字的三种解读: + +- 每日免费池真实存储(`profile_daily_free_points.remaining_points`,北京时间 0 点清零重发); +- 会员周期池真实存储(`profile_membership.cycle_remaining_points`,到 `cycle_resets_at` 换期清零); +- 永久泥点在读取时由 `wallet_balance − 每日免费剩余 − 月度剩余` 推导 + (`build_profile_mud_point_balance_response`,`api-server/src/runtime_profile.rs`)。 + +也就是说,现在的「永久」不是一种来源,而是「没人认领的余数」:充值、邀请奖励、兑换码、每日任务、注册赠送, +以及任何回不到原池的退款都落在这里。由此产生两个后果:对外投影里 `totalPoints` 带着池语义 +(= 含每日与月度的总额),而 `permanentPoints` 是减法的产物,产品要的「两张独立余额卡片」在契约上并不成立。 + +边界:每日免费池、永久余数口径、账本与历史数据、充点商品与历史订单都在线上;只有会员侧(周期池、档位枚举、 +会员商品与权益)未上线,可以重做。 + +## 决策 + +1. **存储层保持单一权威总额。** 不新增 `permanent_balance` 列:每日免费存 `profile_daily_free_points`, + 月度存 `profile_membership.cycle_remaining_points`,永久泥点 = 总额 − 每日免费 − 月度。 + 只有一处写入锚点(`apply_profile_wallet_signed_delta`),三池不会互相打架。 +2. **对外投影把三池做成并列一等字段**:`dailyFreePoints` / `monthlyPoints` / `permanentPoints`, + 各自带自己的元数据(每日重置时间;月度到期日与刷新日、期数;永久无到期)。 + `totalPoints` 不再承担池语义,永久泥点也不允许表现为「总额的剩余」。 +3. **扣减顺序固定为 每日免费 → 月度 → 永久**;可用总额 = 月度 + 永久,每日免费单独展示与单独计量。 + 每日免费当天 24 点清零,先扣它对用户最有利,退款回填的账本感知逻辑也不用改。 +4. **非会员来源一律落入永久泥点**:充值、邀请、兑换码、每日任务、注册赠送,以及跨天 / 跨周期回填失败的退款。 +5. **TODO(本 ADR 的已知临时性)**:后续把永久泥点升级为独立存储列。触发条件是出现「必须区分充值本金与 + 奖励点数」(例如按充值金额精确回缴)或出现第二个需要独立写永久池的副作用。届时迁移口径为 + 「存量用户回填 `permanent = 现有余数` + 双写切换 + 保留回滚方案」,不改变本 ADR 的对外三池投影。 + +## 影响与代价 + +- 池层面的 bug 会表现为**永久泥点虚高**,而不是余额对不上;排查必须回到账本的 `permanentPointsDelta`。 +- 存储层不强制「永久只能由充值 / 奖励增加」,语义靠账本来源类型白名单保证。 +- 对外字段改名(月度从 `limitedPoints` 变为 `monthlyPoints`)会同步影响 `shared-contracts`、 + 前端共享组件、AGC 客户端与后台页面,必须同批改完。 +- 每日免费与永久余数在线,任何口径变化都是**在线契约变更**;`totalPoints` 的旧含义需要一次性退役干净, + 不留「有时含每日、有时不含」的歧义。 + +## 备选方案与取舍 + +1. **永久独立存储列(在线迁移)**:永久成为一等字段、可加白名单校验;代价是为存量用户回填、 + 双写切换、准备回滚,并让每日免费与月度的每次变动都要保证三处一致。已作废,记为 TODO。 +2. **三池都是真相源,`total` 改为派生投影**:领域模型最干净;代价是 `wallet_balance` 的所有读路径 + (账本 `balance_after`、后台快照、冻结 / 预留、对账工具)与全部历史账本数据都要改,在线风险最大。已作废。 +3. **保持现状(永久继续是余数且不改对外字段)**:零改动;代价是产品要的「两张独立余额卡片」在契约上 + 永远表达不出来。已作废。 + +## 明确不做 + +- 本轮不改 `wallet_balance` 的口径与在线数据结构,不做永久泥点独立存储。 +- 不给每日免费泥点引入过期以外的第二种语义(不并入可用总额、不参与「月度不足时补扣」)。 +- 不为奖励点数发明独立过期规则。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e1c5c9503..43b1f70b5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,18 @@ # 决策记录 +## 2026-10-02 泥点双余额与会员补差升级方案(拷问定稿) + +- 决策(对外投影):余额接口并列暴露每日免费泥点 / 月度泥点 / 永久泥点三池,永久泥点不再表现为「总额的剩余」;`totalPoints` 的池语义退役,`limitedPoints` 更名 `monthlyPoints`。存储层不动——`wallet_balance` 仍是唯一权威总额,永久泥点 = 总额 − 每日免费 − 月度,并留 TODO 待后续改为独立存储(触发条件见 ADR)。 +- 决策(扣减与展示):扣减顺序维持「每日免费 → 月度 → 永久」,可用总额 = 月度 + 永久,每日免费保留并单独计量与展示。 +- 决策(会员侧重做):会员侧未上线,旧档位 `Month/Season/Year` 与 `Basic/Ultimate` 及后台按档位权益字段直接删除;账期改为按开通日自然月(当月最后一天夹取),年付 12 期用 `cycle_index` / `cycle_count` 表达,仍只有一行活跃会员记录。 +- 决策(充点侧):6 档充点、取消首充赠送;复用旧 4 档 `points_*` id 并把赠送置 0,新增两档(`points_1280` / `points_3280`),不删除任何行(历史订单与首充资格按 `user_id + product_id` 引用)。 +- 决策(升级):后端只读报价接口 + 下单重算并落订单快照;会员订单复用 `profile_recharge_order`(新增 kind 与末位变更快照字段);新增账本来源 `MembershipUpgradeGrant`,幂等锚 `membership-upgrade-grant:{user}:{order_id}`;金额向上取到分、补点向下取整,年付按「后续完整月数 + 本期实际月长占比」;会员退款维持 `manual_review`,只靠订单快照支撑人工复核。 +- 边界:每日免费池、永久余数口径、账本与充点订单**在线上**;只有会员侧未上线,因此会员侧零迁移、钱包侧不允许口径漂移。 +- 决策(会员档位枚举化):会员档位身份改为 Rust 枚举 `RuntimeProfileMembershipPlan { Normal, Starter, Plus, Pro, Max }`,绝不字符串;新增 `profile_membership_plan` 目录表并**以该枚举为主键**(后台按档位改价 / 改权益);`profile_membership.tier` 与 `profile_recharge_product_config.tier` 两列删除、旧 `RuntimeProfileMembershipTier` 整体退役。会员侧无存量行,属经用户授权的破坏性变更;账本来源枚举仍只允许末尾追加 `MembershipUpgradeGrant`。 +- 决策(目录权益):目录一行一档,含月价、**独立可配置年价** `year_price_cents`、每期额度、模型权限 `Basic` / `Full`(本期只落字段、不执行拦截)、并发上限 `u32`(**哨兵 128 = 不设上限**,不用 `Option`);`Normal` = 0 点 / `Basic` / 并发 1 / rank 0。 +- 决策(目录数值):`Starter` ¥39 / 400 / ¥390 · `Plus` ¥99 / 1150 / ¥990 · `Pro` ¥299 / 3650 / ¥2990 · `Max` ¥699 / 8650 / ¥6990。 +- 权威入口:[实施计划](../../technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md);决策见 [`【ADR】泥点三池以单一总额为权威`](../../adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md)、[`【ADR】会员账期按开通日自然月`](../../adr/【ADR】会员账期按开通日自然月-2026-10-02.md)、[`【ADR】会员订单复用充值订单与补差升级幂等锚点`](../../adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md)、[`【ADR】会员档位以枚举为权威`](../../adr/【ADR】会员档位以枚举为权威-2026-10-02.md)。状态:方案定稿,未实施。 + ## 2026-10-01 游戏广场评分展示边界 - 用户确认广场卡片增加一位小数的 10 分制平均分与评分人数,无有效评价显示“暂无评分”;保留现有排序、筛选、卡片打开详情及返回上下文。 diff --git a/docs/technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md b/docs/technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md new file mode 100644 index 000000000..1aeaeefad --- /dev/null +++ b/docs/technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md @@ -0,0 +1,207 @@ +# 【实施计划】双余额泥点与会员补差升级 + +> 适用范围:`server-rs/crates`(SpacetimeDB 模块、`module-runtime`、`api-server`、`shared-contracts`) +> 与前端余额展示 / 充值弹层(`packages/shared`、`apps/ai-game-creator-shell`、`apps/admin-web`)。 +> 状态:方案定稿(2026-10-02),未实施。本文是开发依据;术语见 `CONTEXT.md`,决策见四份 ADR。 +> 字段级后端设计(DB schema / 账本 / 领域结构 / HTTP 契约 / 开放决策)见 +> [`【技术设计】泥点三池与会员计费后端设计-2026-10-02`](./【技术设计】泥点三池与会员计费后端设计-2026-10-02.md)。 + +## 0. 一句话交付与验收判据 + +一句话:把「一个总额 + 三视图」的钱包改造成对外**并列三池**(每日免费 / 月度 / 永久)的记账, +并在此之上建成月付 / 年付会员——按开通日自然月计期、年付 12 期逐月发放、支持补差升级与到期重选。 + +验收判据(全部满足才算完成): + +1. 余额接口并列返回每日免费泥点、月度泥点(含到期日、刷新日、期数)、永久泥点, + 不再出现「永久泥点 = 总额 − 其他池」的对外口径。 +2. 扣减顺序恒为 每日免费 → 月度 → 永久;可用总额 = 月度 + 永久;每日免费单独计量与展示。 +3. 月付 / 年付按**开通日自然月**计期;年付共 12 期,每期刷新先清上期余额再发新额度; + 到期未续清月度余额、停发,永久泥点保留可用。 +4. 补差升级按 §3.3 公式计算;金额向上取到分、补点向下取整;刷新日与到期日不变; + 防重复回调重复发点。 +5. 在线部分零回归:每日免费池、永久泥点口径、账本历史、充点订单与首充资格不受影响; + `npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。 + +## 1. 现状事实基线 + +| 关注点 | 现状 | +|---|---| +| 权威余额 | `profile_dashboard_state.wallet_balance`,**包含**每日免费与月度 | +| 每日免费池 | `profile_daily_free_points`(北京时间 `day_key`、`granted_points` / `remaining_points`),在线 | +| 月度池 | `profile_membership.cycle_*`(`cycle_started_at` / `cycle_resets_at` / `cycle_granted_points` / `cycle_remaining_points` / `cycle_period_days`),**固定 30 天、无年付、无期数**,会员侧未上线 | +| 永久池 | **推导余数** `wallet_balance − 每日免费剩余 − 月度剩余`,无独立表,在线 | +| 扣减顺序 | 每日免费 → 会员周期 → 永久(`apply_profile_wallet_signed_delta`) | +| 退款回填 | 账本感知,跨天 / 跨周期回填失败的金额落永久池 | +| 账本 | `profile_wallet_ledger` 只追加,幂等靠稳定 id;16 种来源类型,**无升级补点** | +| 充值商品 | `profile_recharge_product_config`:四档充点 `points_60/180/300/680`(基础 ¥1 = 10,后三档首充赠送 50%),会员商品 `duration_days` 全 30 | +| 对外投影 | `ProfileMudPointBalanceResponse`:`totalPoints = wallet_balance`、`limitedPoints = cycle_remaining_points`、`permanentPoints = 总额 − 每日 − 月度` | +| 后台权益 | `ProfileMembershipBenefitResponse` 含 `month/season/year/starter/basic/pro/ultimate` 七个旧档位字段 | +| 年付 / 补差 | 全仓无实现 | + +冲突点(本次必须一次性收口): + +- `totalPoints` 带池语义(含每日与月度),与「可用总额 = 月度 + 永久」直接冲突。 +- `limitedPoints` 是全仓唯一出现「限时」措辞的地方,与「月度泥点」规范词冲突。 +- 原细则表只写「月度优先扣减」,与权威契约的「每日免费优先」冲突——本方案维持每日免费优先。 +- 权威契约把充值商品写死为四档带首充赠送,与本方案的 6 档无赠送冲突。 + +## 2. 术语 + +规范词见 `CONTEXT.md` 的「泥点与会员计费」:每日免费泥点、月度泥点、**永久泥点**、永久余数、会员账期、 +补差升级、可用总额。实现侧只保留一个规范词:**永久泥点**(`_Avoid_: 不限时泥点`、充值余额、无限泥点)。 + +## 3. 规则细则(合并去重,开发以此节为准) + +### 3.1 双余额记账 + +| 规则 | 月度泥点 | 永久泥点 | +|---|---|---| +| 来源 | 会员每期额度 | 直接充值 + 非会员奖励(历史赠点保留) | +| 有效期 | 当期有效,到期清零 | 永不过期 | +| 扣减顺序 | 每日免费耗尽后优先 | 月度不足时补扣 | +| 刷新 | 下期发新额度,旧余额不结转 | 不刷新,保留余额 | +| 升级时 | 剩余保留 + 按 §3.3 补发差额 | 完全不变 | +| 展示 | 独立卡片 + 到期 / 刷新日期 | 独立卡片 + 永久标记 | + +**每日免费泥点保留**,并位于扣减顺序最前(本方案对原细则的补充): +每日免费当天 24 点清零,先扣它对用户最有利,且不用改动现有的退款回填语义。 +可用总额 = 月度 + 永久,每日免费单独一行展示,不并入可用总额。 + +示例:月度剩 100 + 永久 500,消费 150 → 先扣 100 月度再扣 50 永久。 + +「永久」指不过期,创作照常扣点。 + +### 3.2 账期与刷新 + +- 按**开通日**计期(9/20 开通 → 10/20 到期),不按自然月;锚点为北京时间的开通日时刻。 +- 每一期从**原始开通日**重算并夹取月末(1/31 开通 → 2/28 → 3/31),不从上一期递推。 +- 年付:开通发首期,后续按月再发 11 期,共 12 期;`expires_at` = 开通时刻 + 12 个月(同样夹取)。 +- 每期刷新:先清上期余额,再发新额度。 +- 到期未续:清月度余额、停发;永久泥点保留可用。 +- 本期为单次购买,不自动续费;到期后由用户重新选择套餐(月转年、年转月、降档一律到期后再选)。 + +### 3.3 补差升级 + +``` +月付应付 = 新月价 − 当前月价 +月付补点 = 新套餐每期额度 − 当前套餐每期额度 +年付应付 = (新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余时间比例) +年付本期补点 = (新每期额度 − 当前每期额度) × 本期剩余时间比例 +后续各期额度 = 新档每期额度 +``` + +- 本期剩余时间比例 = 本期剩余毫秒 / 本期实际毫秒(`cycle_started_at` → `cycle_resets_at`); + 后续完整月数 = 从本期 `cycle_resets_at` 到 `expires_at` 之间剩余的完整自然月期数。 +- 费用向上取到分,补点向下取整。 +- 升级后月度余额 = 原剩余月度余额 + 补点(已用点不返还,永久点不动); + `cycle_granted_points` 同步 +补点,否则退款回填的上限判据会算错。 +- 有效期 = 原刷新日与原到期日不变。 +- 连续升级以**当前套餐**为比较基准(Starter→Plus 再 →Pro 时按 Plus vs Pro 算,不回到最初套餐)。 +- 支付成功才变更;失败 / 关闭不动原状态;防重复回调重复发点。 + +参考示例(口径来自产品细则,实施时用领域测试固化): + +| 变更 | 补付 | 补点 | 变更后月度余额 | 有效期 | +|---|---|---|---|---| +| Plus 月 → Pro 月 | ¥200 | 2,500 | 3,115(原剩 615) | 原到期日不变 | +| Plus 年 → Pro 年 | ¥1,083.34 | 1,250 | 1,865(原剩 615) | 原到期日不变 | + +### 3.4 允许操作矩阵 + +| 当前状态 | 可操作 | 不可操作 | +|---|---|---| +| 无会员 / 已到期 | 开任意月付或年付 | — | +| 月付会员 | 升更高月付 | 即时月转年、降档(到期后再选) | +| 年付会员 | 升更高年付 | 年转月、降档(到期后再改) | +| 同套餐同周期 | — | 重复购买(显示「当前套餐」) | + +### 3.5 与现状的差异清单 + +1. `limitedPoints` → `monthlyPoints`(全仓改名,含 `shared-contracts`、`packages/shared` 契约、AGC、后台)。 +2. `totalPoints` 的池语义退役:对外只给三个池;不提供「可用总额」后端字段,前端按需相加。 +3. 会员周期从固定 30 天改为按开通日自然月;新增 `cycle_index` / `cycle_count`。 +4. 新增年付(12 期)与补差升级;新增后端只读报价接口。 +5. 账本来源枚举新增 `MembershipUpgradeGrant`。 +6. 会员订单复用 `profile_recharge_order` + 末位变更快照字段。 +7. 充点商品复用旧 4 档 id 并把赠送置 0(取消首充赠送),新增 ¥128 / ¥328 两档,共 6 档;不删除任何行(历史订单与首充资格引用 `product_id`)。 +8. 会员档位改为枚举 `RuntimeProfileMembershipPlan { Normal, Starter, Plus, Pro, Max }`; + 会员目录迁到 `profile_membership_plan` 并以**该枚举为主键**(后台按档位改价); + 删除 `RuntimeProfileMembershipTier`、`profile_membership.tier` 与 `profile_recharge_product_config.tier`。 + +## 4. 数据模型与账本 + +- **不动**:`profile_dashboard_state.wallet_balance`(在线唯一权威总额)、`profile_daily_free_points`、 + `profile_wallet_ledger` 既有形状与 id 方案、永久余数推导口径。 +- `profile_membership`:**删除** `tier` / `cycle_period_days`;新增 `plan`(`RuntimeProfileMembershipPlan` 枚举,字段名 `plan`、类型必须是枚举)/ `cycle_index` / `cycle_count` / `cycle_kind`。 +- 新增 `profile_membership_plan`:**以档位枚举为主键**,含标题、rank、月价、年价(独立可配置字段)、每期额度、模型权限、并发上限(`128` = 不设上限)、排序与上下架;一行一档,月 / 年共享每期额度。 +- `profile_recharge_product_config`:**删除** `tier` 与 `membership_period_points` / `membership_period_days` / `membership_queue_limit` / `membership_discount_bps`;删 4 行 `member_*`;充点复用旧 4 档 id 并置 `bonus_points = 0`,新增两档(`points_1280` / `points_3280`)。 +- 新增账本来源类型 `MembershipUpgradeGrant`;幂等锚 `membership-upgrade-grant:{user}:{order_id}`。 +- `profile_recharge_order` 末尾追加会员变更快照字段(变更前 / 后档位、周期、补点数、有效期、kind);快照字段全部用枚举,不用字符串。 +- schema 变更必须同步 `migration.rs`、表目录、生成绑定,并运行 `npm run check:spacetime-schema`; + 已有表新增字段一律放在结构体最后并带明确默认值。 +- 会员侧删列 / 删枚举属**破坏性变更**(会员未上线,已授权):迁移计划与回退见技术设计 §2.7; + 账本来源枚举仍只允许末尾追加 `MembershipUpgradeGrant`。 + +## 5. 接口契约 + +| 接口 | 变更 | +|---|---| +| `GET /api/profile/recharge-center` 等余额投影 | 并列三池;`monthlyPoints` 带到期日 / 刷新日 / 期数;不再暴露池语义的 `totalPoints` | +| `POST /api/profile/membership/upgrade-quote`(新增) | 只读报价:入参目标档位 + 目标周期,返回应付金额、补点、变更后月度余额、有效期;目标档位 token 严格解析进枚举 | +| 会员购买 / 升级下单 | **复用** `POST /api/profile/recharge/orders`;`product_id` = 目标计划 id,下单时重算并落订单快照 | +| `GET|POST /admin/api/profile/membership-plans`(新增) | 后台套餐配置,按档位枚举改月价 / 年价 / 每期额度 / 模型权限 / 并发上限 | +| `GET|POST /admin/api/profile/recharge-products` | 6 档充点商品的上下架与新档位 | + +`/api/external/v1` 不涉及本方案;若实施中触及其路由、DTO 或语义,必须在同一次变更中同步修订 +`docs/openapi/genarrative-external-v1.openapi.json` 与契约测试。 + +## 6. 里程碑 + +### M1 双余额与月度会员(不含年付与补差) + +- 交付物:三池并列投影(含 `monthlyPoints` 改名与 `totalPoints` 退役)、自然月账期、 + `profile_membership_plan`(档位枚举主键)与月度会员购买、充点 6 档(复用旧 id + 新增 2 档)、 + 会员档位枚举化与旧 `tier` / 旧权益字段退役。 +- 验收判据:§0 的判据 1、2、5;月付会员可购买、可到期清零、可在新期收到新额度。 +- 必测点:三池恒等式(总额 = 每日 + 月度 + 永久)、扣减顺序与可用总额、月末夹取(1/31 → 2/28 → 3/31)、 + 每日免费与月度同时存在时的扣减拆分元数据、退款跨期落永久池、旧充点商品历史订单仍可展示。 + +### M2 年付 12 期与补差升级 + +- 交付物:年付(12 期)、后端只读报价接口、`MembershipUpgradeGrant` 与订单维度幂等、 + 订单变更快照、后台套餐配置页。 +- 验收判据:§0 的判据 3、4;年付 12 期逐月发放;升级补付 / 补点与 §3.3 示例一致; + 重复回调不重复发点;退款进 `manual_review` 且快照可读。 +- 必测点:金额向上取到分 / 补点向下取整的边界(含 0.5 期、闰年、12/31 跨年)、 + 连续升级基准、报价与下单重算一致、支付失败不动原状态、年付到期未续清月度保留永久。 + +## 7. 风险与边界 + +- **永久余数是临时实现**:池层面的 bug 会表现为永久泥点虚高,排查必须回到账本 `permanentPointsDelta`(见 ADR 的 TODO)。 +- **在线契约变更**:`totalPoints` / `limitedPoints` 的消费方分布在后端投影、`packages/shared` 契约与 store、 + `PlatformMudPointWalletEntry`、`PlatformProfileRechargeModal`、AGC `AccountWallet` / `useAccountWallet`、 + admin-web 多处;漏改会静默显示错数字。必须一次性改完并全量跑相关测试。 +- **升级补点不可自动回收**:它只有「发」没有「扣」的对称半边,退款只能人工复核。 +- **惰性刷新没有 cron**:到期与时区问题只在用户被触碰时暴露,测试必须直接构造时间。 +- 跨天 / 跨周期退款仍然有损(落永久池),这是刻意行为,不要「顺手修」。 + +## 8. 待确认输入与遗留 TODO + +- **套餐目录数值已定**:`Starter` ¥39 / 400 点 / ¥390 · `Plus` ¥99 / 1150 / ¥990 · `Pro` ¥299 / 3650 / ¥2990 · `Max` ¥699 / 8650 / ¥6990(`Normal` 全 0);模型权限 Starter 仅基础、其余含高性能;并发 1 / 3 / 5 / 128(不设上限)。 +- **充点 6 档金额已定**:复用旧 4 档 id 并置赠送 0,新增 `points_1280`(¥128 / 1280 点)与 `points_3280`(¥328 / 3280 点);数值按原型 ¥6 / 18 / 30 / 68 / 128 / 328、¥1 = 10 泥点固化。 +- **TODO(永久泥点独立存储)**:见 `docs/adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md` 第 5 条。 +- **TODO(文档同步)**:实施时同批修订权威契约 + `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的「账户充值数据契约」与 + 「用户钱包与编辑器生成扣费契约」两节,以及 `docs/【编辑器】模型定价配置管理方案-2026-06-22.md` 中 + 与扣费顺序相关的表述。 + +## 9. 需同步修订的权威文档 + +| 文档 | 修订点 | +|---|---| +| `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` | 三池投影与扣减顺序、永久泥点来源、会员账期与年付、升级补点与幂等、充值商品档位与首充赠送、账本来源全集 | +| `docs/【项目基线】当前产品与工程约束-2026-05-15.md` | 「用户钱包与编辑器生成扣费契约」的余额字段 | +| `docs/【编辑器】模型定价配置管理方案-2026-06-22.md` | 与扣费顺序 / 月度池相关的表述 | +| `docs/project-memory/shared-memory/decision-log.md` | 本次四条决策(已追加 2026-10-02 条目) | +| `CONTEXT.md` | 新增「泥点与会员计费」术语 | diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md new file mode 100644 index 000000000..a42613790 --- /dev/null +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -0,0 +1,428 @@ +# 【技术设计】泥点三池与会员计费后端设计 + +> 定位:承接 [`【实施计划】双余额泥点与会员补差升级-2026-10-02`](./【实施计划】双余额泥点与会员补差升级-2026-10-02.md) +> 与四份 ADR,给出**可直接评审**的后端 DB schema、账本与幂等、领域数据结构、HTTP 契约。 +> 范围:`server-rs/crates`(`spacetime-module`、`module-runtime`、`spacetime-client`、`api-server`、`shared-contracts`)。 +> 状态:**评审版(2026-10-02)**;两轮拷问结论已就地并入本文(见 §9)。 +> 在线边界:每日免费池、永久泥点口径、钱包账本、充点订单**在线**;会员侧未上线,可零迁移重做。 + +--- + +## 0. 一句话 + +把「一个总额 + 三视图」的钱包改造成对外**并列三池**(每日免费 / 月度 / 永久),并把会员侧整体重做: +**档位身份改为 Rust 枚举**、目录表**以该枚举为主键**、按开通日自然月计期、年付 12 期与补差升级; +存储层保持 `wallet_balance` 单一权威总额不变。 + +--- + +## 1. 硬约束(设计前提) + +1. **在线不可漂移**:`profile_dashboard_state.wallet_balance`、`profile_daily_free_points`、 + `profile_wallet_ledger` 的字段形状与账本 id 方案不动。 +2. **默认只追加、不重排**:已有表新增字段一律放结构体末尾并带明确 `#[default(...)]`。 + 本轮例外:**会员侧**经用户明确授权做一次破坏性变更(删列 + 删/换枚举),依据是会员侧无存量行, + 迁移计划见 §2.7;钱包侧与充点订单不允许破坏性变更。 +3. ⚠️ **枚举按变体索引编码**:删除或重排变体会让已落库的行错位。因此: + - 账本来源枚举 `RuntimeProfileWalletLedgerSourceType` 仍**只允许末尾追加** `MembershipUpgradeGrant`; + - `RuntimeProfileMembershipTier` **整体删除**,前提是会员侧零存量行(用户已确认)。 +4. **幂等靠稳定账本 id**;扣 / 退账本 id 前缀对称性必须保留。 +5. **金额与取整只有后端一处实现**,前端展示价不是真相源。 +6. **会员档位身份必须是 Rust 枚举、禁止字符串**(用户硬性要求)。对外 wire 只传枚举 token, + 服务端**严格解析**进枚举,不接受自由字符串。 + +--- + +## 2. 数据模型 + +### 2.1 变更总览 + +| 表 / 对象 | 动作 | +|---|---| +| `profile_dashboard_state` | 不动(唯一权威总额 `wallet_balance`) | +| `profile_daily_free_points` | 不动 | +| `profile_wallet_ledger` | 形状不动;**末尾追加**来源枚举变体 `MembershipUpgradeGrant` | +| `profile_membership` | **删除** `tier` / `cycle_period_days`;**新增** `plan`(枚举) / `cycle_index` / `cycle_count` / `cycle_kind` | +| `profile_recharge_product_config` | **删除** `tier` 与四个 `membership_*` 列;删 4 行 `member_*`;充点 6 档重播种 | +| `profile_recharge_order` | 末尾**追加**会员变更快照字段 | +| `profile_membership_plan`(新增) | 会员目录,**以档位枚举为主键** | +| 枚举 | 新增 `RuntimeProfileMembershipPlan` / `RuntimeProfileMembershipCycleKind` / `RuntimeProfileMembershipModelAccess` / `RuntimeProfileMembershipChangeKind`;**删除** `RuntimeProfileMembershipTier`;账本来源追加 `MembershipUpgradeGrant` | + +### 2.2 会员档位枚举 + 目录表 `profile_membership_plan`(已定) + +**档位身份 = 枚举**(不再是字符串档位 id): + +```rust +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipPlan { + Normal, // 0 非会员 + Starter, // 1 + Plus, // 2 + Pro, // 3 + Max, // 4 +} +``` + +**目录表用该枚举当主键**(用户要求:后台要按档位直接改价 / 改权益): + +```rust +#[spacetimedb::table(accessor = profile_membership_plan)] +#[derive(Clone)] +pub struct ProfileMembershipPlan { + #[primary_key] + pub(crate) plan: RuntimeProfileMembershipPlan, + pub(crate) title: String, + pub(crate) rank: u8, + pub(crate) month_price_cents: u64, + pub(crate) year_price_cents: u64, + pub(crate) period_points: u64, + pub(crate) model_access: RuntimeProfileMembershipModelAccess, + pub(crate) concurrent_job_limit: u32, + pub(crate) sort_order: i32, + pub(crate) enabled: bool, + pub(crate) created_by: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_by: String, + pub(crate) updated_at: Timestamp, +} +``` + +- **一行一档**:月付与年付共享同一 `period_points`(年付 = 12 次同额度发放)。 + `cycle_count` 由 `cycle_kind` 推导(`Monthly = 1` / `Yearly = 12`),不落目录字段。 +- **年价是独立可配置字段** `year_price_cents`(Q6),不再按 `月价 × 10` 推导。 +- **并发 `concurrent_job_limit: u32`,哨兵 `128` 表示「不设上限」**(Q5,明确不使用 `Option`)。 +- **模型权限 `model_access` 只新增字段,本期不实现任何限制**(Q4);服务端提交路径本期不拦截。 +- 旧会员目录(`member_starter` / `member_basic` / `member_pro` / `member_ultimate` 四行 + `module-runtime` 静态查表) + 随之退役,见 §2.8。 +- 种子数据(用户给定,`enabled`:`Normal = false`,其余 `true`): + +| plan | rank | 月价 | 年价 | 每期泥点 | 模型权限 | 并发上限 | +|---|---|---|---|---|---|---| +| `Normal` | 0 | 0 | 0 | 0 | `Basic` | 1 | +| `Starter` | 1 | ¥39 = 3900 | ¥390 = 39000 | 400 | `Basic` | 1 | +| `Plus` | 2 | ¥99 = 9900 | ¥990 = 99000 | 1150 | `Full` | 3 | +| `Pro` | 3 | ¥299 = 29900 | ¥2990 = 299000 | 3650 | `Full` | 5 | +| `Max` | 4 | ¥699 = 69900 | ¥6990 = 699000 | 8650 | `Full` | 128(不设上限 for display ) | + +- `Normal`(Q7 已确认):0 每期泥点、`ModelAccess::Basic`、并发 1、rank 0、不可购买。 + +### 2.3 `profile_membership` 列变更 + +| 列 | 动作 | 类型 / 默认 | 说明 | +|---|---|---|---| +| `tier` | **删除** | — | 旧枚举整体删除,不保留兼容列 | +| `cycle_period_days` | **删除** | — | 自然月规则取代固定 30 天 | +| `plan` | 新增 | `RuntimeProfileMembershipPlan`,默认 `Normal` | 权威档位身份;字段名 `plan`,**类型必须是枚举** | +| `cycle_index` | 新增 | `u32`,默认 `0` | 当前第几期(`1..=cycle_count`) | +| `cycle_count` | 新增 | `u32`,默认 `1` | 本次购买总期数(月付 1 / 年付 12) | +| `cycle_kind` | 新增 | `RuntimeProfileMembershipCycleKind`,默认 `Monthly` | 周期类型(落列,不靠 `plan` 反查) | + +`status` / `started_at` / `expires_at` / `updated_at` / `cycle_started_at` / `cycle_resets_at` / +`cycle_granted_points` / `cycle_remaining_points` 语义不变。 + +### 2.4 `profile_recharge_order` 追加列(会员变更快照) + +全部**末位追加 + 默认值**;快照字段一律用枚举,不用字符串。 + +| 列 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `membership_change_kind` | `Option` | `None` | `Purchase` / `Upgrade` | +| `membership_plan_before` | `Option` | `None` | 变更前档位 | +| `membership_plan_after` | `Option` | `None` | 变更后档位 | +| `membership_cycle_kind_before` | `Option` | `None` | 变更前周期 | +| `membership_cycle_kind_after` | `Option` | `None` | 变更后周期 | +| `membership_period_points_before` | `Option` | `None` | 变更前每期额度 | +| `membership_period_points_after` | `Option` | `None` | 变更后每期额度 | +| `membership_granted_points_delta` | `i64` | `0` | 本次补点(进月度池的点数) | +| `membership_cycle_index` | `Option` | `None` | 变更时所在期数 | +| `membership_expires_at_before` | `Option` | `None` | 变更前到期 | +| `membership_expires_at_after` | `Option` | `None` | 变更后到期 | +| `membership_cycle_resets_at_before` | `Option` | `None` | 变更前刷新日 | +| `membership_cycle_resets_at_after` | `Option` | `None` | 变更后刷新日 | +| `membership_price_breakdown_json` | `Option` | `None` | 报价明细(剩余完整月数 / 剩余比例 / 单价 / 取整),审计与人工复核用 | + +- `points_delta` 语义不变 = 「钱包里加了几个泥点」;会员购买 / 升级时 = 本次入月度池的点数。 +- 退款仍走 `manual_review`,快照只读,不自动回滚。 + +### 2.5 枚举增删 + +- 新增 `RuntimeProfileMembershipPlan { Normal, Starter, Plus, Pro, Max }`。 +- 新增 `RuntimeProfileMembershipCycleKind { Monthly, Yearly }`(全新,无历史数据)。 +- 新增 `RuntimeProfileMembershipModelAccess { Basic, Full }`(仅新增字段,本期不执行)。 +- 新增 `RuntimeProfileMembershipChangeKind { Purchase, Upgrade }`(订单快照用,替代字符串)。 +- **删除** `RuntimeProfileMembershipTier { Normal, Month, Season, Year, Starter, Basic, Pro, Ultimate }` + 及其 `as_str()`(会员侧零存量,用户已授权破坏性变更)。 +- `RuntimeProfileWalletLedgerSourceType` **末尾追加** `MembershipUpgradeGrant` + (`as_str = "membership_upgrade_grant"`),同步 `shared-contracts` 常量与后台来源筛选。 + +### 2.6 充点商品:复用旧 id + 新增 2 档(已定,不变) + +- 现状 4 档:`points_60` ¥6 / `points_180` ¥18 / `points_300` ¥30 / `points_680` ¥68(后三档首充赠送 50%)。 +- 决策:**复用旧 4 档 id**,把 `bonus_points` 置 0(取消首充赠送,文案同步去掉「首充加赠」), + 新增 `points_1280`(¥128 / 1280 点)与 `points_3280`(¥328 / 3280 点),共 6 档,基础比例仍 ¥1 = 10 泥点。 +- 旧行不删除:历史订单与首充资格按 `user_id + product_id` 引用仍可解读;同一 `product_id` 的规则随版本变化已在本文注明。 + +### 2.7 破坏性迁移计划(Q2 = (a),已获用户明确授权) + +**前提**:会员侧未上线 → `profile_membership` 无存量行;`profile_recharge_product_config` 是**种子配置**, +可整体重播种,不承载用户数据。 + +1. `profile_membership`:删 `tier` / `cycle_period_days`;加 `plan` / `cycle_index` / `cycle_count` / `cycle_kind`。 +2. `profile_recharge_product_config`:删 `tier` + `membership_period_points` / `membership_period_days` / + `membership_queue_limit` / `membership_discount_bps`;删 4 行 `member_*`;重播种 6 档充点。 +3. 新增表 `profile_membership_plan` 并写入 §2.2 种子。 +4. `profile_recharge_order` 末位追加 §2.4 快照列。 +5. `profile_wallet_ledger` 来源枚举末尾追加 `MembershipUpgradeGrant`。 +6. 同步 `server-rs/crates/spacetime-module/src/migration.rs`、后端架构文档表目录、生成绑定; + 运行 `npm run spacetime:generate` 与 `npm run check:spacetime-schema`。 + +⚠️ **风险与回退**:SpacetimeDB 持久表的删列属破坏性迁移,自动迁移不保证支持。 +实施时必须用真实 `spacetime publish`(本地库)验证第 1、2 步;若平台拒绝原地删列,则 +**回退为「保留列 + 写死默认值 + 运行时不读」**,并在生成绑定与表目录里明确标注为废弃残留。 +两种路径都必须先让 schema 门禁通过,不得使用 `--delete-data=always` 绕过。 + +### 2.8 会员侧遗留清理清单 + +实施时**删除**(不是废弃),并同步修掉引用它们的测试: + +- `module-runtime`:`RuntimeProfileMembershipTier` 枚举、`runtime_profile_membership_tier_rank`、 + `runtime_profile_membership_period_points`、`runtime_profile_membership_queue_limit`、 + `runtime_profile_membership_discount_bps`、`runtime_profile_membership_benefits`、 + `RuntimeProfileMembershipBenefitSnapshot`、`runtime_profile_recharge_membership_products`、 + `PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS`、`build_membership_recharge_product` 中的会员分支。 +- `spacetime-module`:`canonical_membership_pricing_tier`、`canonical_membership_pricing_tier` 驱动的 + `membership_product_price_cents_by_tier`、`apply_active_membership_renew_row` 的固定天数推进、 + `membership_cycle_reset_at` 固定天数实现,以及 `membership_discount_bps` 折扣概念。 +- 数据与契约:`RuntimeProfileMembershipRecord` / `Snapshot` 的 `tier` / `cycle_period_days`、 + `RuntimeProfileRechargeProductSnapshot` / `Record` / `ConfigSnapshot` 的 `tier` 与四个 `membership_*` 字段、 + DTO `ProfileMembershipResponse.tier`、`ProfileRechargeProductResponse.tier`、后台商品 upsert 输入的 `tier`。 +- 测试:`legacy_membership_tiers_share_new_pricing_ranks`、`recharge_product_catalog_matches_reference_prices` + 中的会员档位断言等,改为按 §2.2 种子断言。 + +钱包 / 泥点 / 充点订单侧**不动**(`profile_wallet_ledger`、`profile_daily_free_points`、 +`profile_dashboard_state` 与 6 档充点商品除外)。 + +### 2.9 表定义所在文件更正 + +真正生效的表定义在 **`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`**: +`src/runtime.rs` 用 `#[path = "runtime/active/profile.rs"] mod profile;` 挂载。 +`server-rs/crates/spacetime-module/src/runtime/profile.rs` 是**未被引用的旧副本**, +实施前先确认它的处置方式(删除或与 `active/` 对齐),避免文档指向失效文件。 + +--- + +## 3. 领域数据结构(`module-runtime`) + +```rust +pub struct RuntimeProfileMembershipPlanSnapshot { + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub rank: u8, + pub cycle_count: u32, // 月付 1 / 年付 12,由 cycle_kind 推导 + pub price_cents: u64, // 按所选周期取 month/year 价 + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, +} + +pub struct RuntimeProfileMembershipCycleRef { + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub cycle_started_at_micros: i64, + pub cycle_resets_at_micros: i64, +} + +pub struct RuntimeProfileMembershipUpgradeQuote { + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub cycle_index: u32, + pub remaining_full_months: u32, + pub remaining_ratio_ppm: u32, +} +``` + +`RuntimeProfileMembershipSnapshot` / `RuntimeProfileMembershipRecord`: +`tier` → `plan`(枚举),删 `cycle_period_days`,新增 `cycle_index` / `cycle_count` / `cycle_kind`。 + +纯函数(无 IO,可单测): + +- `membership_cycle_window(anchor_micros, index, cycle_kind) -> (start, reset)`: + 北京时间锚点 + 自然月推进 + 月末夹取,**从原始锚点重算**(1/31 → 2/28 → 3/31)。 +- `quote_membership_upgrade(current, target, now) -> quote`: + 金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 +- `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、 + `cycle_granted_points += 补点`、`plan` 更新,刷新日与到期日不变。 +- `is_unlimited_concurrency(limit) -> bool`:`limit >= 128` 视为不设上限(哨兵值集中在领域层一处)。 + +--- + +## 4. 写路径(SpacetimeDB) + +```mermaid +flowchart TD + A[下单 reducer: 购买 / 升级] -->|支付成功回调| B[应用会员权益] + B -->|purchase| C[发首期: plan, cycle_index=1, cycle_count] + B -->|upgrade| D[补点: cycle_remaining += delta
ledger=membership-upgrade-grant] + C --> E[membership-period-grant 账本] + F[带符号变动 / 读快照] -->|cycle_resets_at 到期| G[先清后发: cycle_index+1] + F -->|expires_at 到期| H[清月度, 停发, 保留永久] + G --> E + I[创作扣费] --> J[每日免费 → 月度 → 永久] +``` + +- **购买**:`apply_profile_membership_purchase`(改造)→ 写 `plan` / `cycle_index=1` / `cycle_count` / + 自然月窗口 / 发首期额度;账本 `membership-period-grant:{user}:{micros}:{plan}`(沿用)。 +- **换期**:`refresh_profile_membership_cycle`(改造)→ 自然月推进 `cycle_index+1`,先清上期再发新期; + `cycle_index >= cycle_count` 或 `expires_at` 到期 → 清月度、停发、保留永久。 + 账本沿用 `membership-period-reset:{user}:{reset_micros}`(`action = "reset"` / `"expire"`)。 +- **升级**:`apply_active_membership_upgrade`(改造,替换 `apply_active_membership_upgrade_row`)→ + `cycle_remaining += 补点`、`cycle_granted_points += 补点`、`plan = 目标`;账本 + `membership-upgrade-grant:{user}:{order_id}`(订单维度天然幂等),来源 `MembershipUpgradeGrant`。 +- **下单金额**:`resolve_profile_membership_order_amount_cents`(改造)→ 以 `plan` + `cycle_kind` 比较 + (替换按 `tier` 的 `resolve_active_membership_purchase_mode` / `membership_product_price_cents_by_tier`), + 升级金额改用 §6 的补差公式。 +- **扣减 / 退款回填**:**不改** `apply_profile_wallet_signed_delta` 的三池顺序(每日免费 → 月度 → 永久), + `cycle_resets_at` 仍是退款回填的对齐锚点;跨天 / 跨周期回填失败的金额继续落永久池。 +- 年付后续期换期的账本来源**沿用** `MembershipPeriodReset`(净额「先清后发」语义);期数放在 metadata,不新增枚举变体。 + +--- + +## 5. HTTP 契约 + +### 5.1 余额投影 `ProfileMudPointBalanceResponse`(重做) + +```jsonc +{ + "dailyFreePoints": { "points": 20, "grantedPoints": 20, "resetsAt": "...", "dayKey": 20261002 }, + "monthlyPoints": { + "points": 615, "grantedPoints": 3650, + "expiresAt": "...", "resetsAt": "...", + "cycleIndex": 3, "cycleCount": 12, + "plan": "pro", "planTitle": "Pro 年付" + }, + "permanentPoints": 1482 +} +``` + +- 删除:`totalPoints`(池语义的总额)、`limitedPoints`、`limitedExpiresAt`。 +- 三池并列,各自带元数据;永久泥点无到期字段。 +- 决策:**不提供** `availableTotalPoints`;`walletBalance`(原始总额)从用户面移除,仅后台 / 内部对账使用。 + +### 5.2 会员信息 `ProfileMembershipResponse`(改) + +- 新增 `plan`(枚举 token,如 `"pro"`)/ `planTitle` / `cycleKind` / `cycleIndex` / `cycleCount`。 +- **删除** `tier`(旧枚举整体退役,不再对外暴露)。 +- 保留 `status` / `startedAt` / `expiresAt` / `cycleStartedAt` / `cycleResetsAt` / + `cycleGrantedPoints` / `cycleRemainingPoints`。 + +### 5.3 升级报价(新增,只读) + +`POST /api/profile/membership/upgrade-quote` + +```jsonc +// request +{ "targetPlan": "pro", "targetCycleKind": "yearly" } +// response +{ + "currentPlan": "plus", "currentCycleKind": "yearly", + "targetPlan": "pro", "targetCycleKind": "yearly", + "amountCents": 108334, + "grantedPointsDelta": 1250, + "monthlyBalanceAfter": 1865, + "expiresAt": "...", "cycleResetsAt": "...", "cycleIndex": 3, + "breakdown": { "remainingFullMonths": 6, "remainingRatioPpm": 500000 } +} +``` + +- 纯读、不落库;下单时后端按同一组输入重算并落订单快照(§2.4)。 +- `targetPlan` 严格解析进 `RuntimeProfileMembershipPlan`,未知 token 直接 4xx。 +- **唯一解析器**:档位 / 周期 / 模型权限 / 变更类型的 wire token 一律经各自的 `parse()` 解析 + (`RuntimeProfileMembershipPlan::parse` 等),返回 `None` 即拒绝;禁止在各处手写字符串匹配。 + +### 5.4 下单(复用现有链路) + +`POST /api/profile/recharge/orders { product_id, payment_channel }`。 +决策:**复用此单接口**;会员订单的 `product_id` = 目标计划 token(枚举字符串化), +后端按当前会员状态区分购买 / 升级并重算金额。 + +### 5.5 充值中心 `ProfileRechargeCenterResponse`(改) + +- `mudPointBalance` 用 §5.1;`membership` 用 §5.2;会员列表来自 `profile_membership_plan`(含 `plan` / 月价 / 年价 / 每期泥点 / 模型权限 / 并发上限)。 +- `walletBalance` 不在用户面暴露;会员商品行不再出现在充点商品列表里。 + +### 5.6 后台(新增 / 改) + +- 新增 `GET|POST /admin/api/profile/membership-plans`(按档位枚举 CRUD 价格与权益)。 +- `GET|POST /admin/api/profile/recharge-products` 继续管 6 档充点商品。 +- 订单列表 / 退款人工复核读取 §2.4 快照(含变更前后档位与补点)。 + +--- + +## 6. 计算规则(后端唯一真相源) + +- **自然月推进**:从原始开通日锚点出发,第 n 期 = 锚点 + n 个自然月(月末夹取),不走固定 30 天。 +- **月付**:`应付 = 新月价 − 当前月价`;`补点 = 新每期额度 − 当前每期额度`。 +- **年付**: + `应付 = (新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余毫秒 / 本期实际毫秒)`; + `补点 = (新每期额度 − 当前每期额度) × 本期剩余比例`。 +- **取整**:金额向上取到分,补点向下取整。 +- **档位比较**:用目录 `rank`,不用旧 `tier` 枚举排名。 +- **一致性**:报价接口与下单重算必须逐字段一致(契约测试)。 + +--- + +## 7. 兼容与迁移 / 在线边界 + +- 旧充点行不删除(复用 id 并去掉赠送);首充资格与历史订单继续可读。 +- 旧会员档位枚举与会员商品行**删除**(会员侧未上线,无需兼容)。 +- **不做** `tier → plan` 回填;若实施时发现意外存量会员行,先停下评估再补。 +- 钱包侧(总额、每日免费、永久余数、账本)口径不允许漂移;充点订单不允许破坏性变更。 + +--- + +## 8. 测试矩阵 + +- 三池恒等式:`wallet_balance == daily_free + monthly + permanent`(构造式)。 +- 扣减拆分元数据 `dailyFreePointsDelta` / `membershipPeriodPointsDelta` / `permanentPointsDelta`。 +- 跨天 / 跨周期退款落永久池(不凭空补点)。 +- 自然月边界:1/31 → 2/28 → 3/31、闰年 2 月、12/31 跨年。 +- 档位目录:`plan` 枚举为主键、`Normal` 语义、`year_price_cents` 独立于月价、 + `concurrent_job_limit = 128` 判定为不设上限。 +- DTO 严格解析:非法 `plan` token 拒绝;响应不含 `tier`。 +- 升级报价 == 下单重算;订单维度幂等(重复回调不重复发点)。 +- 支付失败 / 关闭不动原状态;同套餐同周期拒绝下单。 +- 月付 / 年付到期未续:清月度、保留永久、停发。 + +--- + +## 9. 评审结论 + +| # | 问题 | 结论 | +|---|---|---| +| 1 | 会员目录 | 独立 `profile_membership_plan` 表,且**主键是档位枚举**(后台按档位改价 / 改权益) | +| 2 | 档位身份形态 | 必须是 Rust 枚举 `RuntimeProfileMembershipPlan`,绝不字符串;字段名 `plan` | +| 3 | 迁移路径 | **(a) 破坏性**:删两处 `tier` 列、删 `member_*` 行、干净定义新枚举;已授权覆盖「枚举只追加」 | +| 4 | `cycle_kind` | 落 `profile_membership` 列,不靠 `plan` 反查 | +| 5 | 对外档位 | 只给 `plan`;**删除** `tier`(不再保留兼容列 / 字段) | +| 6 | 模型限制 | 只新增 `model_access` 字段,**本期不实现限制** | +| 7 | 并发 | 不用 `Option`;`u32` 哨兵 `128` = 不设上限 | +| 8 | 年价 | 新增可配置字段 `year_price_cents`,不做 ×10 推导 | +| 9 | `Normal` 权益 | 0 每期泥点、`Basic`、并发 1、rank 0 | +| 10 | 会员遗留清理 | 全部删除(会员侧离线),见 §2.8 | +| 11 | 充点 6 档 | 复用旧 4 档 id 并把 `bonus_points` 置 0;新增 `points_1280` / `points_3280` | +| 12 | 年付换期账本 | 沿用 `MembershipPeriodReset` | +| 13 | 可用总额 | 不提供后端字段;前端按需相加 | +| 14 | 下单接口 | 复用 `POST /api/profile/recharge/orders` | +| 15 | `walletBalance` | 用户面不暴露 | + +## 10. 仍需你确认的输入 + +- **并发与模型权限本期不执行**:目录字段与投影照常暴露,但服务端拦截留到后续里程碑——如需本期就拦,请明确。 +- **`profile_recharge_product_config` 原地删列**:若 SpacetimeDB 本地发布拒绝,回退为「保留列 + 默认值 + 不读」,是否接受该回退。 +- **旧副本 `runtime/profile.rs`**:确认删除。