diff --git a/docs/README.md b/docs/README.md index f03df27e1..1ec79f75c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -100,8 +100,8 @@ ## 钱包与会员计费 -- [双余额泥点与会员补差升级实施计划](./technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md):每日免费 / 月度 / 永久三池并列投影、按开通日自然月计期、年付 12 期与补差升级的规则细则、里程碑与验收判据;方案定稿,未实施。 -- [泥点三池与会员计费后端设计](./technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md):可直接评审的后端 DB schema、账本与幂等、领域数据结构与 HTTP 契约;会员档位改为枚举、目录表以枚举为主键,含破坏性迁移计划与遗留清理清单;待评审,未实施。 +- [双余额泥点与会员补差升级实施计划](./technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md):每日免费 / 月度 / 永久三池并列投影、按开通日自然月计期、年付 12 期与补差升级的规则细则、里程碑与验收判据;已落地(会员购买与升级的 C 端 UI 未做)。 +- [泥点三池与会员计费后端设计](./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` 账本幂等、后端只读报价与退款人工复核边界。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 43b1f70b5..5016cbdb5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -11,7 +11,13 @@ - 决策(会员档位枚举化):会员档位身份改为 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)。状态:方案定稿,未实施。 +- 权威入口:[实施计划](../../technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md);设计见 [`【技术设计】泥点三池与会员计费后端设计`](../../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)。 +- 状态:**已落地**。后端(领域模块、`profile_membership_plan` 表与播种、两处 `tier` 列删除、订单变更快照、自然月账期、6 档充点、三池余额投影、升级报价接口、后台档位接口)、契约生成(ts-rs → `packages/shared/src/contracts/generated/`)、共享组件与平台入口三池展示、AGC 壳、后台「会员档位」页与充值商品页收敛均已实现并通过门禁。明确不做:模型权限与并发上限只落字段、不提供 `availableTotalPoints`、会员购买 / 升级的 C 端 UI。 +- 实现期补充决断(未改变上述范围): + - 余额 DTO 由 Rust 单一真源经 ts-rs 导出,前端 re-export 而非手写第二份字段列表;`u64` 标 `ts(type = "number")` 避免 `bigint` 分叉,`check:generated-bindings` 以「重新生成逐字节比对」守漂移。 + - 「合计」由前端派生(`packages/shared/src/utils/mudPoints.ts` 的 `resolveProfileMudPointBalanceView`),月度刷新时刻固定按北京时间格式化,不把第二份口径放回后端。 + - 会员订单 `product_id` 编码「档位 + 周期」(`membership-{plan}-{cycleKind}`),会员商品不进 `profile_recharge_product_config`;支付确认不做出售校验,避免付款后档位被下架导致权益丢失。 + - 后台 `rank` 不可改(升级比较基准由代码内目录决定),`normal` 档位的价格与额度由后端强制归零。 ## 2026-10-01 游戏广场评分展示边界 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 50bb79dd0..d34df3264 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -316,72 +316,109 @@ flowchart TD ## 5. HTTP 契约 -### 5.1 余额投影 `ProfileMudPointBalanceResponse`(重做) +### 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 年付" - }, + "dailyFreePoints": 20, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-10-03T00:00:00+08:00", + "monthlyPoints": 615, + "monthlyResetsAt": "2026-11-20T09:00:00+08:00", "permanentPoints": 1482 } ``` - 删除:`totalPoints`(池语义的总额)、`limitedPoints`、`limitedExpiresAt`。 -- 三池并列,各自带元数据;永久泥点无到期字段。 +- 三个池子都是**扁平标量**:月度泥点的档位、期序号、期总数等元数据挂在 + `ProfileMembershipResponse`(§5.2),余额投影只负责「各池还剩多少 + 各自什么时候失效」。 - 决策:**不提供** `availableTotalPoints`;`walletBalance`(原始总额)从用户面移除,仅后台 / 内部对账使用。 +- 需要「合计」时由前端派生(`packages/shared/src/utils/mudPoints.ts` 的 + `resolveProfileMudPointBalanceView`),不把第二份口径放回后端。 -### 5.2 会员信息 `ProfileMembershipResponse`(改) +### 5.2 会员信息 `ProfileMembershipResponse`(改,已落地) -- 新增 `plan`(枚举 token,如 `"pro"`)/ `planTitle` / `cycleKind` / `cycleIndex` / `cycleCount`。 +- 新增 `plan`(档位 token,如 `"pro"`;非会员与已到期统一为 `"normal"`)/ + `cycleKind`(`monthly` / `yearly`)/ `cycleIndex` / `cycleCount`。 +- **不提供** `planTitle`:展示标题属于目录表 `profile_membership_plan.title`, + 由充值中心的 `membershipPlans` 提供,避免同一档位在两个响应里各带一份可变标题。 - **删除** `tier`(旧枚举整体退役,不再对外暴露)。 +- 删除 `cyclePeriodDays`(固定 30 天已被自然月账期取代)。 - 保留 `status` / `startedAt` / `expiresAt` / `cycleStartedAt` / `cycleResetsAt` / `cycleGrantedPoints` / `cycleRemainingPoints`。 -### 5.3 升级报价(新增,只读) +### 5.3 升级报价(新增,只读,已落地) `POST /api/profile/membership/upgrade-quote` ```jsonc -// request -{ "targetPlan": "pro", "targetCycleKind": "yearly" } +// request:只给目标档位;周期沿用当前会员周期(有效期内不支持月年互换) +{ "targetPlan": "pro" } // response { - "currentPlan": "plus", "currentCycleKind": "yearly", - "targetPlan": "pro", "targetCycleKind": "yearly", + "currentPlan": "plus", + "targetPlan": "pro", + "cycleKind": "yearly", + "cycleIndex": 3, + "cycleCount": 12, "amountCents": 108334, "grantedPointsDelta": 1250, "monthlyBalanceAfter": 1865, - "expiresAt": "...", "cycleResetsAt": "...", "cycleIndex": 3, - "breakdown": { "remainingFullMonths": 6, "remainingRatioPpm": 500000 } + "expiresAt": "...", + "cycleResetsAt": "...", + "remainingFullMonths": 6 } ``` - 纯读、不落库;下单时后端按同一组输入重算并落订单快照(§2.4)。 +- 周期不接受入参:`targetCycleKind` 不在契约里,月转年 / 年转月是拒绝项(到期后重新选择)。 - `targetPlan` 严格解析进 `RuntimeProfileMembershipPlan`,未知 token 直接 4xx。 - **唯一解析器**:档位 / 周期 / 模型权限 / 变更类型的 wire token 一律经各自的 `parse()` 解析 (`RuntimeProfileMembershipPlan::parse` 等),返回 `None` 即拒绝;禁止在各处手写字符串匹配。 +- 日期字段是 `expiresAt` / `cycleResetsAt`(RFC3339 字符串),不带 `*Micros` 双字段。 -### 5.4 下单(复用现有链路) +### 5.4 下单(复用现有链路,已落地) `POST /api/profile/recharge/orders { product_id, payment_channel }`。 -决策:**复用此单接口**;会员订单的 `product_id` = 目标计划 token(枚举字符串化), -后端按当前会员状态区分购买 / 升级并重算金额。 +决策:**复用此单接口**;会员订单的 `product_id` 编码「档位 + 周期」: +`membership-{plan}-{cycleKind}`(如 `membership-pro-yearly`), +由 `runtime_profile_membership_product_id()` / `parse_runtime_profile_membership_product_id()` 双向解析。 -### 5.5 充值中心 `ProfileRechargeCenterResponse`(改) +- 会员商品**没有** `profile_recharge_product_config` 行:标题与价格都来自目录表, + 订单行按 `product_id` 现算商品快照(`build_membership_order_product`)。 +- 后端按当前会员状态区分购买(无有效会员 → 目录价)与升级(有效期内 → 补差重算), + 金额在创建订单与支付确认两处都走同一函数 `resolve_profile_membership_order_amount_cents`。 +- 支付确认时即使档位已下架,已支付订单仍按原档位结算(`resolve_profile_recharge_order_product` + 不做出售校验),避免「付款后档位被后台下架」导致权益丢失。 -- `mudPointBalance` 用 §5.1;`membership` 用 §5.2;会员列表来自 `profile_membership_plan`(含 `plan` / 月价 / 年价 / 每期泥点 / 模型权限 / 并发上限)。 -- `walletBalance` 不在用户面暴露;会员商品行不再出现在充点商品列表里。 +### 5.5 充值中心 `ProfileRechargeCenterResponse`(改,已落地) -### 5.6 后台(新增 / 改) +- `mudPointBalance` 用 §5.1;`membership` 用 §5.2。 +- 会员列表改为 `membershipPlans: RuntimeProfileMembershipPlanRecord[]`:一行一档,含 `plan` / + `title` / `rank` / 月价 / 年价 / 每期泥点 / 模型权限 / 并发上限 / 排序 / 可购买, + 由 `profile_membership_plan` 表按 `sort_order` 升序读出。 +- 删除 `walletBalance`、`membershipProducts`、`benefits`(旧 Month/Season/Year 权益矩阵)。 +- `pointProducts` 只剩泥点商品(会员商品行不再出现在商品配置表里)。 + +### 5.6 后台(新增 / 改,已落地) - 新增 `GET|POST /admin/api/profile/membership-plans`(按档位枚举 CRUD 价格与权益)。 -- `GET|POST /admin/api/profile/recharge-products` 继续管 6 档充点商品。 -- 订单列表 / 退款人工复核读取 §2.4 快照(含变更前后档位与补点)。 + - `rank` 不进请求:升级比较基准由代码内目录决定,后台不能改。 + - `normal` 是不可购买的非会员占位档位,价格与额度由后端按 0 落库、`enabled` 强制 `false`。 + - 非 `normal` 档位要求月价 / 年价 / 并发上限都大于 0,否则 4xx。 +- 后台页:新增「会员档位」页(`AdminMembershipPlanPage`);「充值商品」页收敛为纯泥点商品 + (`AdminRechargeProductPage` 不再有会员档位与 `membership_*` 字段)。 +- 订单列表 / 退款人工复核读取 §2.4 快照(`membershipChange`,含变更前后档位、周期、补点与有效期)。 + +### 5.7 契约生成(ts-rs,已落地) + +- 余额 DTO 的形状由 Rust 单一真源导出,前端**不再手写第二份字段列表**: + `cargo test -p shared-contracts --features ts-bindings export_bindings` 生成 + `packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts`, + `packages/shared/src/contracts/runtime.ts` 只 re-export 成 `ProfileMudPointBalance`。 +- `u64` 字段加 `ts(type = "number")`:JSON 走数字,不用 `bigint`,避免序列化与前端类型分叉。 +- `npm run check:generated-bindings` 以「重新生成并与工作区逐字节比较」做门禁,防止 Rust 改了而生成文件没重跑。 +- 需要展示用派生字段(如 `totalPoints` 合计)时,在前端 `utils/mudPoints.ts` 里派生,不改 DTO。 --- @@ -453,22 +490,40 @@ flowchart TD ### 10.2 实施状态 -已落地(各自独立可编译): +已全部落地(工作区可编译、测试全绿): + +后端: - 会员领域模块 `module-runtime/src/membership/{catalog,cycle,limits,upgrade}.rs`,公历基元抽为 `civil_calendar`。 - 账本来源枚举末尾追加 `MembershipUpgradeGrant`,并同步契约常量、后台索引映射、前端来源标签与生成绑定。 -- 新增表 `profile_membership_plan`(主键为档位枚举 `RuntimeProfileMembershipPlan`),同步 `migration.rs`、表目录与生成绑定。 -- 会员档位目录播种 `ensure_default_profile_membership_plan`:表为空时按代码内目录整表播种,表非空时保留后台改价。 -- 删除未被引用的旧副本 `runtime/profile.rs`。 +- 新增表 `profile_membership_plan`(主键为档位枚举 `RuntimeProfileMembershipPlan`),同步 `migration.rs`、表目录与生成绑定; + 播种 `ensure_default_profile_membership_plan`:表为空时按代码内目录整表播种,表非空时保留后台改价。 +- 删列与清退:`profile_membership` 去掉 `tier` / `cycle_period_days`、追加 + `plan` / `cycle_index` / `cycle_count` / `cycle_kind`;`profile_recharge_product_config` 去掉 + `tier` 与四个 `membership_*` 字段;`RuntimeProfileMembershipTier`、会员权益矩阵、 + `canonical_membership_pricing_tier`、`membership_discount_bps`、固定 30 天常量全部退役。 +- `profile_recharge_order` 末位追加会员变更快照列(档位 / 周期 / 补点 / 有效期 / 价格拆分 JSON), + 购买与升级都会落快照;退款仍走人工复核,不自动回滚。 +- 自然月账期:`advance_profile_membership_cycle_to` 每期从原始锚点重算(1/31 → 2/28 → 3/31), + 到期清月度、停发、保留永久;6 档充点商品取消首充赠送,`¥128` / `¥328` 两档由停用改为在售。 +- HTTP:三池余额投影、`ProfileMembershipResponse.plan`、`POST /api/profile/membership/upgrade-quote`、 + `GET|POST /admin/api/profile/membership-plans`;会员订单复用 `POST /api/profile/recharge/orders`。 -待落地——**第 1、2 项必须作为同一个提交落地**,否则工作区不可编译: +前端与契约: -1. `module-runtime` 领域层清退:删 `RuntimeProfileMembershipTier`、会员权益快照/记录、`cycle_period_days`、`tier` 与充点商品上的四个 `membership_*` 字段;会员快照/记录改用 `plan` + `cycle_index` / `cycle_count` / `cycle_kind`;充值中心改用 `membership_plans`。 -2. `spacetime-module`:两张表的列增删、充值中心装配、后台商品 upsert 收敛为泥点商品、会员目录价格与自然月周期逻辑改走 `plan` + 目录表。 -3. `spacetime-client` mapper / facade 与 `api-server` DTO / 后台接口同步;重新生成绑定。 -4. 三池余额投影 `ProfileMudPointBalanceResponse`、`ProfileMembershipResponse.plan`、`POST /api/profile/membership/upgrade-quote` 与下单重算。 -5. 充点 6 档落库与 `resolve_default_point_product_migration` 中 `points_1280` / `points_3280` 的历史反例修正。 -6. `packages/shared` 契约与共享组件同步。 -7. 门禁:`SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1 npm run check:spacetime-schema`、`npm run check:generated-bindings`、`npm run check:encoding`、`git diff --check`。 +- `ProfileMudPointBalanceResponse` 由 ts-rs 生成到 `packages/shared/src/contracts/generated/`, + `packages/shared/src/contracts/runtime.ts` 只 re-export;`check:generated-bindings` 守住漂移。 +- 共享组件与平台入口改用三池:`PlatformMudPointWalletEntry` 明细面板并列「永久泥点 / 月度泥点 / 每日免费泥点」, + 充值弹层余额读三池合计,`ProfileRechargeCenterResponse` 不再有 `walletBalance`。 +- 展示用合计在 `packages/shared/src/utils/mudPoints.ts` 派生(`totalPoints`),月度刷新时刻固定按北京时间格式化。 +- 后台:新增「会员档位」页,充值商品页收敛为纯泥点商品。 + +未做(依决策保留,不是遗漏): + +- 模型权限与并发上限**只落字段**,服务端不做拦截。 +- 不提供 `availableTotalPoints`;合计由前端派生。 +- 会员购买 / 升级的 C 端 UI 未做(会员侧未上线,充值弹层只陈列泥点商品)。 +- 永久泥点仍是「总额 − 每日免费 − 月度」的推导余数,独立存储见 + [`【ADR】泥点三池以单一总额为权威`](../../adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md) 的 TODO 与触发条件。 > 约束来源:`scripts/check-spacetime-schema-guard.mjs` 只做源码静态比对,破坏性变更必须显式带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 才会放行;而 `module-runtime` 与 `spacetime-module` 相互依赖,任一侧单独清退都会让另一侧无法编译,所以领域层清退必须与 schema 变更同批提交。